From 0b32338c643d13ff7545f46399085d0cebdf8836 Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Wed, 15 Jul 2026 16:56:53 -0700 Subject: [PATCH 1/6] add device list command --- .../Device/IoTHubDeviceListCommand.cs | 109 +++++++++ .../src/Models/DeviceListResult.cs | 10 + .../Options/Device/IoTHubDeviceListOptions.cs | 12 + .../Device/IoTHubDeviceListCommandTests.cs | 228 ++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceListCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceListResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceListOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceListCommandTests.cs diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceListCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceListCommand.cs new file mode 100644 index 0000000000..6d7641b89f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceListCommand.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceListCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-list"; + public override string Name => "list"; + public override string Description => "List devices in an IoT Hub device registry. Returns each device identity metadata without authentication keys. " + + "This is the preferred command for listing devices, including when the user asks for a small, specific number of devices (e.g. 'list 2 devices'); set --max-count to the requested number. " + + "Use --max-count to set the page size (default 100, maximum 100). Values greater than 100 are capped at 100, so one call returns at most 100 devices. " + + "For natural-language user requests to list all devices, the rest of the devices, more than 100 devices, or any large device set, use the 'iothub query run' command (MCP tool iothub_query_run) with a compact projection instead of this list command, but still return only one page and do not loop for additional pages. " + + "For normal list/show responses, present a compact summary with deviceId, status, connectionState, and any user-requested tags or fields. Do not include full raw JSON unless the user explicitly asks for raw device identity metadata. " + + "Hub names/IDs are case-sensitive and must match exactly."; + public override string Title => "List IoT Hub Devices"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private const int DefaultMaxCount = 100; + private const int MinMaxCount = 1; + private const int MaxMaxCount = DefaultMaxCount; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.MaxCount.AsOptional()); + } + + protected override IoTHubDeviceListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + var maxCount = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.MaxCount.Name); + options.MaxCount = maxCount switch + { + null => DefaultMaxCount, + > MaxMaxCount => MaxMaxCount, + _ => maxCount.Value + }; + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + if (options.MaxCount < MinMaxCount) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = $"The entered max-count '{options.MaxCount}' is less than 1 device. Please specify a value of at least 1."; + return context.Response; + } + + try + { + var result = await _service.ListDevices( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.MaxCount, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.DeviceListResult); + + if (result.Truncated) + { + context.Response.Message = $"Showing the first {options.MaxCount} devices. The hub contains more devices, but the results were truncated because the maximum is {options.MaxCount}."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing devices in IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + TimeoutException => HttpStatusCode.RequestTimeout, + _ => base.GetStatusCode(ex) + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceListResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceListResult.cs new file mode 100644 index 0000000000..508c708cc4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceListResult.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record DeviceListResult( + [property: JsonPropertyName("devices")] List Devices, + [property: JsonPropertyName("truncated")] bool Truncated); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceListOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceListOptions.cs new file mode 100644 index 0000000000..e23857815b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceListOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceListOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public int? MaxCount { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceListCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceListCommandTests.cs new file mode 100644 index 0000000000..3b091af54e --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceListCommandTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubDeviceListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubDeviceListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeviceListCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeviceListCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_ListDevices_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expectedDevices = new List + { + new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null), + new DeviceIdentity("device2", "gen2", "bbbb==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null) + }; + + _service.ListDevices( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new DeviceListResult(expectedDevices, false)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_ListDevices_WithMaxCount_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var maxCount = 10; + + var expectedDevices = new List + { + new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z", 0, new DeviceAuthentication("SAS"), null) + }; + + _service.ListDevices( + name, + resourceGroup, + subscription, + maxCount, + Arg.Any(), + Arg.Any()) + .Returns(new DeviceListResult(expectedDevices, false)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--max-count", maxCount.ToString() + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_MaxCountGreaterThanOneHundred_CapsAtOneHundred() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + _service.ListDevices( + name, + resourceGroup, + subscription, + 100, + Arg.Any(), + Arg.Any()) + .Returns(new DeviceListResult([], false)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--max-count", "500" + ]); + + // Act + await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + await _service.Received(1).ListDevices( + name, + resourceGroup, + subscription, + 100, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ListDevices_Truncated_SetsMessage() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var pageDevices = new List + { + new DeviceIdentity("device1", "gen1", "aaaa==", "Connected", "Enabled", null, null, null, null, 0, new DeviceAuthentication("SAS"), null) + }; + + _service.ListDevices( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new DeviceListResult(pageDevices, true)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response.Results); + Assert.NotNull(response.Message); + Assert.Contains("truncated", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_MaxCountLessThanOne_ReturnsBadRequest() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--max-count", "0" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("less than 1", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-device-list", _command.Id); + Assert.Equal("list", _command.Name); + Assert.NotNull(_command.Description); + Assert.Contains("more than 100 devices", _command.Description, StringComparison.Ordinal); + Assert.Contains("iothub query run", _command.Description, StringComparison.Ordinal); + Assert.Contains("iothub_query_run", _command.Description, StringComparison.Ordinal); + Assert.Contains("compact projection", _command.Description, StringComparison.Ordinal); + Assert.Contains("compact summary", _command.Description, StringComparison.Ordinal); + Assert.Contains("return only one page", _command.Description, StringComparison.Ordinal); + Assert.Contains("do not loop", _command.Description, StringComparison.Ordinal); + Assert.True(_command.Metadata.ReadOnly); + Assert.False(_command.Metadata.Secret); + } +} From 87b54df2112824a22e2113a669b9b60531243430 Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Wed, 15 Jul 2026 17:14:02 -0700 Subject: [PATCH 2/6] add device list files --- servers/Azure.Mcp.Server/src/Program.cs | 322 ++---------- .../src/Azure.Mcp.Tools.IoTHub.csproj | 21 + .../src/Commands/IoTHubJsonContext.cs | 41 ++ .../Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs | 105 ++++ .../src/Options/IoTHubOptionDefinitions.cs | 121 +++++ .../src/Services/IIoTHubDeviceService.cs | 70 +++ .../src/Services/IIoTHubService.cs | 59 +++ .../src/Services/IoTHubDeviceService.cs | 451 ++++++++++++++++ .../src/Services/IoTHubService.cs | 496 ++++++++++++++++++ 9 files changed, 1401 insertions(+), 285 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHubOptionDefinitions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubDeviceService.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubService.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubDeviceService.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 70f9c8394b..b763b48288 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -1,154 +1,52 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Net; -using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Core.Services.Http; +using Azure.Mcp.Core.Services.ProcessExecution; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Core.Services.Time; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Mcp.Core.Areas; -using Microsoft.Mcp.Core.Areas.Server; -using Microsoft.Mcp.Core.Areas.Server.Commands; -using Microsoft.Mcp.Core.Areas.Server.Commands.Discovery; -using Microsoft.Mcp.Core.Areas.Server.Commands.ServerInstructions; -using Microsoft.Mcp.Core.Areas.Server.Commands.ToolLoading; -using Microsoft.Mcp.Core.Areas.Server.Models; -using Microsoft.Mcp.Core.Areas.Server.Options; -using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Extensions; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Models; -using Microsoft.Mcp.Core.Models.Command; -using Microsoft.Mcp.Core.Services.Caching; -using Microsoft.Mcp.Core.Services.ProcessExecution; -using Microsoft.Mcp.Core.Services.Telemetry; -using Microsoft.Mcp.Core.Services.Time; - -namespace Azure.Mcp.Server; +using ServiceStartCommand = Azure.Mcp.Core.Areas.Server.Commands.ServiceStartCommand; internal class Program { - private static readonly IAreaSetup[] Areas = RegisterAreas(); - - // Derived from the registered ServerSetup instance so the name stays in sync - // with the actual area registration — no magic string duplication. - private static readonly string ServerAreaName = - Array.Find(Areas, static a => a is Microsoft.Mcp.Core.Areas.Server.ServerSetup)?.Name ?? "server"; + private static IAreaSetup[] Areas = RegisterAreas(); private static async Task Main(string[] args) { try { - // Fast path: Handle simple metadata requests without initializing service infrastructure - // This optimization reduces startup time from ~10s to <3s for these queries - var fastPathResult = TryHandleFastPathRequest(args); - if (fastPathResult.HasValue) - { - return fastPathResult.Value; - } - - // The server start and plugin-telemetry containers always need full area registration. - ServiceStartCommand.ConfigureServices = services => ConfigureServices(services); + ServiceStartCommand.ConfigureServices = ConfigureServices; ServiceStartCommand.InitializeServicesAsync = InitializeServicesAsync; - PluginTelemetryCommand.ConfigureServices = services => ConfigureServices(services); - PluginTelemetryCommand.InitializeServicesAsync = InitializeServicesAsync; - - // Optimization: detect the target service area early so we can skip registering the - // other 60+ areas in the DI container. - var targetAreaName = GetTargetAreaName(args); - ServiceCollection services = new(); - - ConfigureServices(services, targetAreaName); + ConfigureServices(services); services.AddLogging(builder => { + builder.ConfigureOpenTelemetryLogger(); + // Send console logs to stderr so stdout carries only the command's JSON + // response. Keeps CLI output parseable and avoids corrupting stdio MCP output. builder.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace); builder.SetMinimumLevel(LogLevel.Information); }); var serviceProvider = services.BuildServiceProvider(); + await InitializeServicesAsync(serviceProvider); - // Optimization: run telemetry initialization concurrently with CommandFactory resolution. - // Telemetry init reads the MAC address (NetworkInterface) and device ID (registry on Windows) - // from the thread pool. CommandFactory resolves command singletons for the target area. - // Both complete in parallel so neither adds to the other's latency on the hot path. - // - // If GetRequiredService throws before we reach the await, telemetryInitTask would become - // an unobserved faulted task. Observe it (suppressing its exception) in that path so the - // DI failure is what surfaces to the user — not a TaskScheduler.UnobservedTaskException. - var telemetryInitTask = InitializeServicesAsync(serviceProvider); - ICommandFactory commandFactory; - try - { - commandFactory = serviceProvider.GetRequiredService(); - } - catch - { - await telemetryInitTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - throw; - } - await telemetryInitTask; - - // Short-circuit for --learn: return command metadata without executing. - // This MUST happen before Parse/InvokeAsync so that System.CommandLine's - // required-option validation cannot block the discovery response, and to - // avoid wastefully parsing 250+ commands and options only to discard the result. - if (Array.Exists(args, a => string.Equals(a, ICommandFactory.LearnOptionName, StringComparison.OrdinalIgnoreCase))) - { - var learnJson = commandFactory.GetLearnResponse(args); - Console.WriteLine(learnJson); - using var learnDoc = JsonDocument.Parse(learnJson); - var learnStatus = learnDoc.RootElement.TryGetProperty("status", out var statusEl) - ? statusEl.GetInt32() - : (int)HttpStatusCode.InternalServerError; - return (learnStatus >= (int)HttpStatusCode.OK && learnStatus < (int)HttpStatusCode.MultipleChoices) ? 0 : 1; - } - + var commandFactory = serviceProvider.GetRequiredService(); var rootCommand = commandFactory.RootCommand; var parseResult = rootCommand.Parse(args); - var command = parseResult.CommandResult.Command; - int status = 0; - - if (command is ExtendedCommand extendedCommand && - (extendedCommand.BaseCommand is ServiceStartCommand || extendedCommand.BaseCommand is PluginTelemetryCommand)) - { - // One of the special commands that need to be handled differently. - status = await parseResult.InvokeAsync(); - } - else - { - // Command wasn't one of the registered ServerSetup commands, so bind up a Host of all the services - // to run the command. - var builder = Host.CreateApplicationBuilder(); - builder.Logging.ClearProviders(); - builder.Logging.AddEventSourceLogger(); - ConfigureServices(builder.Services); - builder.Services.AddAzureMcpServer(new() - { - Transport = TransportTypes.StdIo - }); - - using var host = builder.Build(); - - await InitializeServicesAsync(host.Services); - await host.StartAsync(); - - commandFactory = host.Services.GetRequiredService(); - rootCommand = commandFactory.RootCommand; - parseResult = rootCommand.Parse(args); - - status = await parseResult.InvokeAsync(); - - await host.StopAsync(); - await host.WaitForShutdownAsync(); - } + var status = await parseResult.InvokeAsync(); if (status == 0) { @@ -174,43 +72,35 @@ private static IAreaSetup[] RegisterAreas() return [ // Register core areas + new Azure.Mcp.Tools.AzureAIBestPractices.AzureAIBestPracticesSetup(), new Azure.Mcp.Tools.AzureBestPractices.AzureBestPracticesSetup(), new Azure.Mcp.Tools.Extension.ExtensionSetup(), new Azure.Mcp.Core.Areas.Group.GroupSetup(), - new Microsoft.Mcp.Core.Areas.Server.ServerSetup(), + new Azure.Mcp.Core.Areas.Server.ServerSetup(), new Azure.Mcp.Core.Areas.Subscription.SubscriptionSetup(), - new Microsoft.Mcp.Core.Areas.Tools.ToolsSetup(), + new Azure.Mcp.Core.Areas.Tools.ToolsSetup(), // Register Azure service areas new Azure.Mcp.Tools.Aks.AksSetup(), new Azure.Mcp.Tools.AppConfig.AppConfigSetup(), new Azure.Mcp.Tools.AppLens.AppLensSetup(), new Azure.Mcp.Tools.AppService.AppServiceSetup(), new Azure.Mcp.Tools.Authorization.AuthorizationSetup(), - new Azure.Mcp.Tools.AzureBackup.AzureBackupSetup(), new Azure.Mcp.Tools.AzureIsv.AzureIsvSetup(), new Azure.Mcp.Tools.ManagedLustre.ManagedLustreSetup(), - new Azure.Mcp.Tools.AzureMigrate.AzureMigrateSetup(), - new Azure.Mcp.Tools.AzureTerraform.AzureTerraformSetup(), new Azure.Mcp.Tools.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), new Azure.Mcp.Tools.Deploy.DeploySetup(), - new Azure.Mcp.Tools.DeviceRegistry.DeviceRegistrySetup(), + new Azure.Mcp.Tools.Dps.DpsSetup(), new Azure.Mcp.Tools.EventGrid.EventGridSetup(), new Azure.Mcp.Tools.Acr.AcrSetup(), - new Azure.Mcp.Tools.Advisor.AdvisorSetup(), new Azure.Mcp.Tools.BicepSchema.BicepSchemaSetup(), new Azure.Mcp.Tools.Cosmos.CosmosSetup(), new Azure.Mcp.Tools.CloudArchitect.CloudArchitectSetup(), - new Azure.Mcp.Tools.Communication.CommunicationSetup(), - new Azure.Mcp.Tools.Compute.ComputeSetup(), new Azure.Mcp.Tools.ConfidentialLedger.ConfidentialLedgerSetup(), - new Azure.Mcp.Tools.ContainerApps.ContainerAppsSetup(), new Azure.Mcp.Tools.EventHubs.EventHubsSetup(), - new Azure.Mcp.Tools.FileShares.FileSharesSetup(), - new Azure.Mcp.Tools.FoundryExtensions.FoundryExtensionsSetup(), + new Azure.Mcp.Tools.Foundry.FoundrySetup(), new Azure.Mcp.Tools.FunctionApp.FunctionAppSetup(), - new Azure.Mcp.Tools.Functions.FunctionsSetup(), new Azure.Mcp.Tools.Grafana.GrafanaSetup(), - new Azure.Mcp.Tools.Insights.InsightsSetup(), + new Azure.Mcp.Tools.IoTHub.IoTHubSetup(), new Azure.Mcp.Tools.KeyVault.KeyVaultSetup(), new Azure.Mcp.Tools.Kusto.KustoSetup(), new Azure.Mcp.Tools.LoadTesting.LoadTestingSetup(), @@ -219,23 +109,18 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.Monitor.MonitorSetup(), new Azure.Mcp.Tools.ApplicationInsights.ApplicationInsightsSetup(), new Azure.Mcp.Tools.MySql.MySqlSetup(), - new Azure.Mcp.Tools.Policy.PolicySetup(), new Azure.Mcp.Tools.Postgres.PostgresSetup(), - new Azure.Mcp.Tools.Pricing.PricingSetup(), new Azure.Mcp.Tools.Redis.RedisSetup(), - new Azure.Mcp.Tools.ResilienceManagement.ResilienceManagementSetup(), + new Azure.Mcp.Tools.Communication.CommunicationSetup(), + new Azure.Mcp.Tools.DeviceRegistry.DeviceRegistrySetup(), new Azure.Mcp.Tools.ResourceHealth.ResourceHealthSetup(), new Azure.Mcp.Tools.Search.SearchSetup(), new Azure.Mcp.Tools.Speech.SpeechSetup(), new Azure.Mcp.Tools.ServiceBus.ServiceBusSetup(), - new Azure.Mcp.Tools.ServiceFabric.ServiceFabricSetup(), new Azure.Mcp.Tools.SignalR.SignalRSetup(), - new Azure.Mcp.Tools.SreAgent.SreAgentSetup(), new Azure.Mcp.Tools.Sql.SqlSetup(), new Azure.Mcp.Tools.Storage.StorageSetup(), - new Azure.Mcp.Tools.StorageSync.StorageSyncSetup(), new Azure.Mcp.Tools.VirtualDesktop.VirtualDesktopSetup(), - new Azure.Mcp.Tools.WellArchitectedFramework.WellArchitectedFrameworkSetup(), new Azure.Mcp.Tools.Workbooks.WorkbooksSetup(), #if !BUILD_NATIVE // IMPORTANT: DO NOT MODIFY OR ADD EXCLUSIONS IN THIS SECTION @@ -249,7 +134,9 @@ private static IAreaSetup[] RegisterAreas() } private static void WriteResponse(CommandResponse response) - => Console.WriteLine(JsonSerializer.Serialize(response, ModelsJsonContext.Default.CommandResponse)); + { + Console.WriteLine(JsonSerializer.Serialize(response, ModelsJsonContext.Default.CommandResponse)); + } /// /// @@ -262,7 +149,7 @@ private static void WriteResponse(CommandResponse response) /// /// 's command picking: The container used to populate instances of /// and selected by - /// based on the command line input. This container is a local variable in + /// baesd on the command line input. This container is a local variable in /// , and it is not tied to /// Microsoft.Extensions.Hosting.IHostBuilder (stdio) nor any /// Microsoft.AspNetCore.Hosting.IWebHostBuilder (http). @@ -288,7 +175,7 @@ private static void WriteResponse(CommandResponse response) /// on or , both of which have /// transport-specific implementations. This method can add the stdio-specific /// implementation to allow the first container (used for command picking) to work, - /// but such transport-specific registrations must be overridden within + /// but such transport-specific registrations must be overriden within /// with the appropriate /// transport-specific implementation based on command line arguments. /// @@ -298,26 +185,12 @@ private static void WriteResponse(CommandResponse response) /// project if needed. Below is the list of known differences: /// /// - /// - /// This project's accepts an optional . - /// When set (single-command CLI invocations), only infrastructure areas and the named - /// Azure service area register their services. Server-mode resource providers - /// (registry, instructions, allowlists) are replaced with null-stubs when an area filter - /// is active, since those providers are only needed by the MCP server transport. - /// + /// No differences. This is also copy/pasta as a placeholder for this project. /// /// /// A service collection. - /// - /// When non-, only the named Azure service area and infrastructure areas - /// (those with other than ) - /// have their services registered. Pass for full initialization. - /// - internal static void ConfigureServices(IServiceCollection services, string? areaFilter = null) + internal static void ConfigureServices(IServiceCollection services) { - var thisAssembly = typeof(Program).Assembly; - - services.InitializeConfigurationAndOptions(thisAssembly); services.ConfigureOpenTelemetry(); services.AddMemoryCache(); @@ -325,149 +198,28 @@ internal static void ConfigureServices(IServiceCollection services, string? area services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); // !!! WARNING !!! // stdio-transport-specific implementations of ITenantService and ICacheService. - // The http-transport-specific implementations and configurations must be registered + // The http-traport-specific implementations and configurations must be registered // within ServiceStartCommand.ExecuteAsync(). - services.AddHttpClientServices(configureDefaults: true); - services.AddAzureTenantService(); - services.AddSingleUserCliCacheService(disabled: true); + services.AddAzureTenantService(addUserAgentClient: true); + services.AddSingleUserCliCacheService(); foreach (var area in Areas) { - // When areaFilter is set (CLI path), skip Azure service areas that don't match the target. - // Non-Azure-service areas (Category != AzureServices) provide shared infrastructure - // (command routing, server start, subscription listing, etc.) and must always be registered. - // Any area whose Category is AzureServices and whose name doesn't match the filter is skipped; - // this avoids registering services (HTTP clients, SDKs, etc.) for 60+ irrelevant services. - if (areaFilter != null && - area.Category == CommandCategory.AzureServices && - !string.Equals(area.Name, areaFilter, StringComparison.OrdinalIgnoreCase)) - { - continue; - } services.AddSingleton(area); area.ConfigureServices(services); } - - // Optimization: server-mode providers (registry, instructions, plugin allowlists) are only - // used when running as an MCP server. For CLI area invocations they are never resolved, so - // register lightweight stubs to avoid reading embedded resources on every CLI call. - if (areaFilter == null || string.Equals(areaFilter, ServerAreaName, StringComparison.OrdinalIgnoreCase)) - { - services.AddRegistryRoot(thisAssembly, $"registry.json"); - - services.AddSingleton( - new ResourceServerInstructionsProvider(thisAssembly, $"azure-rules.txt")); - - services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, thisAssembly, $"consolidated-tools.json")); - - services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, thisAssembly, $"allowed-plugin-file-references.json")); - - services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, thisAssembly, $"allowed-skill-names.json")); - } - else - { - services.AddSingleton(new RegistryRoot()); - services.AddSingleton(new NullServerInstructionsProvider()); - services.AddSingleton(new NullConsolidatedToolDefinitionProvider()); - services.AddSingleton(new NullPluginFileReferenceAllowlistProvider()); - services.AddSingleton(new NullPluginSkillNameAllowlistProvider()); - } } internal static async Task InitializeServicesAsync(IServiceProvider serviceProvider) { - ServiceStartOptions? options = serviceProvider.GetService>()?.Value; - - if (options != null) - { - // Update the UserAgentPolicy for all Azure service calls to include the transport type. - var transport = string.IsNullOrEmpty(options.Transport) ? TransportTypes.StdIo : options.Transport; - BaseAzureService.InitializeUserAgentPolicy(transport); - - if (options.DangerouslyDisableRetryLimits) - { - BaseAzureService.DisableRetryLimits(); - } - } - // Perform any initialization before starting the service. // If the initialization operation fails, do not continue because we do not want // invalid telemetry published. var telemetryService = serviceProvider.GetRequiredService(); await telemetryService.InitializeAsync(); } - - /// - /// Extracts the target service area name from the first non-option CLI token. - /// Returns when no area can be determined (e.g. bare --help, - /// --learn, --version, or no arguments), which causes - /// to fall back to full initialization of all areas. - /// - /// Command-line arguments. - /// The area name (e.g. "storage"), or . - internal static string? GetTargetAreaName(string[] args) - { - // Scan for the first token that is not an option flag (does not start with '-'). - // '--' is the POSIX end-of-options marker: everything after it is a positional argument, - // but for our purposes we stop scanning at '--' and treat it as "no area found". - string? firstToken = null; - foreach (var arg in args) - { - if (arg == "--") - break; - if (arg.Length > 0 && !arg.StartsWith('-')) - { - firstToken = arg; - break; - } - } - - if (firstToken is null) - { - return null; - } - - // The "tools" area introspects factory.AllCommands to enumerate every registered command. - // Filtering to only the tools area would return an empty result set, so skip optimization. - if (string.Equals(firstToken, "tools", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - // Only apply the optimization when the first token is a known registered area. - // If the token doesn't match any area (e.g. a typo), fall through to full initialization - // so System.CommandLine can produce helpful "Did you mean..." suggestions. - if (!Array.Exists(Areas, a => string.Equals(a.Name, firstToken, StringComparison.OrdinalIgnoreCase))) - { - return null; - } - - return firstToken; - } - - /// - /// Attempts to handle the --version flag without requiring full service initialization. - /// - /// Command-line arguments. - /// Exit code if request was handled, null otherwise. - private static int? TryHandleFastPathRequest(string[] args) - { - // Handle --version / -v flags before DI initialization - if (args.Length == 1 && (args[0] == "--version" || args[0] == "-v")) - { - var version = AssemblyHelper.GetFullAssemblyVersion(typeof(Program).Assembly); - Console.WriteLine(version); - return 0; - } - - return null; - } } diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj b/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj new file mode 100644 index 0000000000..1204e0414e --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Azure.Mcp.Tools.IoTHub.csproj @@ -0,0 +1,21 @@ + + + true + + + + + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs new file mode 100644 index 0000000000..faf53db6c0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHubJsonContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Commands; + +[JsonSerializable(typeof(IoTHubDescription))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IoTHubKey))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IoTHubUsageSnapshot))] +[JsonSerializable(typeof(IoTHubDeviceCountStats))] +[JsonSerializable(typeof(IoTHubDailyMessageUsage))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IoTHubDeleteCommandResult))] +[JsonSerializable(typeof(DeviceTwin))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DeviceIdentity))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DeviceListResult))] +[JsonSerializable(typeof(IoTHubRegistryStatistics))] +[JsonSerializable(typeof(DeviceAuthentication))] +[JsonSerializable(typeof(TwinPatch))] +[JsonSerializable(typeof(IoTHubQueryRequest))] +[JsonSerializable(typeof(IoTHubQueryRunResult))] +[JsonSerializable(typeof(QueryPredicate))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(QueryDiscoveredField))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(QueryDiscoveredFields))] +[JsonSerializable(typeof(QueryCompileRequest))] +[JsonSerializable(typeof(IoTHubQueryCompileResult))] +[JsonSerializable(typeof(IoTHubQueryDiscoverResult))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(object))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class IoTHubJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs b/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs new file mode 100644 index 0000000000..a3c1282cdd --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/IoTHubSetup.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Tools.IoTHub; + +public class IoTHubSetup : IAreaSetup +{ + public string Name => "iothub"; + + public string Title => "Manage Azure IoT Hub"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + var iothub = new CommandGroup(Name, + "IoT Hub operations - Commands for managing Azure IoT Hubs.", + Title); + + var hub = new CommandGroup("hub", "IoT Hub resource operations."); + iothub.AddSubGroup(hub); + + var createCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(createCommand.Name, createCommand); + + var getCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(getCommand.Name, getCommand); + + var updateCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(updateCommand.Name, updateCommand); + + var deleteCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(deleteCommand.Name, deleteCommand); + + var keysGetCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(keysGetCommand.Name, keysGetCommand); + + var usageShowCommand = serviceProvider.GetRequiredService(); + hub.AddCommand(usageShowCommand.Name, usageShowCommand); + + var device = new CommandGroup("device", "IoT Hub device registry operations."); + iothub.AddSubGroup(device); + + var deviceListCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceListCommand.Name, deviceListCommand); + + var deviceShowCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceShowCommand.Name, deviceShowCommand); + + var deviceTwinGetCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceTwinGetCommand.Name, deviceTwinGetCommand); + + var deviceTwinUpdateCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceTwinUpdateCommand.Name, deviceTwinUpdateCommand); + + var deviceTwinQueryCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceTwinQueryCommand.Name, deviceTwinQueryCommand); + + var deviceStatisticsCommand = serviceProvider.GetRequiredService(); + device.AddCommand(deviceStatisticsCommand.Name, deviceStatisticsCommand); + + var query = new CommandGroup("query", "IoT Hub query operations."); + device.AddSubGroup(query); + + var queryRunCommand = serviceProvider.GetRequiredService(); + query.AddCommand(queryRunCommand.Name, queryRunCommand); + + var queryCompileCommand = serviceProvider.GetRequiredService(); + query.AddCommand(queryCompileCommand.Name, queryCompileCommand); + + var queryDiscoverCommand = serviceProvider.GetRequiredService(); + query.AddCommand(queryDiscoverCommand.Name, queryDiscoverCommand); + + return iothub; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHubOptionDefinitions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHubOptionDefinitions.cs new file mode 100644 index 0000000000..2cf2cd9f55 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHubOptionDefinitions.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; + +namespace Azure.Mcp.Tools.IoTHub.Options; + +public static class IoTHubOptionDefinitions +{ + public const string NameName = "name"; + public const string LocationName = "location"; + public const string SkuName = "sku"; + public const string CapacityName = "capacity"; + public const string DeviceIdName = "device-id"; + public const string MaxCountName = "max-count"; + public const string ContinuationTokenName = "continuation-token"; + public const string QueryName = "query"; + public const string PatchName = "patch"; + public const string StartTimeName = "start-time"; + public const string EndTimeName = "end-time"; + public static readonly Option Name = new( + $"--{NameName}" + ) + { + Description = "The name of the IoT Hub.", + Required = true + }; + + public static readonly Option Location = new( + $"--{LocationName}" + ) + { + Description = "The location of the IoT Hub.", + Required = true + }; + + public static readonly Option Sku = new( + $"--{SkuName}" + ) + { + Description = "The SKU of the IoT Hub (e.g. S1, F1).", + Required = true + }; + + public static readonly Option Capacity = new( + $"--{CapacityName}" + ) + { + Description = "The capacity of the IoT Hub.", + Required = true + }; + + public static readonly Option DeviceId = new( + $"--{DeviceIdName}" + ) + { + Description = "The device identifier in the IoT Hub device registry.", + Required = true + }; + + public static readonly Option MaxCount = new( + $"--{MaxCountName}" + ) + { + Description = "The maximum number of items to return per page. Defaults to 100 when not specified. Values greater than 100 are capped at 100.", + Required = false + }; + + public static readonly Option QueryMaxCount = new( + $"--{MaxCountName}" + ) + { + Description = "The maximum number of query items to return per page. Defaults to 100 when not specified. Values greater than 100 are capped at 100.", + Required = false + }; + + public static readonly Option ContinuationToken = new( + $"--{ContinuationTokenName}" + ) + { + Description = "The opaque continuationToken string returned by a previous iothub_query_run response to fetch exactly one next page. Omit it to start from the first page. Do not pass hasMore=true/false or any boolean value.", + Required = false + }; + + public static readonly Option Query = new( + $"--{QueryName}" + ) + { + Description = "The IoT Hub query language expression to filter devices.", + Required = true + }; + + public static readonly Option Patch = new( + $"--{PatchName}" + ) + { + Description = "The JSON patch document to update the device twin (e.g., {\"properties\":{\"desired\":{\"temperature\":72}}}).", + Required = true + }; + + public static readonly Option StartTime = new( + $"--{StartTimeName}" + ) + { + Description = "The start time for the usage query as an absolute ISO 8601 timestamp. Include a timezone offset, e.g. 2026-07-07T00:00:00Z (UTC) or 2026-07-07T00:00:00-07:00. " + + "Resolve any relative expression (such as 'this morning', 'yesterday midnight', or 'last hour') in the user's local timezone first, then pass the absolute value; the query runs in UTC. " + + "A timestamp without an offset is treated as UTC. Defaults to 24 hours before the end time.", + Required = false + }; + + public static readonly Option EndTime = new( + $"--{EndTimeName}" + ) + { + Description = "The end time for the usage query as an absolute ISO 8601 timestamp. Include a timezone offset, e.g. 2026-07-08T00:00:00Z (UTC) or 2026-07-08T00:00:00-07:00. " + + "Resolve any relative expression (such as 'today', 'end of yesterday', or 'now') in the user's local timezone first, then pass the absolute value; the query runs in UTC. " + + "A timestamp without an offset is treated as UTC. Defaults to now.", + Required = false + }; + +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubDeviceService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubDeviceService.cs new file mode 100644 index 0000000000..c54b538210 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubDeviceService.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.IoTHub.Models; +using System.Text.Json; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public interface IIoTHubDeviceService +{ + Task ListDevices( + string name, + string resourceGroup, + string subscription, + int? maxCount = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task GetDevice( + string deviceId, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task GetDeviceTwin( + string deviceId, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task UpdateDeviceTwin( + string deviceId, + TwinPatch patch, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task> QueryTwins( + string query, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task RunQuery( + string query, + string name, + string resourceGroup, + string subscription, + int? maxCount = null, + string? continuationToken = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task GetDeviceStatistics( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubService.cs new file mode 100644 index 0000000000..5efdfc8214 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IIoTHubService.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public interface IIoTHubService +{ + Task> GetIoTHub( + string? name, + string? resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task CreateIoTHub( + string name, + string resourceGroup, + string location, + string sku, + long capacity, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task UpdateIoTHub( + string name, + string resourceGroup, + string? sku, + long? capacity, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task DeleteIoTHub( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task> GetIoTHubKeys( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task GetUsageSnapshot( + string name, + string resourceGroup, + string subscription, + string? startTime = null, + string? endTime = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubDeviceService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubDeviceService.cs new file mode 100644 index 0000000000..df064a0724 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubDeviceService.cs @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Web; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Core.Services.Http; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Models; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public class IoTHubDeviceService( + IIoTHubService ioTHubService, + IAzureTokenCredentialProvider credentialProvider, + IHttpClientService httpClientService, + ITenantService tenantService, + ICacheService cacheService, + ILogger logger) + : BaseAzureService(tenantService), IIoTHubDeviceService +{ + private readonly IIoTHubService _ioTHubService = ioTHubService ?? throw new ArgumentNullException(nameof(ioTHubService)); + private readonly IAzureTokenCredentialProvider _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); + private readonly IHttpClientService _httpClientService = httpClientService ?? throw new ArgumentNullException(nameof(httpClientService)); + private readonly ITenantService _tenantService = tenantService ?? throw new ArgumentNullException(nameof(tenantService)); + private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Resolving the hub hostname and shared access keys requires two ARM control-plane round-trips + // (a hub GET and a listKeys POST). Cache the result briefly so repeated data-plane calls + // (list, query, twin) against the same hub don't pay that cost every time. + private const string ConnectionCacheGroup = "iothub-device-connection"; + private static readonly TimeSpan s_connectionCacheDuration = TimeSpan.FromMinutes(15); + + // Upper bound for a single IoT Hub operation (control-plane + data-plane). If exceeded the + // caller gets a clear timeout error instead of appearing to hang indefinitely. + private static readonly TimeSpan s_operationTimeout = TimeSpan.FromSeconds(100); + private static readonly TimeSpan s_queryRunTimeout = TimeSpan.FromSeconds(30); + + private sealed record HubConnection(string Hostname, List Keys); + + private async Task GetHubConnectionAsync( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken) + { + var cacheKey = $"{subscription}/{resourceGroup}/{name}"; + var cached = await _cacheService.GetAsync(ConnectionCacheGroup, cacheKey, s_connectionCacheDuration, cancellationToken); + if (cached is not null) + { + _logger.LogDebug("Using cached IoT Hub connection details for hub {HubName} in resource group {ResourceGroup}.", name, resourceGroup); + return cached; + } + + _logger.LogInformation("Resolving IoT Hub connection details for hub {HubName} in resource group {ResourceGroup}.", name, resourceGroup); + var hubDetails = await _ioTHubService.GetIoTHub(name, resourceGroup, subscription, retryPolicy, cancellationToken); + if (hubDetails.Count == 0) + { + throw new InvalidOperationException($"IoT Hub '{name}' not found in resource group '{resourceGroup}'"); + } + + var hostname = hubDetails[0].HostName ?? throw new InvalidOperationException("IoT Hub hostname is null"); + var keys = await _ioTHubService.GetIoTHubKeys(name, resourceGroup, subscription, retryPolicy, cancellationToken); + + _logger.LogInformation("Resolved IoT Hub connection details for hub {HubName}. Hostname={Hostname}, KeyCount={KeyCount}.", name, hostname, keys.Count); + var connection = new HubConnection(hostname, keys); + await _cacheService.SetAsync(ConnectionCacheGroup, cacheKey, connection, s_connectionCacheDuration, cancellationToken); + return connection; + } + + private static IoTHubKey SelectKey(HubConnection connection, string requiredRight) + => connection.Keys.FirstOrDefault(k => k.Rights?.Contains(requiredRight) == true) + ?? throw new InvalidOperationException($"No key with {requiredRight} rights found"); + + private async Task ExecuteWithTimeoutAsync( + Func> operation, + string operationName, + CancellationToken cancellationToken, + TimeSpan? timeout = null) + { + var operationTimeout = timeout ?? s_operationTimeout; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(operationTimeout); + try + { + return await operation(timeoutCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"The IoT Hub '{operationName}' operation timed out after {operationTimeout.TotalSeconds:N0} seconds. " + + "The hub may be unavailable or the request too large. Try again, narrow your query, or reduce --max-count."); + } + } + + private static TimeSpan GetOperationTimeout(RetryPolicyOptions? retryPolicy, TimeSpan defaultTimeout) + { + if (retryPolicy?.HasNetworkTimeoutSeconds == true && retryPolicy.NetworkTimeoutSeconds > 0) + { + return TimeSpan.FromSeconds(retryPolicy.NetworkTimeoutSeconds); + } + + return defaultTimeout; + } + + public async Task ListDevices( + string name, + string resourceGroup, + string subscription, + int? maxCount = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "RegistryRead"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + // The registry API has no continuation/paging support, so over-fetch by one device to + // detect whether more devices exist beyond the requested page size. + var fetchCount = maxCount.HasValue ? maxCount.Value + 1 : (int?)null; + var maxCountParam = fetchCount.HasValue ? $"&top={fetchCount.Value}" : string.Empty; + var requestUri = $"https://{hostname}/devices?api-version={apiVersion}{maxCountParam}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var response = await httpClient.GetAsync(requestUri, ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(ct); + var devices = JsonSerializer.Deserialize(content, IoTHubJsonContext.Default.ListDeviceIdentity) ?? []; + + var truncated = false; + if (maxCount.HasValue && devices.Count > maxCount.Value) + { + truncated = true; + devices = devices.GetRange(0, maxCount.Value); + } + + return new DeviceListResult(devices, truncated); + }, "list devices", cancellationToken); + } + + public async Task GetDevice( + string deviceId, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(deviceId), deviceId), + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "RegistryRead"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/devices/{Uri.EscapeDataString(deviceId)}?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var response = await httpClient.GetAsync(requestUri, ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(content, IoTHubJsonContext.Default.DeviceIdentity) ?? throw new InvalidOperationException("Failed to deserialize device identity"); + }, "get device", cancellationToken); + } + + public async Task GetDeviceTwin( + string deviceId, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(deviceId), deviceId), + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "ServiceConnect"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/twins/{Uri.EscapeDataString(deviceId)}?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var response = await httpClient.GetAsync(requestUri, ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(content, IoTHubJsonContext.Default.DeviceTwin) ?? throw new InvalidOperationException("Failed to deserialize device twin"); + }, "get device twin", cancellationToken); + } + + public async Task UpdateDeviceTwin( + string deviceId, + TwinPatch patch, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(deviceId), deviceId), + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + if (patch == null) + { + throw new ArgumentNullException(nameof(patch)); + } + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "RegistryWrite"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/twins/{Uri.EscapeDataString(deviceId)}?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var patchJson = JsonSerializer.Serialize(patch, IoTHubJsonContext.Default.TwinPatch); + var content = new StringContent(patchJson, Encoding.UTF8, "application/json"); + + var response = await httpClient.PatchAsync(requestUri, content, ct); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(responseContent, IoTHubJsonContext.Default.DeviceTwin) ?? throw new InvalidOperationException("Failed to deserialize updated device twin"); + }, "update device twin", cancellationToken); + } + + public async Task> QueryTwins( + string query, + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(query), query), + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "RegistryRead"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/devices/query?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var queryObject = new { query }; + var queryJson = JsonSerializer.Serialize(queryObject, IoTHubJsonContext.Default.Object); + var content = new StringContent(queryJson, Encoding.UTF8, "application/json"); + + var response = await httpClient.PostAsync(requestUri, content, ct); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(responseContent, IoTHubJsonContext.Default.ListDeviceTwin) ?? []; + }, "query twins", cancellationToken); + } + + public async Task RunQuery( + string query, + string name, + string resourceGroup, + string subscription, + int? maxCount = null, + string? continuationToken = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(query), query), + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var stopwatch = Stopwatch.StartNew(); + _logger.LogInformation( + "IoT Hub query run started. Hub={HubName}, ResourceGroup={ResourceGroup}, MaxCount={MaxCount}, HasInputContinuationToken={HasInputContinuationToken}.", + name, + resourceGroup, + maxCount, + !string.IsNullOrEmpty(continuationToken)); + + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "ServiceConnect"); + _logger.LogInformation("IoT Hub query connection resolved. Hub={HubName}, ElapsedMs={ElapsedMs}.", name, stopwatch.ElapsedMilliseconds); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/devices/query?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + + var queryObject = new IoTHubQueryRequest(query); + var queryJson = JsonSerializer.Serialize(queryObject, IoTHubJsonContext.Default.IoTHubQueryRequest); + + // IoT Hub query paging uses request/response headers: x-ms-max-item-count sets the page + // size and x-ms-continuation carries the cursor for the next page. + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(queryJson, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + if (maxCount.HasValue) + { + request.Headers.TryAddWithoutValidation("x-ms-max-item-count", maxCount.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrEmpty(continuationToken)) + { + request.Headers.TryAddWithoutValidation("x-ms-continuation", continuationToken); + } + + _logger.LogInformation("Sending IoT Hub query request. Hub={HubName}, ElapsedMs={ElapsedMs}.", name, stopwatch.ElapsedMilliseconds); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + _logger.LogInformation( + "Received IoT Hub query response headers. Hub={HubName}, StatusCode={StatusCode}, ElapsedMs={ElapsedMs}.", + name, + response.StatusCode, + stopwatch.ElapsedMilliseconds); + response.EnsureSuccessStatusCode(); + + var nextContinuationToken = response.Headers.TryGetValues("x-ms-continuation", out var continuationValues) + ? continuationValues.FirstOrDefault() + : null; + + var responseContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogInformation( + "Read IoT Hub query response body. Hub={HubName}, ResponseLength={ResponseLength}, HasOutputContinuationToken={HasOutputContinuationToken}, ElapsedMs={ElapsedMs}.", + name, + responseContent.Length, + !string.IsNullOrEmpty(nextContinuationToken), + stopwatch.ElapsedMilliseconds); + var items = JsonSerializer.Deserialize(responseContent, IoTHubJsonContext.Default.ListJsonElement) ?? []; + + _logger.LogInformation( + "Deserialized IoT Hub query response. Hub={HubName}, Returned={Returned}, ElapsedMs={ElapsedMs}.", + name, + items.Count, + stopwatch.ElapsedMilliseconds); + + return new IoTHubQueryPage(items, nextContinuationToken); + }, "query run", cancellationToken, GetOperationTimeout(retryPolicy, s_queryRunTimeout)); + } + + public async Task GetDeviceStatistics( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + return await ExecuteWithTimeoutAsync(async ct => + { + var connection = await GetHubConnectionAsync(name, resourceGroup, subscription, retryPolicy, ct); + var hostname = connection.Hostname; + var key = SelectKey(connection, "RegistryRead"); + + using var httpClient = _httpClientService.CreateClient(); + var apiVersion = "2021-04-12"; + var requestUri = $"https://{hostname}/statistics/devices?api-version={apiVersion}"; + + var token = GetSasToken(hostname, key.KeyName, key.PrimaryKey); + httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SharedAccessSignature", token); + + var response = await httpClient.GetAsync(requestUri, ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(content, IoTHubJsonContext.Default.IoTHubRegistryStatistics) ?? throw new InvalidOperationException("Failed to deserialize device statistics"); + }, "get device statistics", cancellationToken); + } + + private static string GetSasToken(string hostname, string policyName, string sharedAccessKey) + { + // Generate SAS token for IoT Hub authentication + var expiry = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + var resourceUri = Uri.EscapeDataString(hostname); + var toSign = $"{resourceUri}\n{expiry}"; + + var keyBytes = Convert.FromBase64String(sharedAccessKey); + using var hmac = new HMACSHA256(keyBytes); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(toSign)); + var signature = Uri.EscapeDataString(Convert.ToBase64String(hash)); + + return $"sr={resourceUri}&sig={signature}&se={expiry}&skn={policyName}"; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs new file mode 100644 index 0000000000..06a2ee9a02 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Services/IoTHubService.cs @@ -0,0 +1,496 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.Json; +using Azure.Core; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.ResourceManager; +using Azure.ResourceManager.IotHub; +using Azure.ResourceManager.IotHub.Models; +using Azure.Monitor.Query; +using Azure.Monitor.Query.Models; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.IoTHub.Services; + +public class IoTHubService( + ISubscriptionService subscriptionService, + ITenantService tenantService, + ILogger logger) + : BaseAzureResourceService(subscriptionService, tenantService), IIoTHubService +{ + private readonly ISubscriptionService _subscriptionService = subscriptionService; + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private const string IoTHubMetricNamespace = "Microsoft.Devices/IotHubs"; + + private static readonly string[] IoTHubUsageMetricNames = + [ + "connectedDeviceCount", + "totalDeviceCount", + "dailyMessageQuotaUsed", + "d2c.telemetry.ingress.success", + "d2c.telemetry.ingress.sendThrottle", + ]; + + // When any single hourly bucket of d2c.telemetry.ingress.sendThrottle exceeds this count, the hub + // is throttling heavily enough to recommend moving to a higher SKU tier. + private const double ThrottlingHourlyUpgradeThreshold = 1000; + + public async Task> GetIoTHub( + string? name, + string? resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters((nameof(subscription), subscription)); + + string? additionalFilter = null; + if (!string.IsNullOrEmpty(name)) + { + additionalFilter = $"name =~ '{name}'"; + } + + return await ExecuteResourceQueryAsync( + "Microsoft.Devices/IotHubs", + resourceGroup, + subscription, + retryPolicy, + ConvertToIoTHubDescription, + additionalFilter: additionalFilter, + cancellationToken: cancellationToken); + } + + public async Task CreateIoTHub( + string name, + string resourceGroup, + string location, + string sku, + long capacity, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(location), location), + (nameof(sku), sku), + (nameof(subscription), subscription)); + + var subResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var rg = await subResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + + var data = new IotHubDescriptionData(new AzureLocation(location), new IotHubSkuInfo(new IotHubSku(sku)) { Capacity = capacity }); + var collection = rg.Value.GetIotHubDescriptions(); + var operation = await collection.CreateOrUpdateAsync(WaitUntil.Completed, name, data, null, cancellationToken); + + return ConvertToIoTHubDescription(operation.Value); + } + + public async Task UpdateIoTHub( + string name, + string resourceGroup, + string? sku, + long? capacity, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + var subResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var rg = await subResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + var hub = await rg.Value.GetIotHubDescriptionAsync(name, cancellationToken); + + var data = hub.Value.Data; + if (sku != null) + { + data.Sku = new IotHubSkuInfo(new IotHubSku(sku)) + { + Capacity = capacity ?? data.Sku.Capacity + }; + } + else if (capacity.HasValue) + { + data.Sku.Capacity = capacity.Value; + } + + var operation = await rg.Value.GetIotHubDescriptions().CreateOrUpdateAsync(WaitUntil.Completed, name, data, null, cancellationToken); + return ConvertToIoTHubDescription(operation.Value); + } + + public async Task DeleteIoTHub( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + var subResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var rg = await subResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + var hub = await rg.Value.GetIotHubDescriptionAsync(name, cancellationToken); + await hub.Value.DeleteAsync(WaitUntil.Started, cancellationToken); + } + + public async Task> GetIoTHubKeys( + string name, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + var subResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var rg = await subResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + var hub = await rg.Value.GetIotHubDescriptionAsync(name, cancellationToken); + + var keys = new List(); + await foreach (var key in hub.Value.GetKeysAsync(cancellationToken)) + { + keys.Add(new IoTHubKey(key.KeyName, key.PrimaryKey, key.SecondaryKey, key.Rights.ToString())); + } + return keys; + } + + public async Task GetUsageSnapshot( + string name, + string resourceGroup, + string subscription, + string? startTime = null, + string? endTime = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription)); + + // The subscription argument may be a name or an ID; Azure Monitor requires the + // subscription GUID in the resource URI, so resolve it first. + var subResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var subscriptionId = subResource.Id.SubscriptionId; + + // Read the hub's SKU tier and unit count so the snapshot can flag when sustained + // throttling warrants moving to a higher tier. + var resourceGroupResource = await subResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + var hubResource = await resourceGroupResource.Value.GetIotHubDescriptionAsync(name, cancellationToken); + var sku = hubResource.Value.Data.Sku.Name.ToString(); + var units = hubResource.Value.Data.Sku.Capacity ?? 0; + + var resourceId = + $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}" + + $"/providers/Microsoft.Devices/IotHubs/{name}"; + + // Resolve the query window. Defaults to the last 24 hours when not provided. + // Normalize to UTC so the query and the reported window are unambiguous; offset-aware + // inputs (e.g. ...-07:00) denote the same instant and are converted to UTC here. + var startOffset = ParseTime(startTime, nameof(startTime)); + var endOffset = ParseTime(endTime, nameof(endTime)); + var resolvedEnd = (endOffset ?? DateTimeOffset.UtcNow).ToUniversalTime(); + var resolvedStart = (startOffset ?? resolvedEnd - TimeSpan.FromDays(1)).ToUniversalTime(); + + if (resolvedStart >= resolvedEnd) + { + throw new ArgumentException($"The start time ('{resolvedStart:o}') must be earlier than the end time ('{resolvedEnd:o}')."); + } + + var credential = await GetCredential(cancellationToken); + var clientOptions = ConfigureRetryPolicy(AddDefaultPolicies(new MetricsQueryClientOptions()), retryPolicy); + var client = new MetricsQueryClient(credential, clientOptions); + + var queryOptions = new MetricsQueryOptions + { + TimeRange = new QueryTimeRange(resolvedStart, resolvedEnd), + MetricNamespace = IoTHubMetricNamespace, + // Pin an explicit hourly granularity instead of letting Azure Monitor pick the default + // (which is the finest, PT1M). Reasons: + // 1. connectedDeviceCount is a time-averaged gauge, so its Maximum is + // granularity-dependent. At PT1M, sub-minute connection churn inflates the reported + // peak far above sustained concurrency (e.g. 15 during flapping vs. 2 actually + // connected). Hourly buckets report the operationally meaningful peak. + // 2. dailyMessageQuotaUsed is a daily-resetting counter whose per-day usage is the + // closing (last) sample of each UTC day. Hourly samples stay fine enough to see the + // reset (which lags a little past 00:00 UTC) so the closing value is correct, while + // not depending on the finest default staying stable. + // 3. PT1M over multi-week windows makes the 5-metric x 3-aggregation response exceed + // Azure Monitor's 8 MB limit; PT1H is ~60x smaller and stays within it. + Granularity = TimeSpan.FromHours(1), + }; + queryOptions.Aggregations.Add(MetricAggregationType.Maximum); + queryOptions.Aggregations.Add(MetricAggregationType.Average); + queryOptions.Aggregations.Add(MetricAggregationType.Total); + + var response = await client.QueryResourceAsync( + resourceId, + IoTHubUsageMetricNames, + queryOptions, + cancellationToken); + + var metrics = response.Value.Metrics; + + var (quotaSingle, quotaByDay, quotaTotal) = BuildQuotaUsage(metrics, "dailyMessageQuotaUsed", resolvedStart, resolvedEnd); + + // Peak throttling is the worst single hourly bucket (each metric bucket is PT1H). A partial + // trailing bucket or a sub-hour window still counts, so a short but severe burst is caught. + var peakHourlyThrottling = MaxBucketTotal(metrics, "d2c.telemetry.ingress.sendThrottle"); + var recommendedSku = DetermineRecommendedSku(peakHourlyThrottling, sku); + + return new IoTHubUsageSnapshot( + HubName: name, + SnapshotTime: DateTimeOffset.UtcNow, + StartTime: resolvedStart, + EndTime: resolvedEnd, + ConnectedDeviceCount: BuildDeviceCountStats(metrics, "connectedDeviceCount"), + TotalDeviceCount: BuildDeviceCountStats(metrics, "totalDeviceCount"), + DailyMessageQuotaUsed: quotaSingle, + DailyMessageQuotaUsedByDay: quotaByDay, + TotalMessagesUsed: quotaTotal, + D2CMessageCount: SumTotal(metrics, "d2c.telemetry.ingress.success"), + ThrottlingErrors: SumTotal(metrics, "d2c.telemetry.ingress.sendThrottle"), + PeakHourlyThrottlingErrors: peakHourlyThrottling, + Sku: sku, + Units: units, + RecommendedSku: recommendedSku); + } + + private static DateTimeOffset? ParseTime(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (!DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) + { + throw new ArgumentException($"Invalid {parameterName} format: '{value}'. Use ISO 8601, for example 2026-07-07T00:00:00Z"); + } + + return parsed; + } + + private static IoTHubDeviceCountStats BuildDeviceCountStats(IReadOnlyList metrics, string metricName) + { + var values = metrics + .FirstOrDefault(m => string.Equals(m.Name, metricName, StringComparison.OrdinalIgnoreCase))? + .TimeSeries + .SelectMany(ts => ts.Values) + .ToList(); + + if (values is null || values.Count == 0) + { + return new IoTHubDeviceCountStats(null, null, null); + } + + // Snapshot: the most recent point-in-time value in the window. + var snapshot = values + .Where(v => v.Maximum.HasValue) + .OrderBy(v => v.TimeStamp) + .LastOrDefault()? + .Maximum; + + // Peak: the maximum value observed across the window. + var maximums = values + .Where(v => v.Maximum.HasValue) + .Select(v => v.Maximum!.Value) + .ToList(); + double? peak = maximums.Count > 0 ? maximums.Max() : null; + + // Average: the mean value across the window. + var averages = values + .Where(v => v.Average.HasValue) + .Select(v => v.Average!.Value) + .ToList(); + double? average = averages.Count > 0 ? averages.Average() : null; + + return new IoTHubDeviceCountStats(snapshot, peak, average); + } + + private static double? SumTotal(IReadOnlyList metrics, string metricName) + { + var values = metrics + .FirstOrDefault(m => string.Equals(m.Name, metricName, StringComparison.OrdinalIgnoreCase))? + .TimeSeries + .SelectMany(ts => ts.Values) + .Where(v => v.Total.HasValue) + .Select(v => v.Total!.Value) + .ToList(); + + return values is { Count: > 0 } ? values.Sum() : null; + } + + private static double? MaxBucketTotal(IReadOnlyList metrics, string metricName) + { + var totals = metrics + .FirstOrDefault(m => string.Equals(m.Name, metricName, StringComparison.OrdinalIgnoreCase))? + .TimeSeries + .SelectMany(ts => ts.Values) + .Where(v => v.Total.HasValue) + .Select(v => v.Total!.Value) + .ToList(); + + return totals is { Count: > 0 } ? totals.Max() : null; + } + + /// + /// Recommends a higher IoT Hub SKU when the worst hourly throttling exceeds the threshold. + /// Returns null when throttling is within limits or the hub is already on the top tier (S3), + /// which has no upgrade target. + /// + internal static string? DetermineRecommendedSku(double? peakHourlyThrottling, string sku) => + peakHourlyThrottling > ThrottlingHourlyUpgradeThreshold ? GetUpgradeSku(sku) : null; + + // Maps a SKU tier to the next-higher tier to recommend. S3 is the top Standard tier, and tiers + // without a defined single upgrade path (e.g. Basic) return null so no recommendation is made. + private static string? GetUpgradeSku(string sku) => sku?.ToUpperInvariant() switch + { + "F1" => "S1", + "S1" => "S2", + "S2" => "S3", + _ => null, + }; + + /// + /// Resolves the daily message quota usage for the window. + /// dailyMessageQuotaUsed is a cumulative counter that resets at 00:00 UTC, so each UTC day's + /// usage is its closing value: the last sample reported that day. Using the closing value + /// (rather than the daily maximum) avoids double-counting when the counter's reset lags past + /// 00:00 UTC and a high previous-day peak lingers into the early hours of an otherwise idle day. + /// A single-day window returns a scalar; a multi-day window returns a per-day breakdown plus the + /// total across days. + /// + private static (double? Single, IReadOnlyList? ByDay, double? Total) BuildQuotaUsage( + IReadOnlyList metrics, + string metricName, + DateTimeOffset start, + DateTimeOffset end) + { + var samples = metrics + .FirstOrDefault(m => string.Equals(m.Name, metricName, StringComparison.OrdinalIgnoreCase))? + .TimeSeries + .SelectMany(ts => ts.Values) + .Where(v => v.Maximum.HasValue) + .Select(v => (v.TimeStamp, Value: v.Maximum!.Value)); + + var byDate = samples is null ? null : ComputeDailyClosingValues(samples); + + var dates = EnumerateUtcDates(start, end); + + if (dates.Count <= 1) + { + var day = dates.Count == 1 ? dates[0] : DateOnly.FromDateTime(start.UtcDateTime); + double? single = byDate is not null && byDate.TryGetValue(day, out var value) ? value : null; + return (single, null, null); + } + + var breakdown = new List(dates.Count); + double total = 0; + var hasAny = false; + foreach (var day in dates) + { + double? value = byDate is not null && byDate.TryGetValue(day, out var v) ? v : null; + if (value.HasValue) + { + total += value.Value; + hasAny = true; + } + + breakdown.Add(new IoTHubDailyMessageUsage(day.ToString("yyyy-MM-dd"), value)); + } + + return (null, breakdown, hasAny ? total : null); + } + + /// + /// Groups cumulative-counter samples by UTC day and returns each day's closing value: the + /// value of the latest sample reported that day. For a counter that resets at 00:00 UTC this + /// is the correct daily usage, and-unlike a daily maximum-it is not corrupted when the reset + /// lags past midnight and the prior day's peak lingers into the early hours of the next day. + /// + internal static Dictionary ComputeDailyClosingValues( + IEnumerable<(DateTimeOffset TimeStamp, double Value)> samples) + { + return samples + .GroupBy(s => DateOnly.FromDateTime(s.TimeStamp.UtcDateTime)) + .ToDictionary(g => g.Key, g => g.MaxBy(s => s.TimeStamp).Value); + } + + /// + /// Enumerates the UTC calendar dates covered by the window. The end is treated as exclusive, + /// so a window ending exactly at midnight does not include that final day. + /// + private static List EnumerateUtcDates(DateTimeOffset start, DateTimeOffset end) + { + var startDate = DateOnly.FromDateTime(start.UtcDateTime); + var endUtc = end.UtcDateTime; + var lastDate = DateOnly.FromDateTime(endUtc); + if (endUtc.TimeOfDay == TimeSpan.Zero) + { + lastDate = lastDate.AddDays(-1); + } + + if (lastDate < startDate) + { + lastDate = startDate; + } + + var result = new List(); + for (var day = startDate; day <= lastDate; day = day.AddDays(1)) + { + result.Add(day); + } + + return result; + } + + private IoTHubDescription ConvertToIoTHubDescription(IotHubDescriptionResource resource) + { + return new IoTHubDescription( + resource.Id.ToString(), + resource.Data.Name, + resource.Data.Location.Name, + resource.Id.ResourceGroupName ?? "", + resource.Id.SubscriptionId ?? "", + resource.Data.Sku.Name.ToString(), + resource.Data.Sku.Capacity ?? 0, + "Unknown", + resource.Data.Properties.HostName); + } + + private IoTHubDescription ConvertToIoTHubDescription(JsonElement element) + { + var properties = element.GetProperty("properties"); + var sku = element.GetProperty("sku"); + + return new IoTHubDescription( + element.GetProperty("id").GetString()!, + element.GetProperty("name").GetString()!, + element.GetProperty("location").GetString()!, + element.GetProperty("resourceGroup").GetString()!, + element.GetProperty("subscriptionId").GetString()!, + sku.GetProperty("name").GetString()!, + sku.TryGetProperty("capacity", out var cap) ? cap.GetInt64() : 0, + properties.TryGetProperty("state", out var state) ? state.GetString()! : "Unknown", + properties.TryGetProperty("hostName", out var hostName) ? hostName.GetString()! : "" + ); + } +} From 9c00bf0f40a85578d760a246fccb1fa3f00c7600 Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Thu, 16 Jul 2026 10:51:05 -0700 Subject: [PATCH 3/6] Add IoTHub package versions --- Directory.Packages.props | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index a265d88a6a..e3b9e1e5b1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -118,5 +118,7 @@ + + From 251bfe04397ecc900583762d6fb716da781fc11d Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Thu, 16 Jul 2026 11:09:49 -0700 Subject: [PATCH 4/6] add iothub implementation --- .../src/AssemblyInfo.cs | 7 + .../src/Commands/BaseIoTHubCommand.cs | 38 ++ .../Device/IoTHubDeviceShowCommands.cs | 76 ++++ .../Device/IoTHubDeviceStatisticsCommand.cs | 82 ++++ .../Device/IoTHubDeviceTwinGetCommand.cs | 75 ++++ .../Device/IoTHubDeviceTwinQueryCommand.cs | 75 ++++ .../Device/IoTHubDeviceTwinUpdateCommand.cs | 95 +++++ .../Commands/Device/IoTHubQueryRunCommand.cs | 147 ++++++++ .../Commands/IoTHub/IoTHubCreateCommand.cs | 84 +++++ .../Commands/IoTHub/IoTHubDeleteCommand.cs | 76 ++++ .../src/Commands/IoTHub/IoTHubGetCommand.cs | 75 ++++ .../Commands/IoTHub/IoTHubKeysGetCommand.cs | 73 ++++ .../IoTHub/IoTHubQueryCompileCommand.cs | 140 +++++++ .../IoTHub/IoTHubQueryDiscoverCommand.cs | 125 ++++++ .../Commands/IoTHub/IoTHubUpdateCommand.cs | 79 ++++ .../Commands/IoTHub/IoTHubUsageShowCommand.cs | 87 +++++ .../src/GlobalUsings.cs | 7 + .../src/Models/DeviceTwin.cs | 55 +++ .../src/Models/IoTHubDailyMessageUsage.cs | 13 + .../src/Models/IoTHubDescription.cs | 15 + .../src/Models/IoTHubDeviceCountStats.cs | 15 + .../src/Models/IoTHubKey.cs | 10 + .../src/Models/IoTHubQueryCompileResult.cs | 13 + .../src/Models/IoTHubQueryDiscoverResult.cs | 12 + .../src/Models/IoTHubQueryPage.cs | 10 + .../src/Models/IoTHubQueryRequest.cs | 9 + .../src/Models/IoTHubQueryRunResult.cs | 14 + .../src/Models/IoTHubUsageSnapshot.cs | 23 ++ .../src/Models/PredicateOperator.cs | 31 ++ .../src/Models/PredicateScope.cs | 25 ++ .../src/Models/QueryCompileRequest.cs | 32 ++ .../src/Models/QueryDiscoveredField.cs | 12 + .../src/Models/QueryDiscoveredFields.cs | 21 ++ .../src/Models/QueryPredicate.cs | 29 ++ .../src/Models/RegistryStatistics.cs | 11 + .../Options/Device/IoTHubDeviceShowOptions.cs | 12 + .../Device/IoTHubDeviceStatisticsOptions.cs | 11 + .../Device/IoTHubDeviceTwinGetOptions.cs | 12 + .../Device/IoTHubDeviceTwinQueryOptions.cs | 12 + .../Device/IoTHubDeviceTwinUpdateOptions.cs | 13 + .../Options/Device/IoTHubQueryRunOptions.cs | 14 + .../src/Options/IoTHub/IoTHubCreateOptions.cs | 14 + .../src/Options/IoTHub/IoTHubDeleteOptions.cs | 11 + .../src/Options/IoTHub/IoTHubGetOptions.cs | 11 + .../Options/IoTHub/IoTHubKeysGetOptions.cs | 11 + .../IoTHubQueryCompileOptionDefinitions.cs | 58 +++ .../IoTHub/IoTHubQueryCompileOptions.cs | 15 + .../IoTHub/IoTHubQueryDiscoverOptions.cs | 12 + .../src/Options/IoTHub/IoTHubUpdateOptions.cs | 13 + .../Options/IoTHub/IoTHubUsageShowOptions.cs | 13 + .../src/Query/IoTHubQueryCompiler.cs | 199 ++++++++++ .../src/Query/IoTHubQueryFieldDiscoverer.cs | 151 ++++++++ .../Azure.Mcp.Tools.IoTHub.LiveTests.csproj | 17 + .../IoTHubCommandTests.cs | 168 +++++++++ .../Azure.Mcp.Tools.IoTHub.UnitTests.csproj | 17 + .../IoTHubDeviceStatisticsCommandTests.cs | 122 ++++++ .../Device/IoTHubDeviceTwinGetCommandTests.cs | 97 +++++ .../IoTHubDeviceTwinQueryCommandTests.cs | 100 +++++ .../IoTHubDeviceTwinUpdateCommandTests.cs | 101 +++++ .../Device/IoTHubQueryCompileCommandTests.cs | 249 ++++++++++++ .../Device/IoTHubQueryDiscoverCommandTests.cs | 191 ++++++++++ .../Device/IoTHubQueryRunCommandTests.cs | 355 ++++++++++++++++++ .../IoTHub/IoTHubCreateCommandTests.cs | 81 ++++ .../IoTHub/IoTHubDeleteCommandTests.cs | 100 +++++ .../IoTHub/IoTHubGetCommandTests.cs | 71 ++++ .../IoTHub/IoTHubKeysGetCommandTests.cs | 113 ++++++ .../IoTHub/IoTHubUpdateCommandTests.cs | 116 ++++++ .../IoTHub/IoTHubUsageShowCommandTests.cs | 251 +++++++++++++ .../Services/BlockingHttpContent.cs | 21 ++ .../Services/CallbackHttpMessageHandler.cs | 15 + .../Services/IoTHubDeviceServiceTests.cs | 88 +++++ .../Services/IoTHubServiceTests.cs | 53 +++ .../tests/test-resources-post.ps1 | 43 +++ .../tests/test-resources.bicep | 14 + 74 files changed, 4656 insertions(+) create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/AssemblyInfo.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/BaseIoTHubCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceShowCommands.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceStatisticsCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinGetCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinQueryCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinUpdateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubQueryRunCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubCreateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubDeleteCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubKeysGetCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryCompileCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryDiscoverCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUpdateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUsageShowCommand.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/GlobalUsings.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceTwin.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDailyMessageUsage.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDeviceCountStats.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubKey.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryCompileResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryDiscoverResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryPage.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRequest.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRunResult.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubUsageSnapshot.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateOperator.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateScope.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryCompileRequest.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredField.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredFields.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryPredicate.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Models/RegistryStatistics.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceShowOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceStatisticsOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinGetOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinQueryOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinUpdateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubQueryRunOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubCreateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubDeleteOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubGetOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubKeysGetOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptionDefinitions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryDiscoverOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUpdateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUsageShowOptions.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryCompiler.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryFieldDiscoverer.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/Azure.Mcp.Tools.IoTHub.LiveTests.csproj create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/IoTHubCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Azure.Mcp.Tools.IoTHub.UnitTests.csproj create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceStatisticsCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinGetCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinQueryCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinUpdateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryCompileCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryDiscoverCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryRunCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubCreateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubDeleteCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubGetCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubKeysGetCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUpdateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUsageShowCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/BlockingHttpContent.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/CallbackHttpMessageHandler.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubDeviceServiceTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubServiceTests.cs create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 create mode 100644 tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.IoTHub/src/AssemblyInfo.cs new file mode 100644 index 0000000000..957f96460b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.IoTHub.UnitTests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.IoTHub.LiveTests")] diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/BaseIoTHubCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/BaseIoTHubCommand.cs new file mode 100644 index 0000000000..6e2f85a5cb --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/BaseIoTHubCommand.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Diagnostics.CodeAnalysis; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands; + +public abstract class BaseIoTHubCommand< + [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions> + : SubscriptionCommand + where TOptions : SubscriptionOptions, new() +{ + // Most IoT Hub commands scope to a single resource group. `hub get` overrides this + // to allow subscription-wide listing when no resource group is supplied. + protected virtual bool RequireResourceGroup => true; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(RequireResourceGroup + ? OptionDefinitions.Common.ResourceGroup.AsRequired() + : OptionDefinitions.Common.ResourceGroup.AsOptional()); + } + + protected override TOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + return options; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceShowCommands.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceShowCommands.cs new file mode 100644 index 0000000000..85d2559040 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceShowCommands.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceShowCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-show"; + public override string Name => "show"; + public override string Description => "Show the device identity for a device in an IoT Hub device registry. Returns the device identity metadata without authentication keys." + + "Always present the complete raw JSON result to the user, including all fields (statusReason, connectionStateUpdatedTime, statusUpdatedTime,capabilities, etc.). Do not summarize, reformat, or omit any fields." + + "Device names/IDs are case-sensitive and must match exactly."; + public override string Title => "Show IoT Hub Device"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.DeviceId.AsRequired()); + } + + protected override IoTHubDeviceShowOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.DeviceId = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.DeviceId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetDevice( + options.DeviceId!, + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.DeviceIdentity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error showing device in IoT Hub"); + HandleException(context, ex); + } + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceStatisticsCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceStatisticsCommand.cs new file mode 100644 index 0000000000..7e9854c720 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceStatisticsCommand.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceStatisticsCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-stats"; + public override string Name => "stats"; + public override string Description => "Get device statistics for an IoT Hub identity registry. " + + "Returns aggregate device counts for the hub: disabledDeviceCount (the number of currently disabled devices), " + + "enabledDeviceCount (the number of currently enabled devices), and totalDeviceCount (the total number of devices registered for the IoT Hub). " + + "Hub names/IDs are case-sensitive and must match exactly."; + public override string Title => "Get IoT Hub Device Statistics"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + } + + protected override IoTHubDeviceStatisticsOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetDeviceStatistics( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubRegistryStatistics); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting device statistics for IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + TimeoutException => HttpStatusCode.RequestTimeout, + _ => base.GetStatusCode(ex) + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinGetCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinGetCommand.cs new file mode 100644 index 0000000000..59d6b64b02 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinGetCommand.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceTwinGetCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-twin-get"; + public override string Name => "get-twin"; + public override string Description => "Get a device twin from an IoT Hub device registry."; + public override string Title => "Get IoT Hub Device Twin"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.DeviceId.AsRequired()); + } + + protected override IoTHubDeviceTwinGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.DeviceId = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.DeviceId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetDeviceTwin( + options.DeviceId!, + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.DeviceTwin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting device twin from IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinQueryCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinQueryCommand.cs new file mode 100644 index 0000000000..104894aa2c --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinQueryCommand.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceTwinQueryCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-twin-query"; + public override string Name => "query-twins"; + public override string Description => "Query device twins in an IoT Hub using the IoT Hub query language."; + public override string Title => "Query IoT Hub Device Twins"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = true }; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Query.AsRequired()); + } + + protected override IoTHubDeviceTwinQueryOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.Query = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Query.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.QueryTwins( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.Query!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.ListDeviceTwin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error querying device twins in IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinUpdateCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinUpdateCommand.cs new file mode 100644 index 0000000000..aab5581961 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubDeviceTwinUpdateCommand.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.Device; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.Device; + +public sealed class IoTHubDeviceTwinUpdateCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-device-twin-update"; + public override string Name => "update-twin"; + public override string Description => "Update a device twin in an IoT Hub device registry using a JSON patch document."; + public override string Title => "Update IoT Hub Device Twin"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = false, OpenWorld = false, ReadOnly = false, LocalRequired = false, Secret = true }; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.DeviceId.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Patch.AsRequired()); + } + + protected override IoTHubDeviceTwinUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.DeviceId = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.DeviceId.Name); + options.Patch = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Patch.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + // Parse the patch JSON + TwinPatch? patch; + try + { + patch = JsonSerializer.Deserialize(options.Patch!, IoTHubJsonContext.Default.TwinPatch); + if (patch == null) + { + throw new InvalidOperationException("Failed to parse patch document."); + } + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Invalid JSON patch document: {ex.Message}", ex); + } + + var result = await _service.UpdateDeviceTwin( + options.DeviceId!, + patch, + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.DeviceTwin); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating device twin in IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubQueryRunCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubQueryRunCommand.cs new file mode 100644 index 0000000000..aaa607d8bd --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/Device/IoTHubQueryRunCommand.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Diagnostics; +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubQueryRunCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-query-run"; + public override string Name => "run"; + public override string Description => "Run an IoT Hub SQL-like query against an IoT Hub. Supports read/search queries over device identities, modules, and jobs (e.g. 'SELECT * FROM devices', 'SELECT * FROM devices.modules', 'SELECT * FROM devices.jobs'). Returns exactly one page of matching records as raw JSON. For natural-language list/show requests, especially filtered device requests, use a compact projection such as 'SELECT deviceId, status, connectionState FROM devices WHERE ...' and add only the requested tag or property fields; avoid 'SELECT *' unless the user explicitly asks for raw device twins, all fields, modules, jobs, or full JSON. Use --max-count to set the page size (default 100, maximum 100). Values greater than 100 are capped at 100, so one page is always at most 100 items. Never make repeated calls or loop for additional pages in a single user request, no matter whether the user asks for all devices, the rest of the devices, every device, more than 100 matching records, or any large number. Return exactly one page and, when hasMore is true, include the continuationToken for a later explicit next-page request. The --continuation-token input must be the opaque continuationToken string returned by a previous iothub_query_run response; do not pass hasMore=true/false or any boolean value. To simply list devices through MCP - including a small, specific number such as 'show me 2 devices' - prefer the 'iothub device list' command instead; use this query command for SQL-like filtering, modules, jobs, large device sets, or paging through more than 100 records."; + public override string Title => "Run IoT Hub Query"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private const int DefaultMaxCount = 100; + private const int MinMaxCount = 1; + private const int MaxMaxCount = DefaultMaxCount; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Query.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.QueryMaxCount.AsOptional()); + command.Options.Add(IoTHubOptionDefinitions.ContinuationToken.AsOptional()); + } + + protected override IoTHubQueryRunOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.Query = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Query.Name); + var maxCount = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.MaxCount.Name); + options.MaxCount = maxCount switch + { + null => DefaultMaxCount, + > MaxMaxCount => MaxMaxCount, + _ => maxCount.Value + }; + options.ContinuationToken = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.ContinuationToken.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + if (options.MaxCount < MinMaxCount) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = $"The entered max-count '{options.MaxCount}' is less than 1 item. Please specify a value of at least {MinMaxCount}."; + return context.Response; + } + + if (IsBooleanContinuationToken(options.ContinuationToken)) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "The continuation-token value must be the opaque continuationToken string returned by a previous iothub_query_run response, not hasMore=true/false. Omit --continuation-token to fetch the first page."; + return context.Response; + } + + try + { + _logger.LogInformation( + "Starting IoT Hub query. MaxCount={MaxCount}, HasContinuationToken={HasContinuationToken}", + options.MaxCount, + !string.IsNullOrEmpty(options.ContinuationToken)); + + var stopwatch = Stopwatch.StartNew(); + var page = await _service.RunQuery( + options.Query!, + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.MaxCount, + options.ContinuationToken, + options.RetryPolicy, + cancellationToken); + stopwatch.Stop(); + + _logger.LogInformation( + "IoT Hub query completed. MaxCount={MaxCount}, Returned={Returned}, HasContinuationToken={HasContinuationToken}, ElapsedMs={ElapsedMs}", + options.MaxCount, + page.Items.Count, + !string.IsNullOrEmpty(page.ContinuationToken), + stopwatch.ElapsedMilliseconds); + + var hasMore = !string.IsNullOrEmpty(page.ContinuationToken); + var message = hasMore + ? $"Showing {page.Items.Count} results. More results are available; return this page now and use the continuationToken only on a later explicit next-page request." + : $"Showing {page.Items.Count} results. No more results are available."; + + var result = new IoTHubQueryRunResult( + page.Items, + page.Items.Count, + hasMore, + page.ContinuationToken, + message); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubQueryRunResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running query against IoT Hub"); + HandleException(context, ex); + } + + return context.Response; + } + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + TimeoutException => HttpStatusCode.RequestTimeout, + _ => base.GetStatusCode(ex) + }; + + private static bool IsBooleanContinuationToken(string? continuationToken) + { + var normalizedToken = continuationToken?.Trim(); + return string.Equals(normalizedToken, bool.TrueString, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedToken, bool.FalseString, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubCreateCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubCreateCommand.cs new file mode 100644 index 0000000000..0844f22ebf --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubCreateCommand.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubCreateCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-create"; + public override string Name => "create"; + public override string Description => "Create a new IoT Hub."; + public override string Title => "Create IoT Hub"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = false, LocalRequired = false, Secret = false }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Location.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Sku.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Capacity.AsRequired()); + } + + protected override IoTHubCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.Subscription = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); + options.Location = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Location.Name); + options.Sku = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Sku.Name); + options.Capacity = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Capacity.Name); + + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.CreateIoTHub( + options.Name!, + options.ResourceGroup!, + options.Location!, + options.Sku!, + options.Capacity!.Value, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubDescription); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating IoT Hub {Name}", options.Name); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubDeleteCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubDeleteCommand.cs new file mode 100644 index 0000000000..23048d1e66 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubDeleteCommand.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubDeleteCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-delete"; + public override string Name => "delete"; + public override string Description => "Delete an IoT Hub."; + public override string Title => "Delete IoT Hub"; + public override ToolMetadata Metadata => new() { Destructive = true, Idempotent = true, OpenWorld = false, ReadOnly = false, LocalRequired = false, Secret = false }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + } + + protected override IoTHubDeleteOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + await _service.DeleteIoTHub( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new IoTHubDeleteCommandResult("IoT Hub deleted successfully."), + IoTHubJsonContext.Default.IoTHubDeleteCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting IoT Hub {Name}", options.Name); + HandleException(context, ex); + } + + return context.Response; + } +} + +public record IoTHubDeleteCommandResult(string Message); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs new file mode 100644 index 0000000000..aa74fc4795 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubGetCommand.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubGetCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-get"; + public override string Name => "get"; + public override string Description => "Get IoT Hub details or list all IoT Hubs in a subscription."; + public override string Title => "Get IoT Hub"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override bool RequireResourceGroup => false; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsOptional()); + } + + protected override IoTHubGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetIoTHub( + options.Name, + options.ResourceGroup, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.ListIoTHubDescription); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting IoT Hub(s)"); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubKeysGetCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubKeysGetCommand.cs new file mode 100644 index 0000000000..b72b46e6dc --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubKeysGetCommand.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubKeysGetCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-keys-get"; + public override string Name => "keys"; + public override string Description => "Get keys for an IoT Hub."; + public override string Title => "Get IoT Hub Keys"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = true }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + } + + protected override IoTHubKeysGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetIoTHubKeys( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.ListIoTHubKey); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting keys for IoT Hub {Name}", options.Name); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryCompileCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryCompileCommand.cs new file mode 100644 index 0000000000..3565002467 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryCompileCommand.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Query; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubQueryCompileCommand(ILogger logger) + : BaseCommand +{ + public override string Id => "iothub-query-compile"; + public override string Name => "compile"; + public override string Description => "Compile a structured set of typed predicates into a syntactically valid IoT Hub query string. " + + "Instead of writing raw IoT Hub SQL, supply a JSON array of predicates via --filters where each predicate specifies a 'scope' " + + "(device, tags, desired, or reported), a 'field' (the property name/path within that scope), an 'operator' " + + "(equals, notEquals, lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), and a 'value' (string, number, or boolean). " + + "The compiler maps each predicate to the correct field path (tags.*, properties.desired.*, properties.reported.*, or a top-level device field), " + + "validates it, and joins the predicates with --logical-operator (AND by default). Pass the 'fields' object returned by 'iothub query discover' " + + "to --discovered-fields to reject filters that reference paths not found in sampled device twins. Optionally set --top to return a 'maxCount' hint for " + + "target 'devices' (default), 'devices.modules', or 'devices.jobs'. Returns an object with a single 'query' string that can be passed directly " + + "to 'iothub query run' along with the returned maxCount. Use this to build safe, unambiguous IoT Hub queries without hand-writing SQL. This command performs no network calls."; + public override string Title => "Compile IoT Hub Query"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubQueryCompileOptionDefinitions.Filters.AsRequired()); + command.Options.Add(IoTHubQueryCompileOptionDefinitions.From.AsOptional()); + command.Options.Add(IoTHubQueryCompileOptionDefinitions.Top.AsOptional()); + command.Options.Add(IoTHubQueryCompileOptionDefinitions.LogicalOperator.AsOptional()); + command.Options.Add(IoTHubQueryCompileOptionDefinitions.DiscoveredFields.AsOptional()); + } + + protected override IoTHubQueryCompileOptions BindOptions(ParseResult parseResult) + { + var options = new IoTHubQueryCompileOptions + { + Filters = parseResult.GetValueOrDefault(IoTHubQueryCompileOptionDefinitions.Filters.Name), + From = parseResult.GetValueOrDefault(IoTHubQueryCompileOptionDefinitions.From.Name), + Top = parseResult.GetValueOrDefault(IoTHubQueryCompileOptionDefinitions.Top.Name), + LogicalOperator = parseResult.GetValueOrDefault(IoTHubQueryCompileOptionDefinitions.LogicalOperator.Name), + DiscoveredFields = parseResult.GetValueOrDefault(IoTHubQueryCompileOptionDefinitions.DiscoveredFields.Name) + }; + return options; + } + + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return Task.FromResult(context.Response); + } + + var options = BindOptions(parseResult); + + try + { + List? filters; + try + { + filters = JsonSerializer.Deserialize(options.Filters!, IoTHubJsonContext.Default.ListQueryPredicate); + } + catch (JsonException ex) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = $"The --filters value is not valid JSON: {ex.Message}"; + return Task.FromResult(context.Response); + } + + if (filters is null || filters.Count == 0) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = "The --filters value must be a non-empty JSON array of predicate objects."; + return Task.FromResult(context.Response); + } + + QueryDiscoveredFields? discoveredFields = null; + if (!string.IsNullOrWhiteSpace(options.DiscoveredFields)) + { + try + { + discoveredFields = JsonSerializer.Deserialize(options.DiscoveredFields, IoTHubJsonContext.Default.QueryDiscoveredFields); + } + catch (JsonException ex) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = $"The --discovered-fields value is not valid JSON: {ex.Message}"; + return Task.FromResult(context.Response); + } + } + + var request = new QueryCompileRequest + { + Filters = filters, + From = string.IsNullOrWhiteSpace(options.From) ? "devices" : options.From!, + Top = options.Top, + LogicalOperator = string.IsNullOrWhiteSpace(options.LogicalOperator) ? "AND" : options.LogicalOperator!, + DiscoveredFields = discoveredFields + }; + + string query; + try + { + query = IoTHubQueryCompiler.Compile(request); + } + catch (ArgumentException ex) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = ex.Message; + return Task.FromResult(context.Response); + } + + var result = new IoTHubQueryCompileResult(query, options.Top); + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubQueryCompileResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error compiling IoT Hub query"); + HandleException(context, ex); + } + + return Task.FromResult(context.Response); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryDiscoverCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryDiscoverCommand.cs new file mode 100644 index 0000000000..964d0701f8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubQueryDiscoverCommand.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Diagnostics; +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Query; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubQueryDiscoverCommand(IIoTHubDeviceService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-query-discover"; + public override string Name => "discover"; + public override string Description => "Discover queryable IoT Hub device twin field paths by sampling a small page of devices with 'SELECT * FROM devices'. " + + "Use this before 'iothub query compile' when the user asks for fields whose exact twin path is unknown. The command returns compact field paths grouped by scope: " + + "device, tags, desired, and reported, with observed JSON types and example values. Pass the returned 'fields' object to 'iothub query compile --discovered-fields' " + + "so compile can reject nonexistent fields and suggest matching nested paths. Defaults to sampling 10 devices; --max-count controls the sample size and is capped at 100."; + public override string Title => "Discover IoT Hub Query Fields"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private const string DiscoverQuery = "SELECT * FROM devices"; + private const int DefaultMaxCount = 10; + private const int MinMaxCount = 1; + private const int MaxMaxCount = 100; + + private readonly IIoTHubDeviceService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.QueryMaxCount.AsOptional()); + } + + protected override IoTHubQueryDiscoverOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + var maxCount = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.MaxCount.Name); + options.MaxCount = maxCount switch + { + null => DefaultMaxCount, + > MaxMaxCount => MaxMaxCount, + _ => maxCount.Value + }; + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + if (options.MaxCount < MinMaxCount) + { + context.Response.Status = HttpStatusCode.BadRequest; + context.Response.Message = $"The entered max-count '{options.MaxCount}' is less than 1 item. Please specify a value of at least {MinMaxCount}."; + return context.Response; + } + + try + { + _logger.LogInformation("Starting IoT Hub query field discovery. MaxCount={MaxCount}", options.MaxCount); + + var stopwatch = Stopwatch.StartNew(); + var page = await _service.RunQuery( + DiscoverQuery, + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.MaxCount, + null, + options.RetryPolicy, + cancellationToken); + stopwatch.Stop(); + + var fields = IoTHubQueryFieldDiscoverer.Discover(page.Items); + var totalFieldCount = fields.Device.Count + fields.Tags.Count + fields.Desired.Count + fields.Reported.Count; + var message = $"Discovered {totalFieldCount} queryable field paths from {page.Items.Count} sampled device twins. Use the fields object with iothub query compile --discovered-fields for strict field validation."; + var result = new IoTHubQueryDiscoverResult(fields, page.Items.Count, options.MaxCount!.Value, message); + + _logger.LogInformation( + "IoT Hub query field discovery completed. MaxCount={MaxCount}, SampleCount={SampleCount}, FieldCount={FieldCount}, ElapsedMs={ElapsedMs}", + options.MaxCount, + page.Items.Count, + totalFieldCount, + stopwatch.ElapsedMilliseconds); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubQueryDiscoverResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering IoT Hub query fields"); + HandleException(context, ex); + } + + return context.Response; + } + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + TimeoutException => HttpStatusCode.RequestTimeout, + _ => base.GetStatusCode(ex) + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUpdateCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUpdateCommand.cs new file mode 100644 index 0000000000..b0a078a864 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUpdateCommand.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubUpdateCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-update"; + public override string Name => "update"; + public override string Description => "Update an existing IoT Hub."; + public override string Title => "Update IoT Hub"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = false, LocalRequired = false, Secret = false }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.Sku.AsOptional()); + command.Options.Add(IoTHubOptionDefinitions.Capacity.AsOptional()); + } + + protected override IoTHubUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.Sku = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Sku.Name); + options.Capacity = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Capacity.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.UpdateIoTHub( + options.Name!, + options.ResourceGroup!, + options.Sku, + options.Capacity, + options.Subscription!, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubDescription); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating IoT Hub {Name}", options.Name); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUsageShowCommand.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUsageShowCommand.cs new file mode 100644 index 0000000000..94b443f7a3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Commands/IoTHub/IoTHubUsageShowCommand.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.IoTHub.Options; +using Azure.Mcp.Tools.IoTHub.Options.IoTHub; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.IoTHub.Commands.IoTHub; + +public sealed class IoTHubUsageShowCommand(IIoTHubService service, ILogger logger) + : BaseIoTHubCommand +{ + public override string Id => "iothub-hub-usage-show"; + public override string Name => "usage-show"; + public override string Description => + "Show an IoT Hub usage/operational-state snapshot in a single call: connected device count, total device count, " + + "daily messages used, device-to-cloud (D2C) telemetry messages sent, and throttling errors. " + + "Connected and total device counts are reported over the requested window as snapshot (latest point-in-time value), " + + "peak (maximum), and average. Daily messages used resets at 00:00 UTC: a single-day window returns a scalar dailyMessageQuotaUsed, " + + "while a multi-day window returns a per-day breakdown (dailyMessageQuotaUsedByDay) plus totalMessagesUsed. " + + "The response also reports the worst single-hour D2C throttling count (peakHourlyThrottlingErrors) alongside the hub's " + + "sku and units; when any hour exceeds 1000 throttling errors it includes a recommendedSku pointing to the next tier " + + "(F1->S1, S1->S2, S2->S3) so the hub can be scaled up (no recommendation is made for S3, the top tier). " + + "Whenever recommendedSku is present, always tell the user their hub is being throttled and recommend scaling it up to that SKU tier to relieve the throttling. " + + "Use --start-time and --end-time to set the window (ISO 8601); defaults to the last 24 hours."; + public override string Title => "Show IoT Hub Usage"; + public override ToolMetadata Metadata => new() { Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, LocalRequired = false, Secret = false }; + + private readonly IIoTHubService _service = service ?? throw new ArgumentNullException(nameof(service)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(IoTHubOptionDefinitions.Name.AsRequired()); + command.Options.Add(IoTHubOptionDefinitions.StartTime); + command.Options.Add(IoTHubOptionDefinitions.EndTime); + } + + protected override IoTHubUsageShowOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Name = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.Name.Name); + options.StartTime = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.StartTime.Name); + options.EndTime = parseResult.GetValueOrDefault(IoTHubOptionDefinitions.EndTime.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var result = await _service.GetUsageSnapshot( + options.Name!, + options.ResourceGroup!, + options.Subscription!, + options.StartTime, + options.EndTime, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(result, IoTHubJsonContext.Default.IoTHubUsageSnapshot); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting IoT Hub usage snapshot"); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/GlobalUsings.cs b/tools/Azure.Mcp.Tools.IoTHub/src/GlobalUsings.cs new file mode 100644 index 0000000000..2ad8af129c --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/GlobalUsings.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceTwin.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceTwin.cs new file mode 100644 index 0000000000..4290a44072 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/DeviceTwin.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record DeviceTwin( + [property: JsonPropertyName("deviceId")] string DeviceId, + [property: JsonPropertyName("etag")] string? Etag, + [property: JsonPropertyName("deviceEtag")] string? DeviceEtag, + [property: JsonPropertyName("status")] string? Status, + [property: JsonPropertyName("statusUpdateTime")] string? StatusUpdateTime, + [property: JsonPropertyName("connectionState")] string? ConnectionState, + [property: JsonPropertyName("lastActivityTime")] string? LastActivityTime, + [property: JsonPropertyName("cloudToDeviceMessageCount")] int? CloudToDeviceMessageCount, + [property: JsonPropertyName("authenticationType")] string? AuthenticationType, + [property: JsonPropertyName("version")] long? Version, + [property: JsonPropertyName("properties")] TwinProperties? Properties, + [property: JsonPropertyName("capabilities")] DeviceCapabilities? Capabilities, + [property: JsonPropertyName("tags")] JsonElement? Tags); + +public record TwinProperties( + [property: JsonPropertyName("desired")] JsonElement? Desired, + [property: JsonPropertyName("reported")] JsonElement? Reported); + +public record DeviceCapabilities( + [property: JsonPropertyName("iotEdge")] bool? IotEdge); + +// Only the authentication type is surfaced; symmetric keys and x509 thumbprints +// from the device identity response are intentionally not mapped so they are not returned. +public record DeviceAuthentication( + [property: JsonPropertyName("type")] string? Type); + +public record DeviceIdentity( + [property: JsonPropertyName("deviceId")] string DeviceId, + [property: JsonPropertyName("generationId")] string? GenerationId, + [property: JsonPropertyName("etag")] string? Etag, + [property: JsonPropertyName("connectionState")] string? ConnectionState, + [property: JsonPropertyName("status")] string? Status, + [property: JsonPropertyName("statusReason")] string? StatusReason, + [property: JsonPropertyName("connectionStateUpdatedTime")] string? ConnectionStateUpdatedTime, + [property: JsonPropertyName("statusUpdatedTime")] string? StatusUpdatedTime, + [property: JsonPropertyName("lastActivityTime")] string? LastActivityTime, + [property: JsonPropertyName("cloudToDeviceMessageCount")] int? CloudToDeviceMessageCount, + [property: JsonPropertyName("authentication")] DeviceAuthentication? Authentication, + [property: JsonPropertyName("capabilities")] DeviceCapabilities? Capabilities); + +public record TwinPatch( + [property: JsonPropertyName("tags")] object? Tags, + [property: JsonPropertyName("properties")] TwinPatchProperties? Properties); + +public record TwinPatchProperties( + [property: JsonPropertyName("desired")] object? Desired); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDailyMessageUsage.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDailyMessageUsage.cs new file mode 100644 index 0000000000..b75077ee23 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDailyMessageUsage.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// The messages-used-against-quota value for a single UTC day. +/// +/// The UTC calendar date in yyyy-MM-dd format. +/// The daily message quota consumed on that date, or null when no data was reported. +public record IoTHubDailyMessageUsage( + string Date, + double? MessagesUsed); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs new file mode 100644 index 0000000000..99b834166f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDescription.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubDescription( + string Id, + string Name, + string Location, + string ResourceGroup, + string SubscriptionId, + string Sku, + long Capacity, + string State, + string HostName); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDeviceCountStats.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDeviceCountStats.cs new file mode 100644 index 0000000000..6ca0b33b77 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubDeviceCountStats.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// Device-count values for an IoT Hub metric over the requested time window. +/// +/// The most recent point-in-time value in the window (the current exact device count). +/// The maximum value observed across the window. +/// The mean value across the window. +public record IoTHubDeviceCountStats( + double? Snapshot, + double? Peak, + double? Average); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubKey.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubKey.cs new file mode 100644 index 0000000000..603e5dfe57 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubKey.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubKey( + string KeyName, + string PrimaryKey, + string SecondaryKey, + string Rights); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryCompileResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryCompileResult.cs new file mode 100644 index 0000000000..349aa742d9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryCompileResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// The result of compiling a into an IoT Hub query string. +/// +public record IoTHubQueryCompileResult( + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("maxCount")] int? MaxCount); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryDiscoverResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryDiscoverResult.cs new file mode 100644 index 0000000000..ce23eb41fb --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryDiscoverResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubQueryDiscoverResult( + [property: JsonPropertyName("fields")] QueryDiscoveredFields Fields, + [property: JsonPropertyName("sampleCount")] int SampleCount, + [property: JsonPropertyName("maxCount")] int MaxCount, + [property: JsonPropertyName("message")] string Message); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryPage.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryPage.cs new file mode 100644 index 0000000000..17796dfe70 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryPage.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubQueryPage( + List Items, + string? ContinuationToken); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRequest.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRequest.cs new file mode 100644 index 0000000000..e05b7d2c49 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubQueryRequest( + [property: JsonPropertyName("query")] string Query); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRunResult.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRunResult.cs new file mode 100644 index 0000000000..1c2f3380fd --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubQueryRunResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubQueryRunResult( + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("hasMore")] bool HasMore, + [property: JsonPropertyName("continuationToken")] string? ContinuationToken, + [property: JsonPropertyName("message")] string? Message); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubUsageSnapshot.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubUsageSnapshot.cs new file mode 100644 index 0000000000..661e1eeaf8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/IoTHubUsageSnapshot.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubUsageSnapshot( + string HubName, + DateTimeOffset SnapshotTime, + DateTimeOffset StartTime, + DateTimeOffset EndTime, + IoTHubDeviceCountStats ConnectedDeviceCount, + IoTHubDeviceCountStats TotalDeviceCount, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] double? DailyMessageQuotaUsed, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? DailyMessageQuotaUsedByDay, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] double? TotalMessagesUsed, + double? D2CMessageCount, + double? ThrottlingErrors, + double? PeakHourlyThrottlingErrors, + string Sku, + long Units, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? RecommendedSku); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateOperator.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateOperator.cs new file mode 100644 index 0000000000..8a2a9916d9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateOperator.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// The comparison operator applied by a . +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PredicateOperator +{ + /// Equality comparison (=). + Equals, + + /// Inequality comparison (!=). + NotEquals, + + /// Less-than comparison (<). + LessThan, + + /// Less-than-or-equal comparison (<=). + LessThanOrEqual, + + /// Greater-than comparison (>). + GreaterThan, + + /// Greater-than-or-equal comparison (>=). + GreaterThanOrEqual +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateScope.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateScope.cs new file mode 100644 index 0000000000..d91d62da56 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/PredicateScope.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// Identifies the property scope a targets within an IoT Hub device document. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PredicateScope +{ + /// A top-level device property (e.g. deviceId, status, connectionState). No prefix is applied. + Device, + + /// A device tag. Compiles to a tags. prefixed field path. + Tags, + + /// A desired twin property. Compiles to a properties.desired. prefixed field path. + Desired, + + /// A reported twin property. Compiles to a properties.reported. prefixed field path. + Reported +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryCompileRequest.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryCompileRequest.cs new file mode 100644 index 0000000000..1e8c038768 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryCompileRequest.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// A structured, typed request that the IoT Hub query compiler turns into a syntactically valid query string. +/// +public class QueryCompileRequest +{ + /// The ordered set of predicates that are combined into the query's WHERE clause. + [JsonPropertyName("filters")] + public List Filters { get; set; } = []; + + /// The source collection to query. Defaults to devices. + [JsonPropertyName("from")] + public string From { get; set; } = "devices"; + + /// An optional page-size hint returned as maxCount for query run. + [JsonPropertyName("top")] + public int? Top { get; set; } + + /// The logical operator (AND or OR) used to join predicates. Defaults to AND. + [JsonPropertyName("logicalOperator")] + public string LogicalOperator { get; set; } = "AND"; + + /// Optional discovered field paths used to validate predicates before compiling a query. + [JsonPropertyName("discoveredFields")] + public QueryDiscoveredFields? DiscoveredFields { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredField.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredField.cs new file mode 100644 index 0000000000..ab52c8beb2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredField.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record QueryDiscoveredField( + [property: JsonPropertyName("field")] string Field, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("examples")] List Examples); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredFields.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredFields.cs new file mode 100644 index 0000000000..839f352a1b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryDiscoveredFields.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public class QueryDiscoveredFields +{ + [JsonPropertyName("device")] + public List Device { get; set; } = []; + + [JsonPropertyName("tags")] + public List Tags { get; set; } = []; + + [JsonPropertyName("desired")] + public List Desired { get; set; } = []; + + [JsonPropertyName("reported")] + public List Reported { get; set; } = []; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryPredicate.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryPredicate.cs new file mode 100644 index 0000000000..9699a410c3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/QueryPredicate.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +/// +/// A single, structured filter that is compiled into one clause of an IoT Hub query WHERE expression. +/// +public class QueryPredicate +{ + /// The property scope (device, tags, desired, or reported) the predicate targets. + [JsonPropertyName("scope")] + public PredicateScope Scope { get; set; } + + /// The field path within the scope (e.g. floor, temperature, deviceId). + [JsonPropertyName("field")] + public string? Field { get; set; } + + /// The comparison operator to apply between the field and the value. + [JsonPropertyName("operator")] + public PredicateOperator Operator { get; set; } + + /// The literal value to compare against. Strings are emitted quoted; numbers and booleans are emitted as-is. + [JsonPropertyName("value")] + public JsonElement Value { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Models/RegistryStatistics.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RegistryStatistics.cs new file mode 100644 index 0000000000..9415b869d3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Models/RegistryStatistics.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.IoTHub.Models; + +public record IoTHubRegistryStatistics( + [property: JsonPropertyName("disabledDeviceCount")] long DisabledDeviceCount, + [property: JsonPropertyName("enabledDeviceCount")] long EnabledDeviceCount, + [property: JsonPropertyName("totalDeviceCount")] long TotalDeviceCount); diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceShowOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceShowOptions.cs new file mode 100644 index 0000000000..f066160bcf --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceShowOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceShowOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? DeviceId { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceStatisticsOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceStatisticsOptions.cs new file mode 100644 index 0000000000..63396d6886 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceStatisticsOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceStatisticsOptions : SubscriptionOptions +{ + public string? Name { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinGetOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinGetOptions.cs new file mode 100644 index 0000000000..e0b4ca11c8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinGetOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceTwinGetOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? DeviceId { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinQueryOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinQueryOptions.cs new file mode 100644 index 0000000000..58607bbb8b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinQueryOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceTwinQueryOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? Query { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinUpdateOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinUpdateOptions.cs new file mode 100644 index 0000000000..b3a24eafb0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubDeviceTwinUpdateOptions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.Device; + +public class IoTHubDeviceTwinUpdateOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? DeviceId { get; set; } + public string? Patch { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubQueryRunOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubQueryRunOptions.cs new file mode 100644 index 0000000000..88abf2af1d --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/Device/IoTHubQueryRunOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubQueryRunOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? Query { get; set; } + public int? MaxCount { get; set; } + public string? ContinuationToken { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubCreateOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubCreateOptions.cs new file mode 100644 index 0000000000..d3ee45c7d4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubCreateOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubCreateOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? Location { get; set; } + public string? Sku { get; set; } + public long? Capacity { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubDeleteOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubDeleteOptions.cs new file mode 100644 index 0000000000..51888ad621 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubDeleteOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubDeleteOptions : SubscriptionOptions +{ + public string? Name { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubGetOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubGetOptions.cs new file mode 100644 index 0000000000..5637972969 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubGetOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubGetOptions : SubscriptionOptions +{ + public string? Name { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubKeysGetOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubKeysGetOptions.cs new file mode 100644 index 0000000000..618a454c4b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubKeysGetOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubKeysGetOptions : SubscriptionOptions +{ + public string? Name { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptionDefinitions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptionDefinitions.cs new file mode 100644 index 0000000000..ff38e4b3e6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptionDefinitions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public static class IoTHubQueryCompileOptionDefinitions +{ + public const string FiltersName = "filters"; + public const string FromName = "from"; + public const string TopName = "top"; + public const string LogicalOperatorName = "logical-operator"; + public const string DiscoveredFieldsName = "discovered-fields"; + + public static readonly Option Filters = new( + $"--{FiltersName}" + ) + { + Description = "A JSON array of structured predicates to compile into an IoT Hub query WHERE clause. Each predicate is an object with " + + "'scope' (one of: device, tags, desired, reported), 'field' (the property name/path within the scope), 'operator' " + + "(one of: equals, notEquals, lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), and 'value' (a string, number, or boolean). " + + "Example: [{\"scope\":\"reported\",\"field\":\"batteryLevel\",\"operator\":\"lessThan\",\"value\":20}].", + Required = true + }; + + public static readonly Option From = new( + $"--{FromName}" + ) + { + Description = "The IoT Hub query source collection. Defaults to 'devices'. Supported values: devices, devices.modules, devices.jobs.", + Required = false + }; + + public static readonly Option Top = new( + $"--{TopName}" + ) + { + Description = "An optional positive integer returned as maxCount for iothub query run. IoT Hub query run applies this as the page size instead of embedding SELECT TOP in the query.", + Required = false + }; + + public static readonly Option LogicalOperator = new( + $"--{LogicalOperatorName}" + ) + { + Description = "The logical operator used to join predicates. Supported values: AND (default), OR.", + Required = false + }; + + public static readonly Option DiscoveredFields = new( + $"--{DiscoveredFieldsName}" + ) + { + Description = "An optional JSON object returned by iothub query discover as results.fields. When provided, compile validates each predicate field against the discovered paths for its scope and rejects unknown fields before constructing the query.", + Required = false + }; +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptions.cs new file mode 100644 index 0000000000..462551759f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryCompileOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubQueryCompileOptions : GlobalOptions +{ + public string? Filters { get; set; } + public string? From { get; set; } + public int? Top { get; set; } + public string? LogicalOperator { get; set; } + public string? DiscoveredFields { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryDiscoverOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryDiscoverOptions.cs new file mode 100644 index 0000000000..ecc97fe12d --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubQueryDiscoverOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubQueryDiscoverOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public int? MaxCount { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUpdateOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUpdateOptions.cs new file mode 100644 index 0000000000..a5f734de11 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUpdateOptions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubUpdateOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? Sku { get; set; } + public long? Capacity { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUsageShowOptions.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUsageShowOptions.cs new file mode 100644 index 0000000000..952d97ee14 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Options/IoTHub/IoTHubUsageShowOptions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.IoTHub.Options.IoTHub; + +public class IoTHubUsageShowOptions : SubscriptionOptions +{ + public string? Name { get; set; } + public string? StartTime { get; set; } + public string? EndTime { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryCompiler.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryCompiler.cs new file mode 100644 index 0000000000..1aefa76117 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryCompiler.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Query; + +/// +/// Compiles a structured, typed into a syntactically valid IoT Hub query string. +/// This provides a predicate layer between natural-language intent and raw IoT Hub SQL, removing ambiguity between +/// tags, desired properties, reported properties, and top-level device fields while rejecting malformed input. +/// +public static partial class IoTHubQueryCompiler +{ + // IoT Hub query sources that are supported by the read/search query surface. + private static readonly HashSet s_allowedSources = new(StringComparer.OrdinalIgnoreCase) + { + "devices", + "devices.modules", + "devices.jobs" + }; + + /// + /// Compiles the supplied request into an IoT Hub query string. + /// + /// The typed predicate plan to compile. + /// A valid IoT Hub query string, e.g. SELECT * FROM devices WHERE properties.reported.temperature > 80. + /// Thrown when the request is malformed or contains unsupported values. + public static string Compile(QueryCompileRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var source = string.IsNullOrWhiteSpace(request.From) ? "devices" : request.From.Trim(); + if (!s_allowedSources.Contains(source)) + { + throw new ArgumentException( + $"Unsupported query source '{source}'. Supported sources are: {string.Join(", ", s_allowedSources)}."); + } + + var logicalOperator = (request.LogicalOperator ?? "AND").Trim(); + if (!string.Equals(logicalOperator, "AND", StringComparison.OrdinalIgnoreCase) + && !string.Equals(logicalOperator, "OR", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Unsupported logical operator '{request.LogicalOperator}'. Supported values are 'AND' and 'OR'."); + } + + if (request.Top is { } top && top <= 0) + { + throw new ArgumentException($"The 'top' value '{top}' must be a positive integer."); + } + + var builder = new StringBuilder("SELECT * FROM ").Append(source); + + if (request.Filters is { Count: > 0 }) + { + builder.Append(" WHERE "); + var separator = $" {logicalOperator.ToUpperInvariant()} "; + for (var i = 0; i < request.Filters.Count; i++) + { + if (i > 0) + { + builder.Append(separator); + } + + builder.Append(CompilePredicate(request.Filters[i], i, request.DiscoveredFields)); + } + } + + return builder.ToString(); + } + + private static string CompilePredicate(QueryPredicate predicate, int index, QueryDiscoveredFields? discoveredFields) + { + ArgumentNullException.ThrowIfNull(predicate); + + if (string.IsNullOrWhiteSpace(predicate.Field)) + { + throw new ArgumentException($"Filter at index {index} is missing a 'field' value."); + } + + var field = predicate.Field.Trim(); + if (!FieldPathRegex().IsMatch(field)) + { + throw new ArgumentException( + $"Filter at index {index} has an invalid field path '{field}'. Only letters, digits, underscores, '$' and '.' are allowed."); + } + + ValidateDiscoveredField(predicate.Scope, field, index, discoveredFields); + + var fieldPath = predicate.Scope switch + { + PredicateScope.Device => field, + PredicateScope.Tags => $"tags.{field}", + PredicateScope.Desired => $"properties.desired.{field}", + PredicateScope.Reported => $"properties.reported.{field}", + _ => throw new ArgumentException($"Filter at index {index} has an unsupported scope '{predicate.Scope}'.") + }; + + var op = predicate.Operator switch + { + PredicateOperator.Equals => "=", + PredicateOperator.NotEquals => "!=", + PredicateOperator.LessThan => "<", + PredicateOperator.LessThanOrEqual => "<=", + PredicateOperator.GreaterThan => ">", + PredicateOperator.GreaterThanOrEqual => ">=", + _ => throw new ArgumentException($"Filter at index {index} has an unsupported operator '{predicate.Operator}'.") + }; + + var value = FormatValue(predicate.Value, index); + + return $"{fieldPath} {op} {value}"; + } + + private static void ValidateDiscoveredField(PredicateScope scope, string field, int index, QueryDiscoveredFields? discoveredFields) + { + if (discoveredFields is null) + { + return; + } + + var fields = GetFieldsForScope(discoveredFields, scope); + var scopeName = GetScopeName(scope); + if (fields.Count == 0) + { + throw new ArgumentException( + $"Filter at index {index} references '{scopeName}.{field}', but no discovered fields were provided for scope '{scopeName}'. Run iothub query discover and pass its fields to query compile."); + } + + if (fields.Any(discoveredField => string.Equals(discoveredField.Field, field, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var suggestion = fields.FirstOrDefault(discoveredField => + discoveredField.Field.EndsWith($".{field}", StringComparison.OrdinalIgnoreCase) + || string.Equals(GetLastPathSegment(discoveredField.Field), field, StringComparison.OrdinalIgnoreCase)); + var suggestionText = suggestion is null ? string.Empty : $" Did you mean '{suggestion.Field}'?"; + var knownFields = string.Join(", ", fields.Select(discoveredField => discoveredField.Field).Take(10)); + var additionalFieldCount = fields.Count > 10 ? $" and {fields.Count - 10} more" : string.Empty; + + throw new ArgumentException( + $"Filter at index {index} references unknown field '{scopeName}.{field}'.{suggestionText} Discovered fields for scope '{scopeName}' include: {knownFields}{additionalFieldCount}."); + } + + private static List GetFieldsForScope(QueryDiscoveredFields discoveredFields, PredicateScope scope) => scope switch + { + PredicateScope.Device => discoveredFields.Device, + PredicateScope.Tags => discoveredFields.Tags, + PredicateScope.Desired => discoveredFields.Desired, + PredicateScope.Reported => discoveredFields.Reported, + _ => [] + }; + + private static string GetScopeName(PredicateScope scope) => scope switch + { + PredicateScope.Device => "device", + PredicateScope.Tags => "tags", + PredicateScope.Desired => "desired", + PredicateScope.Reported => "reported", + _ => scope.ToString() + }; + + private static string GetLastPathSegment(string field) + { + var lastDot = field.LastIndexOf('.'); + return lastDot < 0 ? field : field[(lastDot + 1)..]; + } + + private static string FormatValue(JsonElement value, int index) + { + switch (value.ValueKind) + { + case JsonValueKind.Number: + return value.GetRawText(); + case JsonValueKind.True: + return "true"; + case JsonValueKind.False: + return "false"; + case JsonValueKind.String: + var raw = value.GetString() ?? string.Empty; + // Escape single quotes by doubling them to keep the query well-formed. + return $"'{raw.Replace("'", "''")}'"; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + throw new ArgumentException($"Filter at index {index} is missing a 'value'."); + default: + throw new ArgumentException( + $"Filter at index {index} has an unsupported value kind '{value.ValueKind}'. Use a string, number, or boolean."); + } + } + + [GeneratedRegex(@"^[A-Za-z0-9_$]+(\.[A-Za-z0-9_$]+)*$", RegexOptions.CultureInvariant)] + private static partial Regex FieldPathRegex(); +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryFieldDiscoverer.cs b/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryFieldDiscoverer.cs new file mode 100644 index 0000000000..16f1703733 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/src/Query/IoTHubQueryFieldDiscoverer.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tools.IoTHub.Models; + +namespace Azure.Mcp.Tools.IoTHub.Query; + +public static class IoTHubQueryFieldDiscoverer +{ + private const int MaxExamplesPerField = 3; + + public static QueryDiscoveredFields Discover(IEnumerable items) + { + var deviceFields = new FieldAccumulator(); + var tagFields = new FieldAccumulator(); + var desiredFields = new FieldAccumulator(); + var reportedFields = new FieldAccumulator(); + + foreach (var item in items) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + AddTopLevelDeviceFields(item, deviceFields); + + if (item.TryGetProperty("tags", out var tags)) + { + AddFields(tags, string.Empty, tagFields); + } + + if (item.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + { + if (properties.TryGetProperty("desired", out var desired)) + { + AddFields(desired, string.Empty, desiredFields); + } + + if (properties.TryGetProperty("reported", out var reported)) + { + AddFields(reported, string.Empty, reportedFields); + } + } + } + + return new QueryDiscoveredFields + { + Device = deviceFields.ToFields(), + Tags = tagFields.ToFields(), + Desired = desiredFields.ToFields(), + Reported = reportedFields.ToFields() + }; + } + + private static void AddTopLevelDeviceFields(JsonElement item, FieldAccumulator accumulator) + { + foreach (var property in item.EnumerateObject()) + { + if (string.Equals(property.Name, "tags", StringComparison.OrdinalIgnoreCase) + || string.Equals(property.Name, "properties", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + AddFields(property.Value, property.Name, accumulator); + } + } + + private static void AddFields(JsonElement element, string path, FieldAccumulator accumulator) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + if (property.Name.StartsWith('$')) + { + continue; + } + + var childPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}.{property.Name}"; + AddFields(property.Value, childPath, accumulator); + } + break; + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + accumulator.Add(path, GetTypeName(element), element); + break; + } + } + + private static string GetTypeName(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "boolean", + _ => element.ValueKind.ToString().ToLowerInvariant() + }; + + private sealed class FieldAccumulator + { + private readonly SortedDictionary _fields = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string field, string type, JsonElement example) + { + if (string.IsNullOrWhiteSpace(field)) + { + return; + } + + if (!_fields.TryGetValue(field, out var state)) + { + state = new FieldState(field); + _fields.Add(field, state); + } + + state.Add(type, example); + } + + public List ToFields() => _fields.Values + .Select(state => new QueryDiscoveredField(state.Field, state.Type, state.Examples)) + .ToList(); + } + + private sealed class FieldState(string field) + { + private readonly SortedSet _types = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _exampleKeys = new(StringComparer.Ordinal); + + public string Field { get; } = field; + public List Examples { get; } = []; + public string Type => _types.Count == 1 ? _types.First() : "mixed"; + + public void Add(string type, JsonElement example) + { + _types.Add(type); + + var exampleKey = example.GetRawText(); + if (Examples.Count >= MaxExamplesPerField || !_exampleKeys.Add(exampleKey)) + { + return; + } + + Examples.Add(example.Clone()); + } + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/Azure.Mcp.Tools.IoTHub.LiveTests.csproj b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/Azure.Mcp.Tools.IoTHub.LiveTests.csproj new file mode 100644 index 0000000000..0f06a032a0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/Azure.Mcp.Tools.IoTHub.LiveTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/IoTHubCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/IoTHubCommandTests.cs new file mode 100644 index 0000000000..62de144538 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.LiveTests/IoTHubCommandTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tests.Client; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.LiveTests; + +public class IoTHubCommandTests(ITestOutputHelper output) : CommandTestsBase(output) +{ + [Fact] + public async Task IoTHub_Lifecycle() + { + var hubName = "iothub-" + Guid.NewGuid().ToString().Substring(0, 8); + var location = "eastus"; + var sku = "S1"; + var capacity = 1; + + // Create + await CallToolAsync("iothub_hub_create", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "location", location }, + { "sku", sku }, + { "capacity", capacity }, + { "subscription", Settings.SubscriptionId } + }); + + // Get + await CallToolAsync("iothub_hub_get", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId } + }); + + // Update + await CallToolAsync("iothub_hub_update", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "capacity", 2 }, + { "subscription", Settings.SubscriptionId } + }); + + // Keys - will fail with consent requirement in test harness + try + { + await CallToolAsync("iothub_hub_keys", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId } + }); + } + catch (Exception ex) + { + // Expected: client doesn't support consent elicitation + Console.WriteLine($"Keys command expected failure: {ex.Message}"); + } + + // Delete + await CallToolAsync("iothub_hub_delete", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId } + }); + } + + [Fact] + public async Task IoTHubDevice_ListDevices() + { + var hubName = Environment.GetEnvironmentVariable("IOTHUB_NAME") ?? throw new InvalidOperationException("IOTHUB_NAME not set"); + + // List all devices + await CallToolAsync("iothub_device_list", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId } + }); + + // List with max count + await CallToolAsync("iothub_device_list", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "maxCount", 2 } + }); + } + + [Fact] + public async Task IoTHubDevice_GetDeviceTwin() + { + var hubName = Environment.GetEnvironmentVariable("IOTHUB_NAME") ?? throw new InvalidOperationException("IOTHUB_NAME not set"); + var deviceId = "test-device-1"; + + await CallToolAsync("iothub_device_twin_get", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "deviceId", deviceId } + }); + } + + [Fact] + public async Task IoTHubDevice_UpdateDeviceTwin() + { + var hubName = Environment.GetEnvironmentVariable("IOTHUB_NAME") ?? throw new InvalidOperationException("IOTHUB_NAME not set"); + var deviceId = "test-device-2"; + var patch = "{\"properties\":{\"desired\":{\"temperature\":80,\"humidity\":60}}}"; + + await CallToolAsync("iothub_device_twin_update", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "deviceId", deviceId }, + { "patch", patch } + }); + + // Verify the update + await CallToolAsync("iothub_device_twin_get", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "deviceId", deviceId } + }); + } + + [Fact] + public async Task IoTHubDevice_QueryTwins() + { + var hubName = Environment.GetEnvironmentVariable("IOTHUB_NAME") ?? throw new InvalidOperationException("IOTHUB_NAME not set"); + + // Query all devices + await CallToolAsync("iothub_device_twin_query", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "query", "SELECT * FROM devices" } + }); + + // Query devices with specific tag + await CallToolAsync("iothub_device_twin_query", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "query", "SELECT * FROM devices WHERE tags.environment = 'test'" } + }); + + // Query devices with temperature property + await CallToolAsync("iothub_device_twin_query", new() + { + { "name", hubName }, + { "resourceGroup", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "query", "SELECT * FROM devices WHERE properties.desired.temperature > 70" } + }); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Azure.Mcp.Tools.IoTHub.UnitTests.csproj b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Azure.Mcp.Tools.IoTHub.UnitTests.csproj new file mode 100644 index 0000000000..6012faf233 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Azure.Mcp.Tools.IoTHub.UnitTests.csproj @@ -0,0 +1,17 @@ + + + true + Exe + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceStatisticsCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceStatisticsCommandTests.cs new file mode 100644 index 0000000000..41b9f83030 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceStatisticsCommandTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubDeviceStatisticsCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubDeviceStatisticsCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeviceStatisticsCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeviceStatisticsCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_GetStatistics_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expected = new IoTHubRegistryStatistics(3, 7, 10); + + _service.GetDeviceStatistics( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + await _service.Received(1).GetDeviceStatistics( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_MissingRequiredOptions_ReturnsValidationError() + { + // Arrange - missing --name + var args = _commandDefinition.Parse([ + "--resource-group", "test-rg", + "--subscription", "sub-id" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrows_HandlesException() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + _service.GetDeviceStatistics( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns>(_ => throw new Exception("service error")); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinGetCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinGetCommandTests.cs new file mode 100644 index 0000000000..c116aa4bb0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinGetCommandTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubDeviceTwinGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubDeviceTwinGetCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeviceTwinGetCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeviceTwinGetCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_GetDeviceTwin_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var deviceId = "test-device"; + + var expectedTwin = new DeviceTwin( + deviceId, + "etag123", + "deviceEtag123", + "Enabled", + "2024-01-01T00:00:00Z", + "Connected", + "2024-01-03T00:00:00Z", + 0, + "SAS", + 1, + new TwinProperties(JsonSerializer.SerializeToElement(new Dictionary()), JsonSerializer.SerializeToElement(new Dictionary())), + new DeviceCapabilities(false), + JsonSerializer.SerializeToElement(new Dictionary())); + + _service.GetDeviceTwin( + deviceId, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedTwin); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--device-id", deviceId + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-device-twin-get", _command.Id); + Assert.Equal("get-twin", _command.Name); + Assert.NotNull(_command.Description); + Assert.True(_command.Metadata.ReadOnly); + Assert.False(_command.Metadata.Secret); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinQueryCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinQueryCommandTests.cs new file mode 100644 index 0000000000..fb5bf7c49b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinQueryCommandTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubDeviceTwinQueryCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubDeviceTwinQueryCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeviceTwinQueryCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeviceTwinQueryCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_QueryTwins_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices WHERE properties.reported.temperature > 70"; + + var expectedTwins = new List + { + new DeviceTwin( + "device1", + "etag123", + "deviceEtag123", + "Enabled", + "2024-01-01T00:00:00Z", + "Connected", + "2024-01-03T00:00:00Z", + 0, + "SAS", + 1, + new TwinProperties(JsonSerializer.SerializeToElement(new Dictionary()), JsonSerializer.SerializeToElement(new Dictionary { ["temperature"] = 75 })), + new DeviceCapabilities(false), + JsonSerializer.SerializeToElement(new Dictionary())) + }; + + _service.QueryTwins( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedTwins); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-device-twin-query", _command.Id); + Assert.Equal("query-twins", _command.Name); + Assert.NotNull(_command.Description); + Assert.True(_command.Metadata.ReadOnly); + Assert.True(_command.Metadata.Secret); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinUpdateCommandTests.cs new file mode 100644 index 0000000000..459b0f9a54 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubDeviceTwinUpdateCommandTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.Device; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubDeviceTwinUpdateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubDeviceTwinUpdateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeviceTwinUpdateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeviceTwinUpdateCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_UpdateDeviceTwin_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var deviceId = "test-device"; + var patch = "{\"properties\":{\"desired\":{\"temperature\":72}}}"; + + var expectedTwin = new DeviceTwin( + deviceId, + "etag123", + "deviceEtag123", + "Enabled", + "2024-01-01T00:00:00Z", + "Connected", + "2024-01-03T00:00:00Z", + 0, + "SAS", + 1, + new TwinProperties(JsonSerializer.SerializeToElement(new Dictionary { ["temperature"] = 72 }), JsonSerializer.SerializeToElement(new Dictionary())), + new DeviceCapabilities(false), + JsonSerializer.SerializeToElement(new Dictionary())); + + _service.UpdateDeviceTwin( + deviceId, + Arg.Any(), + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedTwin); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--device-id", deviceId, + "--patch", patch + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-device-twin-update", _command.Id); + Assert.Equal("update-twin", _command.Name); + Assert.NotNull(_command.Description); + Assert.False(_command.Metadata.ReadOnly); + Assert.True(_command.Metadata.Secret); + Assert.False(_command.Metadata.Idempotent); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryCompileCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryCompileCommandTests.cs new file mode 100644 index 0000000000..a2f143dee6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryCompileCommandTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubQueryCompileCommandTests +{ + private readonly IoTHubQueryCompileCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubQueryCompileCommandTests() + { + var logger = Substitute.For>(); + var collection = new ServiceCollection(); + _command = new IoTHubQueryCompileCommand(logger); + _context = new CommandContext(collection.BuildServiceProvider()); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_CompilesReportedPredicateWithMaxCount() + { + // Arrange + const string filters = """ + [{"scope":"reported","field":"batteryLevel","operator":"lessThan","value":20}] + """; + + var args = _commandDefinition.Parse([ + "--filters", filters, + "--top", "5" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal("SELECT * FROM devices WHERE properties.reported.batteryLevel < 20", result.Query); + Assert.Equal(5, result.MaxCount); + } + + [Fact] + public async Task ExecuteAsync_CompilesMultipleScopesWithLogicalOperator() + { + // Arrange + const string filters = """ + [ + {"scope":"tags","field":"location.floor","operator":"equals","value":1}, + {"scope":"tags","field":"machineType","operator":"equals","value":"welding"}, + {"scope":"reported","field":"temperature","operator":"greaterThan","value":80} + ] + """; + + var args = _commandDefinition.Parse([ + "--filters", filters, + "--logical-operator", "AND" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal("SELECT * FROM devices WHERE tags.location.floor = 1 AND tags.machineType = 'welding' AND properties.reported.temperature > 80", result.Query); + } + + [Fact] + public async Task ExecuteAsync_WithDiscoveredFields_CompilesKnownField() + { + // Arrange + const string filters = """ + [{"scope":"tags","field":"location.floor","operator":"equals","value":1}] + """; + const string discoveredFields = """ + { + "device": [], + "tags": [ + {"field":"location.floor","type":"number","examples":[1,2]}, + {"field":"machineType","type":"string","examples":["welding"]} + ], + "desired": [], + "reported": [] + } + """; + + var args = _commandDefinition.Parse([ + "--filters", filters, + "--discovered-fields", discoveredFields + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal("SELECT * FROM devices WHERE tags.location.floor = 1", result.Query); + } + + [Fact] + public async Task ExecuteAsync_WithDiscoveredFields_RejectsUnknownFieldWithSuggestion() + { + // Arrange + const string filters = """ + [{"scope":"tags","field":"floor","operator":"equals","value":1}] + """; + const string discoveredFields = """ + { + "device": [], + "tags": [ + {"field":"location.floor","type":"number","examples":[1,2]}, + {"field":"machineType","type":"string","examples":["welding"]} + ], + "desired": [], + "reported": [] + } + """; + + var args = _commandDefinition.Parse([ + "--filters", filters, + "--discovered-fields", discoveredFields + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("unknown field 'tags.floor'", response.Message, StringComparison.Ordinal); + Assert.Contains("Did you mean 'location.floor'?", response.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_InvalidDiscoveredFieldsJson_ReturnsBadRequest() + { + // Arrange + const string filters = """ + [{"scope":"tags","field":"location.floor","operator":"equals","value":1}] + """; + + var args = _commandDefinition.Parse([ + "--filters", filters, + "--discovered-fields", "not-json" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("--discovered-fields value is not valid JSON", response.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_InvalidJson_ReturnsBadRequest() + { + // Arrange + var args = _commandDefinition.Parse([ + "--filters", "not-json" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("not valid JSON", response.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_InvalidFieldPath_ReturnsBadRequest() + { + // Arrange + const string filters = """ + [{"scope":"reported","field":"batteryLevel;DROP","operator":"lessThan","value":20}] + """; + + var args = _commandDefinition.Parse([ + "--filters", filters + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("invalid field path", response.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_ArrayValue_ReturnsBadRequest() + { + // Arrange + const string filters = """ + [{"scope":"reported","field":"batteryLevel","operator":"equals","value":[20]}] + """; + + var args = _commandDefinition.Parse([ + "--filters", filters + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("unsupported value kind", response.Message, StringComparison.Ordinal); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-query-compile", _command.Id); + Assert.Equal("compile", _command.Name); + Assert.Contains("structured set of typed predicates", _command.Description, StringComparison.Ordinal); + Assert.Contains("iothub query run", _command.Description, StringComparison.Ordinal); + Assert.True(_command.Metadata.ReadOnly); + Assert.False(_command.Metadata.Secret); + } + + private static IoTHubQueryCompileResult DeserializeResult(CommandResponse response) + { + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json); + Assert.NotNull(result); + return result; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryDiscoverCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryDiscoverCommandTests.cs new file mode 100644 index 0000000000..3884f77fde --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryDiscoverCommandTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubQueryDiscoverCommandTests +{ + private readonly IIoTHubDeviceService _service; + private readonly IoTHubQueryDiscoverCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubQueryDiscoverCommandTests() + { + _service = Substitute.For(); + var logger = Substitute.For>(); + var collection = new ServiceCollection().AddSingleton(_service); + _command = new IoTHubQueryDiscoverCommand(_service, logger); + _context = new CommandContext(collection.BuildServiceProvider()); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_DiscoversFieldsFromSampledDevices() + { + // Arrange + var items = new List + { + JsonSerializer.SerializeToElement(new + { + deviceId = "device-1", + status = "enabled", + tags = new + { + machineType = "welding", + location = new { factory = "A", floor = 1 } + }, + properties = new + { + reported = new { batteryLevel = 92, runtimeStatus = "running", temperature = 44 }, + desired = new { targetTemperature = 70 } + } + }), + JsonSerializer.SerializeToElement(new + { + deviceId = "device-2", + status = "enabled", + tags = new + { + machineType = "welding", + location = new { factory = "A", floor = 2 } + }, + properties = new + { + reported = new { batteryLevel = 66, runtimeStatus = "error", temperature = 90 }, + desired = new { targetTemperature = 75 } + } + }) + }; + + _service.RunQuery( + "SELECT * FROM devices", + "test-hub", + "test-rg", + "sub-id", + 10, + null, + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(items, null)); + + var args = _commandDefinition.Parse([ + "--name", "test-hub", + "--resource-group", "test-rg", + "--subscription", "sub-id" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal(2, result.SampleCount); + Assert.Equal(10, result.MaxCount); + Assert.Contains(result.Fields.Device, field => field.Field == "deviceId" && field.Type == "string"); + Assert.Contains(result.Fields.Device, field => field.Field == "status" && field.Type == "string"); + Assert.Contains(result.Fields.Tags, field => field.Field == "machineType" && field.Type == "string"); + Assert.Contains(result.Fields.Tags, field => field.Field == "location.factory" && field.Type == "string"); + Assert.Contains(result.Fields.Tags, field => field.Field == "location.floor" && field.Type == "number"); + Assert.Contains(result.Fields.Desired, field => field.Field == "targetTemperature" && field.Type == "number"); + Assert.Contains(result.Fields.Reported, field => field.Field == "batteryLevel" && field.Type == "number"); + Assert.Contains(result.Fields.Reported, field => field.Field == "runtimeStatus" && field.Type == "string"); + Assert.Contains("Discovered", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_CapsMaxCountAt100() + { + // Arrange + _service.RunQuery( + "SELECT * FROM devices", + "test-hub", + "test-rg", + "sub-id", + 100, + null, + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage([], null)); + + var args = _commandDefinition.Parse([ + "--name", "test-hub", + "--resource-group", "test-rg", + "--subscription", "sub-id", + "--max-count", "500" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal(100, result.MaxCount); + + await _service.Received(1).RunQuery( + "SELECT * FROM devices", + "test-hub", + "test-rg", + "sub-id", + 100, + null, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_MaxCountLessThanOne_ReturnsBadRequest() + { + // Arrange + var args = _commandDefinition.Parse([ + "--name", "test-hub", + "--resource-group", "test-rg", + "--subscription", "sub-id", + "--max-count", "0" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("less than 1", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-query-discover", _command.Id); + Assert.Equal("discover", _command.Name); + Assert.Contains("Discover queryable IoT Hub device twin field paths", _command.Description, StringComparison.Ordinal); + Assert.Contains("iothub query compile --discovered-fields", _command.Description, StringComparison.Ordinal); + Assert.True(_command.Metadata.ReadOnly); + Assert.False(_command.Metadata.Secret); + } + + private static IoTHubQueryDiscoverResult DeserializeResult(CommandResponse response) + { + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json); + Assert.NotNull(result); + return result; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryRunCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryRunCommandTests.cs new file mode 100644 index 0000000000..16ad78529b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Device/IoTHubQueryRunCommandTests.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Device; + +public class IoTHubQueryRunCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubDeviceService _service; + private readonly ILogger _logger; + private readonly IoTHubQueryRunCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubQueryRunCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubQueryRunCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + private static List SampleItems(int count) + { + var items = new List(); + for (var i = 0; i < count; i++) + { + items.Add(JsonSerializer.SerializeToElement(new { deviceId = $"device{i}" })); + } + return items; + } + + [Fact] + public async Task ExecuteAsync_RunQuery_Success() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + + _service.RunQuery( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(SampleItems(2), null)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_DefaultsPageSizeTo100() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + + _service.RunQuery( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(SampleItems(1), null)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query + ]); + + // Act + await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + await _service.Received(1).RunQuery( + query, + name, + resourceGroup, + subscription, + 100, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_CapsPageSizeAt100() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + + _service.RunQuery( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(SampleItems(1), null)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query, + "--max-count", "1000" + ]); + + // Act + await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + await _service.Received(1).RunQuery( + query, + name, + resourceGroup, + subscription, + 100, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HasMore_PassesContinuationToken_AndReportsMore() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + var inToken = "prev-token"; + + _service.RunQuery( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + inToken, + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(SampleItems(100), "next-token")); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query, + "--max-count", "500", + "--continuation-token", inToken + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal(100, result.Count); + Assert.True(result.HasMore); + Assert.Equal("next-token", result.ContinuationToken); + Assert.Contains("More results are available", result.Message, StringComparison.Ordinal); + Assert.Contains("return this page now", result.Message, StringComparison.Ordinal); + Assert.Contains("later explicit next-page request", result.Message, StringComparison.Ordinal); + + await _service.Received(1).RunQuery( + query, + name, + resourceGroup, + subscription, + 100, + inToken, + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData("true")] + [InlineData("false")] + [InlineData(" TRUE ")] + public async Task ExecuteAsync_BooleanContinuationToken_ReturnsBadRequest(string continuationToken) + { + // Arrange + var args = _commandDefinition.Parse([ + "--name", "test-hub", + "--resource-group", "test-rg", + "--subscription", "sub-id", + "--query", "SELECT * FROM devices", + "--continuation-token", continuationToken + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("opaque continuationToken string", response.Message, StringComparison.Ordinal); + + await _service.DidNotReceive().RunQuery( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_FinalPage_ReportsNoMoreResults() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + + _service.RunQuery( + query, + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubQueryPage(SampleItems(2), null)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query, + "--max-count", "500" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + var result = DeserializeResult(response); + Assert.Equal(2, result.Count); + Assert.False(result.HasMore); + Assert.Null(result.ContinuationToken); + Assert.Contains("No more results are available", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_MaxCountLessThanOne_ReturnsBadRequest() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var query = "SELECT * FROM devices"; + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--query", query, + "--max-count", "0" + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.Status); + Assert.Null(response.Results); + Assert.Contains("less than 1", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Assert + Assert.Equal("iothub-query-run", _command.Id); + Assert.Equal("run", _command.Name); + Assert.NotNull(_command.Description); + Assert.Contains("maximum 100", _command.Description, StringComparison.Ordinal); + Assert.Contains("Values greater than 100 are capped at 100", _command.Description, StringComparison.Ordinal); + Assert.Contains("more than 100 matching records", _command.Description, StringComparison.Ordinal); + Assert.Contains("compact projection", _command.Description, StringComparison.Ordinal); + Assert.Contains("avoid 'SELECT *'", _command.Description, StringComparison.Ordinal); + Assert.Contains("large device sets", _command.Description, StringComparison.Ordinal); + Assert.Contains("SELECT * FROM devices", _command.Description, StringComparison.Ordinal); + Assert.Contains("Never make repeated calls or loop", _command.Description, StringComparison.Ordinal); + Assert.Contains("Return exactly one page", _command.Description, StringComparison.Ordinal); + Assert.Contains("opaque continuationToken string", _command.Description, StringComparison.Ordinal); + Assert.Contains("do not pass hasMore=true/false", _command.Description, StringComparison.Ordinal); + Assert.DoesNotContain("Azure CLI", _command.Description); + Assert.DoesNotContain("az iot hub query", _command.Description); + Assert.True(_command.Metadata.ReadOnly); + Assert.False(_command.Metadata.Secret); + } + + private static IoTHubQueryRunResult DeserializeResult(CommandResponse response) + { + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json); + Assert.NotNull(result); + return result; + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubCreateCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubCreateCommandTests.cs new file mode 100644 index 0000000000..deba077bbe --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubCreateCommandTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubCreateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubCreateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubCreateCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_CreatesIoTHub() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var location = "eastus"; + var sku = "S1"; + var capacity = 1; + var subscription = "sub-id"; + + var expectedHub = new IoTHubDescription( + "id", name, location, resourceGroup, subscription, sku, capacity, "Active", "test-hub.azure-devices.net"); + + _service.CreateIoTHub( + name, + resourceGroup, + location, + sku, + capacity, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedHub); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--location", location, + "--sku", sku, + "--capacity", capacity.ToString(), + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubDeleteCommandTests.cs new file mode 100644 index 0000000000..feaef41783 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubDeleteCommandTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubDeleteCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubDeleteCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubDeleteCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubDeleteCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_DeletesIoTHub() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + await _service.DeleteIoTHub( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + _service.DeleteIoTHub( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + var responseJson = JsonSerializer.Serialize(response.Results); + Assert.NotNull(responseJson); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubGetCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubGetCommandTests.cs new file mode 100644 index 0000000000..6fee730e45 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubGetCommandTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubGetCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubGetCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubGetCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_GetIoTHub() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expectedHubs = new List { + new IoTHubDescription("id", name, "eastus", resourceGroup, subscription, "S1", 1, "Active", "test-hub.azure-devices.net") + }; + + _service.GetIoTHub( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedHubs); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubKeysGetCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubKeysGetCommandTests.cs new file mode 100644 index 0000000000..98795eb748 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubKeysGetCommandTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubKeysGetCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubKeysGetCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubKeysGetCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubKeysGetCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_GetsIoTHubKeys() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expectedKeys = new List + { + new("iothubowner", "primary-key-value", "secondary-key-value", "RegistryWrite, ServiceConnect, DeviceConnect"), + new("service", "service-primary-key", "service-secondary-key", "ServiceConnect") + }; + + _service.GetIoTHubKeys( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedKeys); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expectedKeys = new List + { + new("iothubowner", "primary-key-value", "secondary-key-value", "RegistryWrite, ServiceConnect, DeviceConnect"), + new("service", "service-primary-key", "service-secondary-key", "ServiceConnect") + }; + + _service.GetIoTHubKeys( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedKeys); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + var responseJson = JsonSerializer.Serialize(response.Results); + Assert.NotNull(responseJson); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUpdateCommandTests.cs new file mode 100644 index 0000000000..1c7e6e2f28 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUpdateCommandTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.IoTHub.Commands; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubUpdateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubUpdateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubUpdateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubUpdateCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_UpdatesIoTHub() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var capacity = 2; + var sku = "S1"; + + var expectedHub = new IoTHubDescription( + "id", name, "eastus", resourceGroup, subscription, sku, capacity, "Active", "test-hub.azure-devices.net"); + + _service.UpdateIoTHub( + name, + resourceGroup, + sku, + capacity, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedHub); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--capacity", capacity.ToString() + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var capacity = 2; + + var expectedHub = new IoTHubDescription( + "id", name, "eastus", resourceGroup, subscription, "S1", capacity, "Active", "test-hub.azure-devices.net"); + + _service.UpdateIoTHub( + name, + resourceGroup, + Arg.Any(), + capacity, + subscription, + Arg.Any(), + Arg.Any()) + .Returns(expectedHub); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--capacity", capacity.ToString() + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + var responseJson = JsonSerializer.Serialize(response.Results); + Assert.NotNull(responseJson); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUsageShowCommandTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUsageShowCommandTests.cs new file mode 100644 index 0000000000..0008af699b --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/IoTHub/IoTHubUsageShowCommandTests.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.IoTHub.Commands.IoTHub; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.IoTHub; + +public class IoTHubUsageShowCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IIoTHubService _service; + private readonly ILogger _logger; + private readonly IoTHubUsageShowCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public IoTHubUsageShowCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_service); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new IoTHubUsageShowCommand(_service, _logger); + _context = new CommandContext(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_ReturnsUsageSnapshot() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + var expected = new IoTHubUsageSnapshot( + HubName: name, + SnapshotTime: DateTimeOffset.UtcNow, + StartTime: DateTimeOffset.UtcNow.AddDays(-1), + EndTime: DateTimeOffset.UtcNow, + ConnectedDeviceCount: new IoTHubDeviceCountStats(0, 0, 0), + TotalDeviceCount: new IoTHubDeviceCountStats(243, 243, 240.98), + DailyMessageQuotaUsed: 31, + DailyMessageQuotaUsedByDay: null, + TotalMessagesUsed: null, + D2CMessageCount: 0, + ThrottlingErrors: 0, + PeakHourlyThrottlingErrors: 0, + Sku: "S1", + Units: 1, + RecommendedSku: null); + + _service.GetUsageSnapshot( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expected); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + await _service.Received(1).GetUsageSnapshot( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData("--resource-group test-rg --subscription sub-id")] // missing --name + [InlineData("--name test-hub --subscription sub-id")] // missing --resource-group + [InlineData("--name test-hub --resource-group test-rg")] // missing --subscription + public async Task ExecuteAsync_MissingRequiredOption_ReturnsValidationError(string argsString) + { + // Arrange + var args = _commandDefinition.Parse(argsString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + + await _service.DidNotReceive().GetUsageSnapshot( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrows_HandlesError() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + + _service.GetUsageSnapshot( + name, + resourceGroup, + subscription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(_ => throw new InvalidOperationException("boom")); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ForwardsStartAndEndTime() + { + // Arrange + var name = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "sub-id"; + var startTime = "2026-07-07T00:00:00Z"; + var endTime = "2026-07-08T00:00:00Z"; + + _service.GetUsageSnapshot( + name, + resourceGroup, + subscription, + startTime, + endTime, + Arg.Any(), + Arg.Any()) + .Returns(new IoTHubUsageSnapshot( + name, + DateTimeOffset.UtcNow, + DateTimeOffset.Parse(startTime), + DateTimeOffset.Parse(endTime), + new IoTHubDeviceCountStats(0, 0, 0), + new IoTHubDeviceCountStats(1, 1, 1), + 1, null, null, 0, 0, 0, "S1", 1, null)); + + var args = _commandDefinition.Parse([ + "--name", name, + "--resource-group", resourceGroup, + "--subscription", subscription, + "--start-time", startTime, + "--end-time", endTime + ]); + + // Act + var response = await _command.ExecuteAsync(_context, args, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + + await _service.Received(1).GetUsageSnapshot( + name, + resourceGroup, + subscription, + startTime, + endTime, + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData("F1", "S1")] + [InlineData("S1", "S2")] + [InlineData("S2", "S3")] + [InlineData("s2", "S3")] // case-insensitive + public void DetermineRecommendedSku_AboveThreshold_RecommendsNextTier(string sku, string expected) + { + Assert.Equal(expected, IoTHubService.DetermineRecommendedSku(1001, sku)); + } + + [Fact] + public void DetermineRecommendedSku_S3_NeverRecommends() + { + // S3 is the top Standard tier, so no upgrade is ever recommended. + Assert.Null(IoTHubService.DetermineRecommendedSku(50000, "S3")); + } + + [Theory] + [InlineData(1000d)] // exactly at the threshold is not above it + [InlineData(0d)] + [InlineData(null)] + public void DetermineRecommendedSku_AtOrBelowThreshold_ReturnsNull(double? peak) + { + Assert.Null(IoTHubService.DetermineRecommendedSku(peak, "S1")); + } + + [Fact] + public void DetermineRecommendedSku_SubHourBurstAboveThreshold_StillRecommends() + { + // A partial-hour bucket with more than 1000 throttling errors still triggers a recommendation. + Assert.Equal("S2", IoTHubService.DetermineRecommendedSku(1500, "S1")); + } + + [Theory] + [InlineData("B1")] + [InlineData("B2")] + [InlineData("B3")] + public void DetermineRecommendedSku_BasicTier_ReturnsNull(string sku) + { + // Basic tiers have no defined single upgrade target here, so no recommendation is made. + Assert.Null(IoTHubService.DetermineRecommendedSku(5000, sku)); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/BlockingHttpContent.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/BlockingHttpContent.cs new file mode 100644 index 0000000000..e11bca089f --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/BlockingHttpContent.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Services; + +internal sealed class BlockingHttpContent() : HttpContent +{ + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => Task.Delay(Timeout.InfiniteTimeSpan); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/CallbackHttpMessageHandler.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/CallbackHttpMessageHandler.cs new file mode 100644 index 0000000000..3b89b452f1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/CallbackHttpMessageHandler.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Services; + +internal sealed class CallbackHttpMessageHandler(Func> sendAsync) : HttpMessageHandler +{ + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + return sendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubDeviceServiceTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubDeviceServiceTests.cs new file mode 100644 index 0000000000..5f3ff881aa --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubDeviceServiceTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Core.Services.Http; +using Azure.Mcp.Tools.IoTHub.Models; +using Azure.Mcp.Tools.IoTHub.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Services; + +public sealed class IoTHubDeviceServiceTests +{ + [Fact] + public async Task RunQuery_WhenResponseBodyDoesNotComplete_UsesRetryNetworkTimeout() + { + var hubName = "test-hub"; + var resourceGroup = "test-rg"; + var subscription = "test-sub"; + var hostName = "test-hub.azure-devices.net"; + var primaryKey = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-key")); + + var ioTHubService = Substitute.For(); + ioTHubService.GetIoTHub(hubName, resourceGroup, subscription, Arg.Any(), Arg.Any()) + .Returns([ + new IoTHubDescription( + "id", + hubName, + "eastus", + resourceGroup, + subscription, + "S1", + 1, + "Active", + hostName) + ]); + ioTHubService.GetIoTHubKeys(hubName, resourceGroup, subscription, Arg.Any(), Arg.Any()) + .Returns([ + new IoTHubKey("service", primaryKey, primaryKey, "ServiceConnect") + ]); + + var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new BlockingHttpContent() + })); + var httpClientService = Substitute.For(); + httpClientService.CreateClient().Returns(new HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan + }); + + var service = new IoTHubDeviceService( + ioTHubService, + Substitute.For(), + httpClientService, + Substitute.For(), + Substitute.For(), + Substitute.For>()); + var retryPolicy = new RetryPolicyOptions + { + HasNetworkTimeoutSeconds = true, + NetworkTimeoutSeconds = 0.01 + }; + + var exception = await Assert.ThrowsAsync(() => service.RunQuery( + "SELECT * FROM devices", + hubName, + resourceGroup, + subscription, + 100, + null, + retryPolicy, + CancellationToken.None)); + + Assert.Contains("query run", exception.Message, StringComparison.Ordinal); + Assert.Contains("timed out", exception.Message, StringComparison.Ordinal); + Assert.NotNull(handler.Request); + Assert.True(handler.Request.Headers.TryGetValues("x-ms-max-item-count", out var maxCountValues)); + Assert.Equal("100", Assert.Single(maxCountValues)); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubServiceTests.cs b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubServiceTests.cs new file mode 100644 index 0000000000..02e87b4d44 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/Azure.Mcp.Tools.IoTHub.UnitTests/Services/IoTHubServiceTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Azure.Mcp.Tools.IoTHub.Services; +using Xunit; + +namespace Azure.Mcp.Tools.IoTHub.UnitTests.Services; + +public class IoTHubServiceTests +{ + private static (DateTimeOffset TimeStamp, double Value) Sample(string utc, double value) + => (DateTimeOffset.Parse(utc, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), value); + + [Fact] + public void ComputeDailyClosingValues_UsesLatestSamplePerDay_NotDailyMax() + { + // dailyMessageQuotaUsed ramps up on Jul 10 to 246844, then the counter's reset lags into + // the early hours of Jul 11 (stale 246844) before dropping to 0 for the rest of the idle day. + var samples = new[] + { + Sample("2026-07-10T00:00:00Z", 83653), + Sample("2026-07-10T12:00:00Z", 220449), + Sample("2026-07-10T18:00:00Z", 246844), + Sample("2026-07-11T00:00:00Z", 246844), // stale carry-over, must NOT count as Jul 11 usage + Sample("2026-07-11T06:00:00Z", 0), + Sample("2026-07-11T23:00:00Z", 0), + }; + + var result = IoTHubService.ComputeDailyClosingValues(samples); + + Assert.Equal(246844, result[new DateOnly(2026, 7, 10)]); + // Closing value for Jul 11 is its last sample (0), not the lingering 246844 peak that a + // daily-maximum would have wrongly attributed to the idle day. + Assert.Equal(0, result[new DateOnly(2026, 7, 11)]); + } + + [Fact] + public void ComputeDailyClosingValues_IsOrderIndependent() + { + var samples = new[] + { + Sample("2026-07-11T23:00:00Z", 500), + Sample("2026-07-11T01:00:00Z", 900), // earlier timestamp, larger value + Sample("2026-07-11T12:00:00Z", 300), + }; + + var result = IoTHubService.ComputeDailyClosingValues(samples); + + // The latest timestamp (23:00) wins regardless of input order or magnitude. + Assert.Equal(500, result[new DateOnly(2026, 7, 11)]); + } +} diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 new file mode 100644 index 0000000000..1e129ad90c --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources-post.ps1 @@ -0,0 +1,43 @@ +param( + [string]$ResourceGroupName, + [hashtable]$DeploymentOutputs +) + +Write-Host "Setting up IoT Hub test devices..." + +$iotHubName = $DeploymentOutputs['IOTHUB_NAME'] +Write-Host "IoT Hub Name: $iotHubName" + +# Create test devices for device registry tests +$testDevices = @('test-device-1', 'test-device-2', 'test-device-3') + +foreach ($deviceId in $testDevices) { + try { + # Create device identity + az iot hub device-identity create --device-id $deviceId --hub-name $iotHubName --auth-type key + Write-Host "Created device: $deviceId" + + # Update device twin with test properties + $twinPatch = @{ + properties = @{ + desired = @{ + temperature = 72 + location = "testlab" + } + } + tags = @{ + environment = "test" + deviceType = "sensor" + } + } | ConvertTo-Json -Depth 10 + + az iot hub device-twin update --device-id $deviceId --hub-name $iotHubName --set "$twinPatch" + Write-Host "Updated device twin for: $deviceId" + } + catch { + Write-Warning "Failed to create/update device ${deviceId}: $_" + } +} + +Write-Host "IoT Hub test setup complete" + diff --git a/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep new file mode 100644 index 0000000000..36ea82f250 --- /dev/null +++ b/tools/Azure.Mcp.Tools.IoTHub/tests/test-resources.bicep @@ -0,0 +1,14 @@ +param location string = resourceGroup().location +param suffix string = uniqueString(resourceGroup().id) + +resource iothub 'Microsoft.Devices/IotHubs@2023-06-30' = { + name: 'iothub-${suffix}' + location: location + sku: { + name: 'S1' + capacity: 1 + } + properties: {} +} + +output IOTHUB_NAME string = iothub.name From d7df504184471dbb3b5ae8d3a2c77df9866513a0 Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Thu, 16 Jul 2026 11:31:42 -0700 Subject: [PATCH 5/6] Import core framework dependencies --- .../Areas/Group/Commands/GroupJsonContext.cs | 1 - .../Areas/Group/Commands/GroupListCommand.cs | 55 +- .../src/Areas/Group/GroupSetup.cs | 17 +- .../Areas/Group/Options/BaseGroupOptions.cs | 2 +- core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs | 31 + .../Discovery/BaseDiscoveryStrategy.cs | 132 + .../CommandGroupDiscoveryStrategy.cs | 46 + .../Discovery/CommandGroupServerProvider.cs | 85 + .../Discovery/CompositeDiscoveryStrategy.cs | 69 + .../ConsolidatedToolDiscoveryStrategy.cs | 297 +++ .../ConsolidatedToolServerProvider.cs | 94 + .../Commands/Discovery/DiscoveryConstants.cs | 22 + .../Commands/Discovery/IDiscoveryStrategy.cs | 36 + .../Commands/Discovery/McpServerMetadata.cs | 63 + .../Discovery/RegistryDiscoveryStrategy.cs | 72 + .../Discovery/RegistryServerProvider.cs | 134 + .../src/Areas/Server/Commands/README.md | 22 + .../Server/Commands/Runtime/IMcpRuntime.cs | 29 + .../Server/Commands/Runtime/McpRuntime.cs | 202 ++ .../Commands/ServiceCollectionExtensions.cs | 308 +++ .../Server/Commands/ServiceInfoCommand.cs | 65 + .../Server/Commands/ServiceInfoJsonContext.cs | 12 + .../Server/Commands/ServiceStartCommand.cs | 830 ++++++ .../Commands/ToolLoading/BaseToolLoader.cs | 231 ++ .../ToolLoading/CommandFactoryToolLoader.cs | 293 ++ .../ToolLoading/CompositeToolLoader.cs | 218 ++ .../Commands/ToolLoading/IToolLoader.cs | 30 + .../ToolLoading/NamespaceToolLoader.cs | 717 +++++ .../ToolLoading/RegistryToolLoader.cs | 246 ++ .../Commands/ToolLoading/ServerToolLoader.cs | 533 ++++ .../ToolLoading/SingleProxyToolLoader.cs | 502 ++++ .../Commands/ToolLoading/ToolLoaderOptions.cs | 14 + .../Server/Commands/TypeToJsonTypeMapper.cs | 186 ++ .../Models/ConsolidatedToolDefinition.cs | 38 + .../Models/OAuthProtectedResourceMetadata.cs | 35 + .../src/Areas/Server/Models/RegistryRoot.cs | 19 + .../Areas/Server/Models/RegistryServerInfo.cs | 68 + .../Areas/Server/Models/ToolInputSchema.cs | 30 + .../Areas/Server/Models/ToolPropertySchema.cs | 34 + .../src/Areas/Server/Options/ModeTypes.cs | 31 + .../Server/Options/OutgoingAuthStrategy.cs | 34 + .../Options/ServiceOptionDefinitions.cs | 94 + .../Server/Options/ServiceStartOptions.cs | 75 + .../Areas/Server/Options/TransportTypes.cs | 20 + .../Areas/Server/Resources/azure-rules.txt | 24 + .../Server/Resources/consolidated-tools.json | 2370 +++++++++++++++++ .../src/Areas/Server/Resources/registry.json | 17 + .../src/Areas/Server/ServerJsonContext.cs | 34 + .../src/Areas/Server/ServerSetup.cs | 48 + .../Commands/SubscriptionJsonContext.cs | 2 - .../Commands/SubscriptionListCommand.cs | 70 +- .../Options/SubscriptionListOptions.cs | 2 +- .../Areas/Subscription/SubscriptionSetup.cs | 8 +- .../Areas/Tools/Commands/ToolsListCommand.cs | 223 ++ .../Options/ToolsListOptionDefinitions.cs | 30 + .../Areas/Tools/Options/ToolsListOptions.cs | 19 + .../src/Areas/Tools/ToolsSetup.cs | 31 + .../src/Extensions/CommandResultExtensions.cs | 192 ++ .../HttpClientServiceCollectionExtensions.cs | 54 + .../McpServerElicitationExtensions.cs | 93 + .../src/Extensions/OpenTelemetryExtensions.cs | 170 ++ .../src/Extensions/ParseResultExtensions.cs | 42 + core/Azure.Mcp.Core/src/Models/AuthMethod.cs | 11 + .../src/Models/AzureCredentials.cs | 13 + .../src/Models/Command/CommandContext.cs | 55 + .../src/Models/Command/CommandInfo.cs | 35 + .../src/Models/Command/CommandResponse.cs | 79 + core/Azure.Mcp.Core/src/Models/ETag.cs | 10 + .../Elicitation/ElicitationRequestParams.cs | 26 + .../Models/Elicitation/ElicitationResponse.cs | 47 + .../src/Models/HiddenCommandAttribute.cs | 9 + .../src/Models/Identity/ManagedIdentity.cs | 10 + .../Identity/SystemAssignedIdentityInfo.cs | 11 + .../Identity/UserAssignedIdentityInfo.cs | 10 + .../src/Models/Metadata/MetadataDefinition.cs | 21 + .../src/Models/ModelsJsonContext.cs | 20 + .../src/Models/Option/OptionDefinitions.cs | 337 +++ .../Models/ResourceGroup/ResourceGroupInfo.cs | 11 + 78 files changed, 10117 insertions(+), 89 deletions(-) create mode 100644 core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/TypeToJsonTypeMapper.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/ToolInputSchema.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/ToolPropertySchema.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs create mode 100644 core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Extensions/HttpClientServiceCollectionExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Models/AuthMethod.cs create mode 100644 core/Azure.Mcp.Core/src/Models/AzureCredentials.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Command/CommandContext.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Command/CommandResponse.cs create mode 100644 core/Azure.Mcp.Core/src/Models/ETag.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationResponse.cs create mode 100644 core/Azure.Mcp.Core/src/Models/HiddenCommandAttribute.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Identity/ManagedIdentity.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Identity/SystemAssignedIdentityInfo.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Identity/UserAssignedIdentityInfo.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs create mode 100644 core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs create mode 100644 core/Azure.Mcp.Core/src/Models/Option/OptionDefinitions.cs create mode 100644 core/Azure.Mcp.Core/src/Models/ResourceGroup/ResourceGroupInfo.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs index 0dfa7c69ce..ac82c2477d 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs @@ -6,7 +6,6 @@ namespace Azure.Mcp.Core.Areas.Group.Commands; [JsonSerializable(typeof(GroupListCommand.Result))] -[JsonSerializable(typeof(ResourceListCommand.ResourceListCommandResult))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class GroupJsonContext : JsonSerializerContext { diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs index add4515b21..f2c0aaf010 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs @@ -2,34 +2,42 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas.Group.Options; +using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Models.ResourceGroup; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Models.Command; -using Microsoft.Mcp.Core.Models.ResourceGroup; namespace Azure.Mcp.Core.Areas.Group.Commands; -[CommandMetadata( - Id = "a0049f31-9a32-4b5e-91ec-e7b074fc7246", - Name = "list", - Title = "List Resource Groups", - Description = """ - List all resource groups in a subscription. This command retrieves all resource groups available - in the specified subscription. Results include resource group names and IDs, - returned as a JSON array. - """, - Destructive = false, - Idempotent = true, - OpenWorld = false, - ReadOnly = true, - LocalRequired = false, - Secret = false)] -public sealed class GroupListCommand(ILogger logger, IResourceGroupService resourceGroupService) : SubscriptionCommand() +public sealed class GroupListCommand(ILogger logger) : SubscriptionCommand() { + private const string CommandTitle = "List Resource Groups"; private readonly ILogger _logger = logger; - private readonly IResourceGroupService _resourceGroupService = resourceGroupService; + + public override string Id => "a0049f31-9a32-4b5e-91ec-e7b074fc7246"; + + public override string Name => "list"; + + public override string Description => + $""" + List all resource groups in a subscription. This command retrieves all resource groups available + in the specified {OptionDefinitions.Common.SubscriptionName}. Results include resource group names and IDs, + returned as a JSON array. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { @@ -42,13 +50,16 @@ public override async Task ExecuteAsync(CommandContext context, try { - var groups = await _resourceGroupService.GetResourceGroups( + var resourceGroupService = context.GetService(); + var groups = await resourceGroupService.GetResourceGroups( options.Subscription!, options.Tenant, options.RetryPolicy, cancellationToken); - context.Response.Results = ResponseResult.Create(new(groups ?? []), GroupJsonContext.Default.Result); + context.Response.Results = groups?.Count > 0 ? + ResponseResult.Create(new Result(groups), GroupJsonContext.Default.Result) : + null; } catch (Exception ex) { diff --git a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs index 460b34248b..560e81fece 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs @@ -2,9 +2,8 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas.Group.Commands; +using Azure.Mcp.Core.Commands; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Mcp.Core.Areas; -using Microsoft.Mcp.Core.Commands; namespace Azure.Mcp.Core.Areas.Group; @@ -14,26 +13,18 @@ public sealed class GroupSetup : IAreaSetup public string Title => "Azure Resource Groups"; - public CommandCategory Category => CommandCategory.SubscriptionManagement; - public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); - services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { - var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups and their resources in your subscriptions.", Title); + var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups in your subscriptions.", Title); // Register Group commands - group.AddCommand(serviceProvider); - - // Register Resource sub-group - var resource = new CommandGroup("resource", "Resource operations - Commands for listing resources within a resource group."); - group.AddSubGroup(resource); - - resource.AddCommand(serviceProvider); + var listCommand = serviceProvider.GetRequiredService(); + group.AddCommand(listCommand.Name, listCommand); return group; } diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs b/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs index 8e1ee2be57..7ffaef3dc9 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Mcp.Core.Options; +using Azure.Mcp.Core.Options; namespace Azure.Mcp.Core.Areas.Group.Options; diff --git a/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs new file mode 100644 index 0000000000..296b412cf1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Areas +{ + public interface IAreaSetup + { + /// + /// Gets the name of the area. + /// + string Name { get; } + + /// + /// Gets the user-friendly title of the area for display purposes. + /// + string Title { get; } + + /// + /// Configure any dependencies. + /// + void ConfigureServices(IServiceCollection services); + + /// + /// Gets a tree whose root node represents the area. + /// + CommandGroup RegisterCommands(IServiceProvider serviceProvider); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs new file mode 100644 index 0000000000..0e37556ffa --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Base class for MCP server discovery strategies that provides common functionality. +/// Implements client caching and server provider lookup by name. +/// +public abstract class BaseDiscoveryStrategy(ILogger logger) : IMcpDiscoveryStrategy +{ + /// + /// Logger instance for this discovery strategy. + /// + protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + /// + /// Cache of MCP clients created by this discovery strategy, keyed by server name (case-insensitive). + /// + protected readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase); + + private bool _disposed = false; + + /// + public abstract Task> DiscoverServersAsync(CancellationToken cancellationToken); + + /// + public async Task FindServerProviderAsync(string name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name, nameof(name)); + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name), "Server name cannot be null or empty."); + } + + var serverProviders = await DiscoverServersAsync(cancellationToken); + foreach (var serverProvider in serverProviders) + { + var metadata = serverProvider.CreateMetadata(); + if (metadata.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return serverProvider; + } + } + + throw new KeyNotFoundException($"No MCP server found with the name '{name}'."); + } + + /// + public async Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(name, nameof(name)); + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name), "Server name cannot be null or empty."); + } + + if (_clientCache.TryGetValue(name, out var client)) + { + return client; + } + + var serverProvider = await FindServerProviderAsync(name, cancellationToken); + client = await serverProvider.CreateClientAsync(clientOptions ?? new McpClientOptions(), cancellationToken); + _clientCache[name] = client; + + return client; + } + + /// + /// Disposes all cached MCP clients with double disposal protection. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + try + { + // First, let derived classes dispose their resources (isolated from base cleanup) + try + { + await DisposeAsyncCore(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while disposing derived resources in discovery strategy {StrategyType}", GetType().Name); + } + + // Then dispose our own critical resources using best-effort approach + var clientDisposalTasks = _clientCache.Values.Select(async client => + { + try + { + await client.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose MCP client in discovery strategy {StrategyType}", GetType().Name); + } + }); + + await Task.WhenAll(clientDisposalTasks); + _clientCache.Clear(); + } + catch (Exception ex) + { + // Log disposal failures but don't throw - we want to ensure cleanup continues + // Individual disposal errors shouldn't stop the overall disposal process + _logger.LogError(ex, "Error occurred while disposing discovery strategy {StrategyType}. Some resources may not have been properly disposed.", GetType().Name); + } + finally + { + _disposed = true; + } + } + + /// + /// Override this method in derived classes to implement disposal logic. + /// This method is called exactly once during disposal. + /// + /// A task representing the asynchronous disposal operation. + protected virtual ValueTask DisposeAsyncCore() + { + // Default implementation does nothing - derived classes override to add their specific cleanup + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs new file mode 100644 index 0000000000..d3e7ebe065 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Discovery strategy that exposes command groups as MCP servers. +/// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol. +/// +/// The command factory used to access available command groups. +/// Options for configuring the service behavior. +/// Logger instance for this discovery strategy. +public sealed class CommandGroupDiscoveryStrategy(CommandFactory commandFactory, IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger) +{ + private readonly CommandFactory _commandFactory = commandFactory; + private readonly IOptions _options = options; + + /// + /// Gets or sets the entry point to use for the command group servers. + /// This can be used to specify a custom entry point for the commands. + /// + public string? EntryPoint { get; set; } = null; + + /// + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + var providers = _commandFactory.RootGroup.SubGroup + .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => _options.Value.Namespace == null || + _options.Value.Namespace.Length == 0 || + _options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Select(group => new CommandGroupServerProvider(group) + { + ReadOnly = _options.Value.ReadOnly ?? false, + EntryPoint = EntryPoint, + }) + .Cast(); + + return Task.FromResult(providers); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs new file mode 100644 index 0000000000..99972f6f65 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Represents a command group that provides metadata and MCP client creation. +/// +public sealed class CommandGroupServerProvider(CommandGroup commandGroup) : IMcpServerProvider +{ + private readonly CommandGroup _commandGroup = commandGroup; + private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + + /// + /// Gets or sets the entry point executable path for the MCP server. + /// If set to null or empty, defaults to the current process executable. + /// + public string? EntryPoint + { + get => _entryPoint; + set => _entryPoint = string.IsNullOrWhiteSpace(value) + ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName + : value; + } + + /// + /// Gets or sets whether the MCP server should run in read-only mode. + /// + public bool ReadOnly { get; set; } = false; + + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(EntryPoint)) + { + throw new InvalidOperationException("EntryPoint must be set before creating the MCP client."); + } + + var arguments = BuildArguments(); + + var transportOptions = new StdioClientTransportOptions + { + Name = _commandGroup.Name, + Command = EntryPoint, + Arguments = arguments, + }; + + var clientTransport = new StdioClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); + } + + /// + /// Builds the command-line arguments for the MCP server process. + /// + /// An array of command-line arguments. + internal string[] BuildArguments() + { + var arguments = new List { "server", "start", "--mode", "all", "--namespace", _commandGroup.Name }; + + if (ReadOnly) + { + arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}"); + } + + return [.. arguments]; + } + + /// + /// Creates metadata for the MCP server provider based on the command group. + /// + public McpServerMetadata CreateMetadata() + { + return new McpServerMetadata + { + Id = _commandGroup.Name, + Name = _commandGroup.Name, + Title = _commandGroup.Title ?? _commandGroup.Name, + Description = _commandGroup.Description + }; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs new file mode 100644 index 0000000000..3b63315bc3 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// A discovery strategy that combines multiple discovery strategies into one. +/// This allows discovering servers from multiple sources and aggregating the results. +/// +/// The collection of discovery strategies to combine. +/// Logger instance for this discovery strategy. +public sealed class CompositeDiscoveryStrategy(IEnumerable strategies, ILogger logger) : BaseDiscoveryStrategy(logger) +{ + private readonly List _strategies = InitializeStrategies(strategies); + + /// + /// Initializes the list of discovery strategies, validating that at least one is provided. + /// + /// The collection of discovery strategies to initialize. + /// A list of initialized discovery strategies. + /// Thrown when the strategies parameter is null. + /// Thrown when no discovery strategies are provided. + private static List InitializeStrategies(IEnumerable strategies) + { + ArgumentNullException.ThrowIfNull(strategies); + + var strategyList = new List(strategies); + + if (strategyList.Count == 0) + { + throw new ArgumentException("At least one discovery strategy must be provided.", nameof(strategies)); + } + + return strategyList; + } + + /// + public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + var tasks = _strategies.Select(strategy => strategy.DiscoverServersAsync(cancellationToken)); + var results = await Task.WhenAll(tasks); + + return results.SelectMany(result => result); + } + + /// + /// Disposes all child discovery strategies and then calls the base dispose. + /// Uses best-effort disposal to ensure all strategies are disposed even if some fail. + /// + protected override async ValueTask DisposeAsyncCore() + { + // Dispose all child strategies using best-effort approach + var childDisposalTasks = _strategies.Select(async strategy => + { + try + { + await strategy.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose discovery strategy {StrategyType}", strategy.GetType().Name); + } + }); + + await Task.WhenAll(childDisposalTasks); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs new file mode 100644 index 0000000000..effb5d5531 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text.Json; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Discovery strategy that exposes command groups as MCP servers. +/// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol. +/// +/// The command factory used to access available command groups. +/// Options for configuring the service behavior. +/// Configuration options for the Azure MCP server. +/// Logger instance for this discovery strategy. +public sealed class ConsolidatedToolDiscoveryStrategy(CommandFactory commandFactory, IServiceProvider serviceProvider, IOptions options, IOptions configurationOptions, ILogger logger) : BaseDiscoveryStrategy(logger) +{ + private readonly CommandFactory _commandFactory = commandFactory; + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly IOptions _options = options; + private readonly IOptions _configurationOptions = configurationOptions; + private CommandFactory? _consolidatedCommandFactory; + + /// + /// Gets or sets the entry point to use for the command group servers. + /// This can be used to specify a custom entry point for the commands. + /// + public string? EntryPoint { get; set; } = null; + public static readonly string[] IgnoredCommandGroups = ["server", "tools"]; + + /// + /// Creates a new CommandFactory with consolidated command groups. + /// This method builds command groups from the consolidated tools definition + /// without mutating the original CommandFactory. + /// + /// A new CommandFactory instance with consolidated command groups. + public CommandFactory CreateConsolidatedCommandFactory() + { + if (_consolidatedCommandFactory != null) + { + return _consolidatedCommandFactory; + } + + // Load consolidated tool definitions from JSON file + var consolidatedTools = LoadConsolidatedToolDefinitions(); + + // Filter commands based on options + var allCommands = _commandFactory.AllCommands; + var filteredCommands = FilterCommands(allCommands); + + // Create individual area setups for each consolidated tool + // This way, each consolidated tool becomes a top-level namespace + var consolidatedAreas = new List(); + + var unmatchedCommands = new HashSet(filteredCommands.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var consolidatedTool in consolidatedTools) + { + var matchingCommands = filteredCommands + .Where(kvp => consolidatedTool.MappedToolList != null && + consolidatedTool.MappedToolList.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + if (matchingCommands.Count == 0) + { + continue; + } + +#if DEBUG + // In debug mode, validate that all tools in MappedToolList found a match when conditions are met + if (_options.Value.ReadOnly == false && (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0)) + { + if (consolidatedTool.MappedToolList != null) + { + var matchedToolNames = new HashSet(matchingCommands.Keys, StringComparer.OrdinalIgnoreCase); + var unmatchedToolsInList = consolidatedTool.MappedToolList + .Where(toolName => !matchedToolNames.Contains(toolName)) + .ToList(); + + if (unmatchedToolsInList.Count > 0) + { + var unmatchedToolsList = string.Join(", ", unmatchedToolsInList); + var errorMessage = $"Consolidated tool '{consolidatedTool.Name}' has {unmatchedToolsInList.Count} tools in MappedToolList that didn't find a match in filteredCommands: {unmatchedToolsList}"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + } + } +#endif + + // Validate metadata for each command + foreach (var (commandName, command) in matchingCommands) + { + if (!AreMetadataEqual(command.Metadata, consolidatedTool.ToolMetadata)) + { + var errorMessage = $"Command '{commandName}' has mismatched ToolMetadata for consolidated tool '{consolidatedTool.Name}'. " + + $"Command metadata: [Destructive={command.Metadata.Destructive}, Idempotent={command.Metadata.Idempotent}, " + + $"OpenWorld={command.Metadata.OpenWorld}, ReadOnly={command.Metadata.ReadOnly}, Secret={command.Metadata.Secret}, " + + $"LocalRequired={command.Metadata.LocalRequired}], " + + $"Consolidated tool metadata: [Destructive={consolidatedTool.ToolMetadata?.Destructive}, " + + $"Idempotent={consolidatedTool.ToolMetadata?.Idempotent}, OpenWorld={consolidatedTool.ToolMetadata?.OpenWorld}, " + + $"ReadOnly={consolidatedTool.ToolMetadata?.ReadOnly}, Secret={consolidatedTool.ToolMetadata?.Secret}, " + + $"LocalRequired={consolidatedTool.ToolMetadata?.LocalRequired}]"; +#if DEBUG + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); +#else + _logger.LogWarning(errorMessage); +#endif + } + + unmatchedCommands.Remove(commandName); + } + + // Create an area setup for this consolidated tool + var area = new SingleConsolidatedToolAreaSetup( + consolidatedTool, + matchingCommands + ); + + consolidatedAreas.Add(area); + } + +#if DEBUG + // Check for unmatched commands + if (unmatchedCommands.Count > 0) + { + var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c)); + var errorMessage = $"Found {unmatchedCommands.Count} unmatched commands: {unmatchedList}"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } +#else + if (unmatchedCommands.Count > 0) + { + var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c)); + _logger.LogWarning("Found {Count} unmatched commands: {Commands}", unmatchedCommands.Count, unmatchedList); + } +#endif + + // Create a new CommandFactory with all consolidated areas + var telemetryService = _serviceProvider.GetRequiredService(); + var factoryLogger = _serviceProvider.GetRequiredService>(); + + _consolidatedCommandFactory = new CommandFactory( + _serviceProvider, + consolidatedAreas, + telemetryService, + _configurationOptions, + factoryLogger + ); + + return _consolidatedCommandFactory; + } + + private List LoadConsolidatedToolDefinitions() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "Azure.Mcp.Core.Areas.Server.Resources.consolidated-tools.json"; + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + var errorMessage = $"Failed to load embedded resource '{resourceName}'"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + using var jsonDoc = JsonDocument.Parse(json); + if (!jsonDoc.RootElement.TryGetProperty("consolidated_tools", out var toolsArray)) + { + var errorMessage = "Property 'consolidated_tools' not found in consolidated-tools.json"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + + return JsonSerializer.Deserialize(toolsArray.GetRawText(), ServerJsonContext.Default.ListConsolidatedToolDefinition) ?? new List(); + } + catch (Exception ex) + { + var errorMessage = "Failed to load consolidated tools from JSON file"; + _logger.LogError(ex, errorMessage); + throw new InvalidOperationException(errorMessage); + } + } + + private Dictionary FilterCommands(IReadOnlyDictionary allCommands) + { + return allCommands + .Where(kvp => + { + var serviceArea = _commandFactory.GetServiceArea(kvp.Key); + return serviceArea == null || !IgnoredCommandGroups.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); + }) + .Where(kvp => _options.Value.ReadOnly == false || kvp.Value.Metadata.ReadOnly == true) + .Where(kvp => + { + // Filter by namespace if specified + if (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0) + { + return true; + } + var serviceArea = _commandFactory.GetServiceArea(kvp.Key); + return serviceArea != null && _options.Value.Namespace.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); + }) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + /// + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(new List()); + } + + /// + /// Compares two ToolMetadata objects for equality. + /// + /// The first ToolMetadata to compare. + /// The second ToolMetadata to compare. + /// True if the metadata objects are equal, false otherwise. + internal static bool AreMetadataEqual(ToolMetadata? metadata1, ToolMetadata? metadata2) + { + if (metadata1 == null && metadata2 == null) + { + return true; + } + + if (metadata1 == null || metadata2 == null) + { + return false; + } + + return metadata1.Destructive == metadata2.Destructive && + metadata1.Idempotent == metadata2.Idempotent && + metadata1.OpenWorld == metadata2.OpenWorld && + metadata1.ReadOnly == metadata2.ReadOnly && + metadata1.Secret == metadata2.Secret && + metadata1.LocalRequired == metadata2.LocalRequired; + } +} + +/// +/// Represents a single consolidated tool as an IAreaSetup. +/// Each instance creates a top-level namespace for one consolidated tool in the CommandFactory. +/// This allows NamespaceToolLoader to see each consolidated tool as a separate top-level namespace. +/// +internal sealed class SingleConsolidatedToolAreaSetup : IAreaSetup +{ + private readonly ConsolidatedToolDefinition _consolidatedTool; + private readonly Dictionary _matchingCommands; + + public SingleConsolidatedToolAreaSetup( + ConsolidatedToolDefinition consolidatedTool, + Dictionary matchingCommands) + { + _consolidatedTool = consolidatedTool; + _matchingCommands = matchingCommands; + } + + public string Name => _consolidatedTool.Name ?? string.Empty; + public string Title => Name; + + public void ConfigureServices(IServiceCollection services) + { + // No additional services needed + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + // Create command group for this consolidated tool + var commandGroup = new CommandGroup(Name, Title); + + // Add all matching commands to this group + foreach (var cmd in _matchingCommands) + { + commandGroup.AddCommand(cmd.Key, cmd.Value); + } + + // Set tool metadata from the consolidated tool definition + commandGroup.ToolMetadata = _consolidatedTool.ToolMetadata; + + return commandGroup; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs new file mode 100644 index 0000000000..e8fec77396 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Server provider that starts the azmcp server in "all" mode while explicitly +/// enumerating each tool (command) in a command group using repeated --tool flags. +/// This allows selective exposure of only the commands that belong to the provided group +/// without relying on the namespace grouping mechanism. +/// +public sealed class ConsolidatedToolServerProvider(CommandGroup commandGroup) : IMcpServerProvider +{ + private readonly CommandGroup _commandGroup = commandGroup; + private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + + /// + /// Gets or sets the entry point executable path for the MCP server. + /// If set to null or empty, defaults to the current process executable. + /// + public string? EntryPoint + { + get => _entryPoint; + set => _entryPoint = string.IsNullOrWhiteSpace(value) + ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName + : value; + } + + /// + /// Gets or sets whether the MCP server should run in read-only mode. + /// + public bool ReadOnly { get; set; } = false; + + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(EntryPoint)) + { + throw new InvalidOperationException("EntryPoint must be set before creating the MCP client."); + } + + var arguments = BuildArguments(); + + var transportOptions = new StdioClientTransportOptions + { + Name = _commandGroup.Name, + Command = EntryPoint, + Arguments = arguments, + }; + + var clientTransport = new StdioClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); + } + + /// + /// Builds the command-line arguments for the MCP server process. + /// Pattern: server start --mode all (--tool )+ [--read-only] + /// + internal string[] BuildArguments() + { + var arguments = new List { "server", "start", "--mode", "all" }; + + foreach (var kvp in _commandGroup.Commands) + { + arguments.Add("--tool"); + arguments.Add(kvp.Key); + } + + if (ReadOnly) + { + arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}"); + } + + return [.. arguments]; + } + + /// + /// Creates metadata for the MCP server provider based on the command group. + /// + public McpServerMetadata CreateMetadata() + { + return new McpServerMetadata + { + Id = _commandGroup.Name, + Name = _commandGroup.Name, + Description = _commandGroup.Description, + ToolMetadata = _commandGroup.ToolMetadata, + }; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs new file mode 100644 index 0000000000..1d9a77fedf --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Constants used by discovery strategies and tool loaders. +/// +public static class DiscoveryConstants +{ + /// + /// Utility namespaces that should be exposed as individual commands rather than namespace proxies. + /// These commands are always available in namespace mode regardless of namespace filters. + /// + public static readonly string[] UtilityNamespaces = ["subscription", "group"]; + + /// + /// Command groups that should be ignored when discovering namespace proxy servers. + /// These include both core infrastructure groups and utility groups that are handled separately. + /// + public static readonly string[] IgnoredCommandGroups = ["extension", "server", "tools", .. UtilityNamespaces]; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs new file mode 100644 index 0000000000..6d8b1e67d1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +public interface IMcpDiscoveryStrategy : IAsyncDisposable +{ + /// + /// Discovers available MCP servers via this strategy. + /// + /// A cancellation token. + /// A collection of discovered MCP servers. + Task> DiscoverServersAsync(CancellationToken cancellationToken); + + /// + /// Finds a server provider by name. + /// + /// The name of the server to find. + /// A cancellation token. + /// The server provider if found. + /// Thrown when no server with the specified name is found. + Task FindServerProviderAsync(string name, CancellationToken cancellationToken); + + /// + /// Gets an MCP client for the specified server. + /// + /// The name of the server to get a client for. + /// Optional client configuration options. If null, default options are used. + /// A cancellation token. + /// An MCP client that can communicate with the specified server. + /// Thrown when no server with the specified name is found. + /// Thrown when the name parameter is null. + Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs new file mode 100644 index 0000000000..9ff0009574 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Represents metadata for an MCP server, including identification and descriptive information. +/// +/// The unique identifier for the server. +/// The display name of the server. +/// A description of the server's purpose or capabilities. +public sealed class McpServerMetadata(string id = "", string name = "", string description = "") +{ + /// + /// Gets or sets the unique identifier for the server. + /// + public string Id { get; set; } = id; + + /// + /// Gets or sets the display name of the server. + /// + public string Name { get; set; } = name; + + /// + /// Gets or sets the user-friendly title of the server for display purposes. + /// + public string? Title { get; set; } + + /// + /// Gets or sets a description of the server's purpose or capabilities. + /// + public string Description { get; set; } = description; + + /// + /// Gets or sets the tool metadata for this server, containing tool-specific information. + /// + public ToolMetadata? ToolMetadata { get; set; } +} + +/// +/// Defines an interface for MCP server providers that can create server metadata and clients. +/// +public interface IMcpServerProvider +{ + /// + /// Creates metadata that describes this server provider. + /// + /// A metadata object containing the server's identity and description. + McpServerMetadata CreateMetadata(); + + /// + /// Creates an MCP client that can communicate with this server. + /// + /// Options to configure the client behavior. + /// A token to cancel the operation. + /// A configured MCP client ready for use. + /// Thrown when the server configuration doesn't specify a valid transport type (missing URL or stdio configuration). + /// Thrown when the server configuration is valid but client creation fails (e.g., missing command for stdio transport, dependency issues, or external process failures). + Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs new file mode 100644 index 0000000000..f660392048 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Discovers MCP servers from an embedded registry.json resource file. +/// This strategy loads server configurations from a JSON resource bundled with the assembly. +/// +/// Options for configuring the service behavior. +/// Logger instance for this discovery strategy. +public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger) +{ + private readonly IOptions _options = options; + + /// + public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + var registryRoot = await LoadRegistryAsync(); + if (registryRoot == null) + { + return []; + } + + return registryRoot + .Servers! + .Where(s => _options.Value.Namespace == null || + _options.Value.Namespace.Length == 0 || + _options.Value.Namespace.Contains(s.Key, StringComparer.OrdinalIgnoreCase)) + .Select(s => new RegistryServerProvider(s.Key, s.Value)) + .Cast(); + } + + /// + /// Loads the registry configuration from the embedded resource file. + /// + /// The deserialized registry root containing server configurations, or null if not found. + private async Task LoadRegistryAsync() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly + .GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("registry.json", StringComparison.OrdinalIgnoreCase)); + + if (resourceName is null) + { + return null; + } + + await using var stream = assembly.GetManifestResourceStream(resourceName)!; + var registry = await JsonSerializer.DeserializeAsync(stream, ServerJsonContext.Default.RegistryRoot); + + if (registry?.Servers != null) + { + foreach (var kvp in registry.Servers) + { + if (kvp.Value != null) + { + kvp.Value.Name = kvp.Key; + } + } + } + + return registry; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs new file mode 100644 index 0000000000..dac744e633 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Models; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Provides an MCP server implementation based on registry configuration. +/// Supports stdio transport mechanism. +/// +/// The unique identifier for the server. +/// Configuration information for the server. +public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo) : IMcpServerProvider +{ + private readonly string _id = id; + private readonly RegistryServerInfo _serverInfo = serverInfo; + + /// + /// Creates metadata that describes this registry-based server. + /// + /// A metadata object containing the server's identity and description. + public McpServerMetadata CreateMetadata() + { + return new McpServerMetadata + { + Id = _id, + Name = _id, + Title = _serverInfo.Title, + Description = _serverInfo.Description ?? string.Empty + }; + } + + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + Func>? clientFactory = null; + + // Determine which factory function to use based on configuration + if (!string.IsNullOrWhiteSpace(_serverInfo.Url)) + { + clientFactory = CreateHttpClientAsync; + } + else if (!string.IsNullOrWhiteSpace(_serverInfo.Type) && _serverInfo.Type.Equals("stdio", StringComparison.OrdinalIgnoreCase)) + { + clientFactory = CreateStdioClientAsync; + } + + if (clientFactory == null) + { + throw new ArgumentException($"Registry server '{_id}' does not have a valid transport type. Either 'url' for HTTP transport or 'type=stdio' with 'command' must be specified."); + } + + try + { + return await clientFactory(clientOptions, cancellationToken); + } + catch (Exception ex) + { + if (!string.IsNullOrWhiteSpace(_serverInfo.InstallInstructions)) + { + var errorWithInstructions = $""" + Failed to initialize the '{_id}' MCP tool. + This tool may require dependencies that are not installed. + + Installation Instructions: + {_serverInfo.InstallInstructions} + """; + + throw new InvalidOperationException(errorWithInstructions.Trim(), ex); + } + + throw new InvalidOperationException($"Failed to create MCP client for registry server '{_id}': {ex.Message}", ex); + } + } + + /// + /// Creates an MCP client that communicates with the server using Server-Sent Events (SSE). + /// + /// Options to configure the client behavior. + /// A token to cancel the operation. + /// A configured MCP client using SSE transport. + private async Task CreateHttpClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + var transportOptions = new HttpClientTransportOptions + { + Name = _id, + Endpoint = new Uri(_serverInfo.Url!), + TransportMode = HttpTransportMode.AutoDetect, + }; + var clientTransport = new HttpClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); + } + + /// + /// Creates an MCP client that communicates with the server using stdio (standard input/output). + /// + /// Options to configure the client behavior. + /// A token to cancel the operation. + /// A configured MCP client using stdio transport. + /// Thrown when the server configuration doesn't specify a valid command for stdio transport. + private async Task CreateStdioClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_serverInfo.Command)) + { + throw new InvalidOperationException($"Registry server '{_id}' does not have a valid command for stdio transport."); + } + + // Merge current system environment variables with serverInfo.Env (serverInfo.Env overrides system) + var env = Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(e => (string)e.Key, e => (string?)e.Value); + + if (_serverInfo.Env != null) + { + foreach (var kvp in _serverInfo.Env) + { + env[kvp.Key] = kvp.Value; + } + } + + var transportOptions = new StdioClientTransportOptions + { + Name = _id, + Command = _serverInfo.Command, + Arguments = _serverInfo.Args, + EnvironmentVariables = env + }; + + var clientTransport = new StdioClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md b/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md new file mode 100644 index 0000000000..f32dfe4bd5 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md @@ -0,0 +1,22 @@ + + +# Starting the Server in different modes +There are two ways to start the server in `STDIO` or `SSE` (Streaming HTTP) modes. + +## STDIO + +`dotnet build && npx @modelcontextprotocol/inspector ./bin/Debug/net9.0/azmcp.exe server start` + +## SSE +`dotnet build && ./bin/Debug/net9.0/azmcp.exe server start --transport sse` + +`npx @modelcontextprotocol/inspector` + +The SSE URI would be `http://localhost:5008` + +Then attach to the azmcp process in debugger. The default port for SSE is 5008. + +## To set timeout in mcp inspector + +http://localhost:5173/?timeout=2000000000#resources diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs new file mode 100644 index 0000000000..0b49e357d0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Runtime; + +/// +/// Defines the core functionality for a Model Context Protocol (MCP) runtime. +/// The runtime is responsible for handling tool discovery and invocation requests. +/// +public interface IMcpRuntime : IAsyncDisposable +{ + /// + /// Handles requests to list all tools available in the MCP server. + /// + /// The request context containing metadata and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the list of available tools. + ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken); + + /// + /// Handles requests to call a specific tool with the provided parameters. + /// + /// The request context containing the tool name and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the output of the tool invocation. + ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs new file mode 100644 index 0000000000..9a9bdfcb27 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Runtime; + +/// +/// Implementation of the MCP runtime that delegates tool discovery and invocation to a tool loader. +/// Provides logging and configuration support for the MCP server. +/// +public sealed class McpRuntime : IMcpRuntime +{ + private readonly IToolLoader _toolLoader; + private readonly IOptions _options; + private readonly ILogger _logger; + + private readonly ITelemetryService _telemetry; + + /// + /// Initializes a new instance of the McpRuntime class. + /// + /// The tool loader responsible for discovering and loading tools. + /// Configuration options for the MCP server. + /// Logger for runtime operations. + /// Thrown if any required dependencies are null. + public McpRuntime( + IToolLoader toolLoader, + IOptions options, + ITelemetryService telemetry, + ILogger logger) + { + _toolLoader = toolLoader ?? throw new ArgumentNullException(nameof(toolLoader)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _logger.LogInformation("McpRuntime initialized with tool loader of type {ToolLoaderType}.", _toolLoader.GetType().Name); + _logger.LogInformation("ReadOnly mode is set to {ReadOnly}.", _options.Value.ReadOnly ?? false); + _logger.LogInformation("Namespace is set to {Namespace}.", string.Join(",", _options.Value.Namespace ?? [])); + } + + /// + /// Delegates tool invocation requests to the configured tool loader. + /// + /// The request context containing the tool name and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the output of the tool invocation. + public async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + using var activity = _telemetry.StartActivity(ActivityName.ToolExecuted, request.Server.ClientInfo); + CaptureToolCallMeta(activity, request.Params?.Meta); + + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + activity?.SetStatus(ActivityStatusCode.Error)?.AddTag(TagName.ErrorDetails, content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + activity?.AddTag(TagName.ToolName, request.Params.Name); + + var symbol = OptionDefinitions.Common.Subscription; + + var subscriptionArgument = request.Params?.Arguments? + .Where(kvp => string.Equals(kvp.Key, NameNormalization.NormalizeOptionName(symbol.Name), StringComparison.OrdinalIgnoreCase)) + .Select(kvp => kvp.Value) + .FirstOrDefault(); + if (subscriptionArgument != null + && subscriptionArgument.HasValue + && subscriptionArgument.Value.ValueKind == JsonValueKind.String) + { + var subscription = subscriptionArgument.Value.GetString(); + if (subscription != null) + { + activity?.AddTag(TagName.SubscriptionGuid, subscription); + } + } + + CallToolResult callTool; + try + { + callTool = await _toolLoader.CallToolHandler(request!, cancellationToken); + + var isSuccessful = !callTool.IsError.HasValue || !callTool.IsError.Value; + if (isSuccessful) + { + activity?.SetStatus(ActivityStatusCode.Ok); + return callTool; + } + + activity?.SetStatus(ActivityStatusCode.Error); + + // In the error case, try to get some details about the error. + // Typically all the error information is stored in the first + // content block and is of type TextContentBlock. + var textContent = callTool.Content + .Where(x => x is TextContentBlock) + .Cast() + .FirstOrDefault(); + + if (textContent != default) + { + activity?.SetTag(TagName.ErrorDetails, textContent.Text); + } + + return callTool; + } + // Catches scenarios where child MCP clients are unable to be created + // due to missing dependencies or misconfiguration. + catch (InvalidOperationException ex) + { + activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling tool handler") + ?.AddTag(TagName.ErrorDetails, ex.Message); + + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = ex.Message, + }], + IsError = true, + }; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling tool handler") + ?.AddTag(TagName.ErrorDetails, ex.Message); + + throw; + } + } + + private static void CaptureToolCallMeta(Activity? activity, JsonObject? meta) + { + if (activity != null && meta != null) + { + var vsCodeConversationIdNode = meta["vscode.conversationId"]; + if (vsCodeConversationIdNode != null && vsCodeConversationIdNode.GetValueKind() == JsonValueKind.String) + { + activity.AddTag(TagName.VSCodeConversationId, vsCodeConversationIdNode.GetValue()); + } + var vsCodeRequestIdNode = meta["vscode.requestId"]; + if (vsCodeRequestIdNode != null && vsCodeRequestIdNode.GetValueKind() == JsonValueKind.String) + { + activity.AddTag(TagName.VSCodeRequestId, vsCodeRequestIdNode.GetValue()); + } + } + } + + /// + /// Delegates tool discovery requests to the configured tool loader. + /// + /// The request context containing metadata and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the list of available tools. + public async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + using var activity = _telemetry.StartActivity(ActivityName.ListToolsHandler, request.Server.ClientInfo); + + try + { + var result = await _toolLoader.ListToolsHandler(request!, cancellationToken); + activity?.SetStatus(ActivityStatusCode.Ok); + + return result; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling list tools handler") + ?.SetTag(TagName.ErrorDetails, ex.Message); + throw; + } + } + + /// + /// Disposes the tool loader and releases associated resources. + /// + public async ValueTask DisposeAsync() + { + await _toolLoader.DisposeAsync(); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..7c5713c364 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Commands.Runtime; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +// This is intentionally placed after the namespace declaration to avoid +// conflicts with Azure.Mcp.Core.Areas.Server.Options +using Options = Microsoft.Extensions.Options.Options; + +/// +/// Extension methods for configuring Azure MCP server services. +/// +public static class AzureMcpServiceCollectionExtensions +{ + private const string DefaultServerName = "Azure MCP Server"; + + /// + /// Adds the Azure MCP server services to the specified . + /// + /// The service collection to add services to. + /// The options for configuring the server. + /// The service collection with MCP server services added. + public static IServiceCollection AddAzureMcpServer(this IServiceCollection services, ServiceStartOptions serviceStartOptions) + { + // Register HTTP client services + services.AddHttpClientServices(); + + // Register options for service start + services.AddSingleton(serviceStartOptions); + services.AddSingleton(Options.Create(serviceStartOptions)); + + // Register default tool loader options from service start options + var defaultToolLoaderOptions = new ToolLoaderOptions + { + Namespace = serviceStartOptions.Namespace, + ReadOnly = serviceStartOptions.ReadOnly ?? false, + InsecureDisableElicitation = serviceStartOptions.InsecureDisableElicitation, + Tool = serviceStartOptions.Tool, + }; + + if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + { + if (defaultToolLoaderOptions.Namespace == null || defaultToolLoaderOptions.Namespace.Length == 0) + { + defaultToolLoaderOptions = defaultToolLoaderOptions with { Namespace = ["extension"] }; + } + } + + services.AddSingleton(defaultToolLoaderOptions); + services.AddSingleton(Options.Create(defaultToolLoaderOptions)); + + // Register tool loader strategies + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register server discovery strategies + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register MCP runtimes + services.AddSingleton(); + + // Register MCP discovery strategies based on proxy mode + if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) + { + services.AddSingleton(sp => + { + var discoveryStrategies = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + }; + + var logger = sp.GetRequiredService>(); + return new CompositeDiscoveryStrategy(discoveryStrategies, logger); + }); + } + else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + { + services.AddSingleton(); + } + else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy) + { + services.AddSingleton(sp => + { + var discoveryStrategies = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + }; + + var logger = sp.GetRequiredService>(); + return new CompositeDiscoveryStrategy(discoveryStrategies, logger); + }); + } + + // Configure tool loading based on mode + if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) + { + services.AddSingleton(); + } + else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + { + services.AddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var toolLoaders = new List + { + // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers. + new ServerToolLoader( + sp.GetRequiredService(), + sp.GetRequiredService>(), + loggerFactory.CreateLogger() + ), + // NamespaceToolLoader enables direct in-process execution for tools in Azure namespaces + sp.GetRequiredService(), + }; + + // Always add utility commands (subscription, group) in namespace mode + // so they are available regardless of which namespaces are loaded + var utilityToolLoaderOptions = new ToolLoaderOptions( + Namespace: Discovery.DiscoveryConstants.UtilityNamespaces, + ReadOnly: defaultToolLoaderOptions.ReadOnly, + InsecureDisableElicitation: defaultToolLoaderOptions.InsecureDisableElicitation, + Tool: defaultToolLoaderOptions.Tool + ); + + toolLoaders.Add(new CommandFactoryToolLoader( + sp, + sp.GetRequiredService(), + Options.Create(utilityToolLoaderOptions), + loggerFactory.CreateLogger() + )); + + // Append extension commands when no other namespaces are specified. + if (defaultToolLoaderOptions.Namespace?.SequenceEqual(["extension"]) == true) + { + toolLoaders.Add(sp.GetRequiredService()); + } + + return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger()); + }); + } + else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy) + { + services.AddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var consolidatedStrategy = sp.GetRequiredService(); + + // Create a new CommandFactory with consolidated command groups + var consolidatedCommandFactory = consolidatedStrategy.CreateConsolidatedCommandFactory(); + + var toolLoaders = new List + { + // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers. + new ServerToolLoader( + sp.GetRequiredService(), + sp.GetRequiredService>(), + loggerFactory.CreateLogger() + ), + // NamespaceToolLoader enables direct in-process execution for consolidated tools + new NamespaceToolLoader( + consolidatedCommandFactory, + sp.GetRequiredService>(), + sp, + loggerFactory.CreateLogger(), + false + ), + }; + + return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger()); + }); + } + else if (serviceStartOptions.Mode == ModeTypes.All) + { + services.AddSingleton(); + services.AddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var toolLoaders = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + }; + + return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger()); + }); + } + + var mcpServerOptions = services + .AddOptions() + .Configure((mcpServerOptions, mcpRuntime) => + { + var mcpServerOptionsBuilder = services.AddOptions(); + var entryAssembly = Assembly.GetEntryAssembly(); + var assemblyName = entryAssembly?.GetName(); + var serverName = entryAssembly?.GetCustomAttribute()?.Title ?? DefaultServerName; + + mcpServerOptions.ProtocolVersion = "2024-11-05"; + mcpServerOptions.ServerInfo = new Implementation + { + Name = serverName, + Version = assemblyName?.Version?.ToString() ?? "1.0.0-beta" + }; + + mcpServerOptions.Handlers = new() + { + CallToolHandler = mcpRuntime.CallToolHandler, + ListToolsHandler = mcpRuntime.ListToolsHandler, + }; + + // Add instructions for the server + mcpServerOptions.ServerInstructions = GetServerInstructions(); + }); + + var mcpServerBuilder = services.AddMcpServer(); + + if (serviceStartOptions.Transport == TransportTypes.Http) + { + mcpServerBuilder.WithHttpTransport(); + } + else + { + mcpServerBuilder.WithStdioServerTransport(); + } + + return services; + } + + /// + /// Generates comprehensive instructions for using the Azure MCP Server effectively. + /// Includes Azure best practices from embedded resource files. + /// + /// Instructions text for LLM interactions with the Azure MCP Server. + private static string GetServerInstructions() + { + var instructions = new StringBuilder(); + + try + { + var azureRulesContent = LoadAzureRulesForBestPractices(); + if (!string.IsNullOrEmpty(azureRulesContent)) + { + instructions.AppendLine(azureRulesContent); + } + } + catch (Exception) + { + // Fallback if resources are not available + instructions.AppendLine("**Note**: Azure rules resources are not available in this configuration."); + instructions.AppendLine("An error occurred while loading Azure rules."); + } + + return instructions.ToString(); + } + + /// + /// Loads Azure rules for calling bestpractices tool from embedded resource files. + /// + /// Combined content from all Azure best practices resource files. + private static string LoadAzureRulesForBestPractices() + { + var coreAssembly = typeof(AzureMcpServiceCollectionExtensions).Assembly; + var azureRulesContent = new StringBuilder(); + + // List of known best practices resource files + var resourceFile = "azure-rules.txt"; + + try + { + string resourceName = EmbeddedResourceHelper.FindEmbeddedResource(coreAssembly, resourceFile); + string content = EmbeddedResourceHelper.ReadEmbeddedResource(coreAssembly, resourceName); + + azureRulesContent.AppendLine(content); + azureRulesContent.AppendLine(); + } + catch (Exception) + { + // Log the error but continue processing other files + azureRulesContent.AppendLine($"### Error loading {resourceFile}"); + azureRulesContent.AppendLine("An error occurred while loading this section."); + azureRulesContent.AppendLine(); + } + + return azureRulesContent.ToString(); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs new file mode 100644 index 0000000000..ce269a0d08 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +/// +/// Command that provides basic server information. +/// +[HiddenCommand] +public sealed class ServiceInfoCommand(IOptions serverOptions, ILogger logger) : BaseCommand +{ + private static readonly EmptyOptions EmptyOptions = new EmptyOptions(); + + private readonly IOptions _serverOptions = serverOptions; + private readonly ILogger _logger = logger; + + public override string Id => "add0f6fe-258c-45c4-af74-0c165d4913cb"; + + public override string Name => "info"; + + public override string Description => "Displays running MCP server information."; + + public override string Title => "Server information."; + + public override ToolMetadata Metadata => new ToolMetadata + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + try + { + context.Response.Results = ResponseResult.Create( + new ServiceInfoCommandResult( + _serverOptions.Value.Name, + _serverOptions.Value.Version), + ServiceInfoJsonContext.Default.ServiceInfoCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error obtaining server information."); + HandleException(context, ex); + } + + return Task.FromResult(context.Response); + } + + protected override EmptyOptions BindOptions(ParseResult parseResult) + { + return EmptyOptions; + } + + internal record ServiceInfoCommandResult(string Name, string Version); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs new file mode 100644 index 0000000000..f4b296fa66 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +[JsonSerializable(typeof(ServiceInfoCommand.ServiceInfoCommandResult))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class ServiceInfoJsonContext : JsonSerializerContext +{ +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs new file mode 100644 index 0000000000..17cf4423e8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -0,0 +1,830 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Diagnostics; +using System.Net; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +/// +/// Command to start the MCP server with specified configuration options. +/// This command is hidden from the main command list. +/// +[HiddenCommand] +public sealed class ServiceStartCommand : BaseCommand +{ + private const string CommandTitle = "Start MCP Server"; + + /// + /// Gets the name of the command. + /// + public override string Name => "start"; + + /// + /// Gets the description of the command. + /// + public override string Description => "Starts Azure MCP Server."; + + /// + /// Gets the title of the command. + /// + public override string Title => CommandTitle; + + /// + /// Gets the metadata for this command. + /// + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true }; + + public static Action ConfigureServices { get; set; } = _ => { }; + + public static Func InitializeServicesAsync { get; set; } = _ => Task.CompletedTask; + + public override string Id => "9953ff62-e3d7-4bdf-9b70-d569e54e3df1"; + + /// + /// Registers command options for the service start command. + /// + /// The command to register options with. + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(ServiceOptionDefinitions.Transport); + command.Options.Add(ServiceOptionDefinitions.Namespace); + command.Options.Add(ServiceOptionDefinitions.Mode); + command.Options.Add(ServiceOptionDefinitions.Tool); + command.Options.Add(ServiceOptionDefinitions.ReadOnly); + command.Options.Add(ServiceOptionDefinitions.Debug); + command.Options.Add(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); + command.Options.Add(ServiceOptionDefinitions.InsecureDisableElicitation); + command.Options.Add(ServiceOptionDefinitions.OutgoingAuthStrategy); + command.Validators.Add(commandResult => + { + string transport = ResolveTransport(commandResult); + bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); + ValidateMode(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode), commandResult); + ValidateTransportConfiguration(transport, httpIncomingAuthDisabled, commandResult); + ValidateNamespaceAndToolMutualExclusion( + commandResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name), + commandResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name), + commandResult); + ValidateOutgoingAuthStrategy(commandResult); + }); + } + + /// + /// Binds the parsed command line arguments to the ServiceStartOptions object. + /// + /// The parsed command line arguments. + /// A configured ServiceStartOptions instance. + protected override ServiceStartOptions BindOptions(ParseResult parseResult) + { + var mode = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Mode.Name); + var tools = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name); + + // When --tool switch is used, automatically change the mode to "all" + if (tools != null && tools.Length > 0) + { + mode = ModeTypes.All; + } + + var outgoingAuthStrategy = ResolveAuthStrategy(parseResult); + + var options = new ServiceStartOptions + { + Transport = ResolveTransport(parseResult), + Namespace = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name), + Mode = mode, + Tool = tools, + ReadOnly = parseResult.GetValueOrDefault(ServiceOptionDefinitions.ReadOnly.Name), + Debug = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Debug.Name), + DangerouslyDisableHttpIncomingAuth = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name), + InsecureDisableElicitation = parseResult.GetValueOrDefault(ServiceOptionDefinitions.InsecureDisableElicitation.Name), + OutgoingAuthStrategy = outgoingAuthStrategy + }; + return options; + } + + /// + /// Executes the service start command, creating and starting the MCP server. + /// + /// The command execution context. + /// The parsed command options. + /// A command response indicating the result of the operation. + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + // Update the UserAgentPolicy for all Azure service calls to include the transport type. + var transport = string.IsNullOrEmpty(options.Transport) ? TransportTypes.StdIo : options.Transport; + BaseAzureService.InitializeUserAgentPolicy(transport); + + try + { + using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options); + + using var host = CreateHost(options); + + await InitializeServicesAsync(host.Services); + + await host.StartAsync(cancellationToken); + + var telemetryService = host.Services.GetRequiredService(); + LogStartTelemetry(telemetryService, options); + + await host.WaitForShutdownAsync(cancellationToken); + + return context.Response; + } + catch (Exception ex) + { + HandleException(context, ex); + return context.Response; + } + } + + internal static void LogStartTelemetry(ITelemetryService telemetryService, ServiceStartOptions options) + { + using var activity = telemetryService.StartActivity(ActivityName.ServerStarted); + + if (activity != null) + { + activity.SetTag(TagName.Transport, options.Transport); + activity.SetTag(TagName.ServerMode, options.Mode); + activity.SetTag(TagName.IsReadOnly, options.ReadOnly); + activity.SetTag(TagName.InsecureDisableElicitation, options.InsecureDisableElicitation); + activity.SetTag(TagName.DangerouslyDisableHttpIncomingAuth, options.DangerouslyDisableHttpIncomingAuth); + activity.SetTag(TagName.IsDebug, options.Debug); + + if (options.Namespace != null && options.Namespace.Length > 0) + { + activity.SetTag(TagName.Namespace, string.Join(",", options.Namespace)); + } + if (options.Tool != null && options.Tool.Length > 0) + { + activity.SetTag(TagName.Tool, string.Join(",", options.Tool)); + } + } + } + + /// + /// Validates if the provided mode is a valid mode type. + /// + /// The mode to validate. + /// Command result to update on failure. + private static void ValidateMode(string? mode, CommandResult commandResult) + { + if (mode == ModeTypes.SingleToolProxy || + mode == ModeTypes.NamespaceProxy || + mode == ModeTypes.All || + mode == ModeTypes.ConsolidatedProxy) + { + return; // Success + } + + commandResult.AddError($"Invalid mode '{mode}'. Valid modes are: {ModeTypes.SingleToolProxy}, {ModeTypes.NamespaceProxy}, {ModeTypes.All}, {ModeTypes.ConsolidatedProxy}."); + } + + /// + /// Validates the transport configuration, ensuring the transport type is valid and compatible with other options. + /// Verifies that HTTP transport is only used when available (ENABLE_HTTP), and that --dangerously-disable-http-incoming-auth + /// is only specified with HTTP transport. + /// + /// The transport to validate. + /// Whether HTTP incoming authentication is disabled. + /// Command result to update on failure. + private static void ValidateTransportConfiguration(string transport, bool httpIncomingAuthDisabled, CommandResult commandResult) + { + if (transport == TransportTypes.StdIo) + { + if (httpIncomingAuthDisabled) + { + commandResult.AddError($"The --dangerously-disable-http-incoming-auth option cannot be used with the {TransportTypes.StdIo} transport. To use this option, specify {TransportTypes.Http} transport with --transport http."); + } + return; + } + + if (transport == TransportTypes.Http) + { +#if ENABLE_HTTP + return; +#else + commandResult.AddError($"{TransportTypes.Http} transport is only supported in the Docker image distribution of Azure MCP Server. Please use the Docker image or switch to {TransportTypes.StdIo} transport."); + return; +#endif + } + + commandResult.AddError($"Invalid transport '{transport}'. Valid transports are: {TransportTypes.StdIo}, {TransportTypes.Http}."); + } + + /// + /// Validates that --namespace and --tool options are not used together. + /// + /// The namespace values. + /// The tool values. + /// Command result to update on failure. + private static void ValidateNamespaceAndToolMutualExclusion(string[]? namespaces, string[]? tools, CommandResult commandResult) + { + bool hasNamespace = namespaces != null && namespaces.Length > 0; + bool hasTool = tools != null && tools.Length > 0; + + if (hasNamespace && hasTool) + { + commandResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both."); + } + } + + /// + /// Validates that the outgoing authentication strategy is compatible with the hosting mode. + /// + /// Command result to update on failure. + private static void ValidateOutgoingAuthStrategy(CommandResult commandResult) + { + var outgoingAuthStrategy = commandResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name); + if (outgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf) + { +#if ENABLE_HTTP + string transport = ResolveTransport(commandResult); + bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); + + if (transport != TransportTypes.Http || httpIncomingAuthDisabled) + { + commandResult.AddError($"The {OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName})."); + } + return; +#else + commandResult.AddError($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy is only supported in the Docker image distribution of Azure MCP Server. " + + "Please use the Docker image or switch to a different outgoing authentication strategy."); +#endif + } + } + + /// + /// Provides custom error messages for specific exception types to improve user experience. + /// + /// The exception to format an error message for. + /// A user-friendly error message. + protected override string GetErrorMessage(Exception ex) => ex switch + { + ArgumentException argEx when argEx.Message.Contains("Invalid transport") => + "Invalid transport option specified. Use --transport stdio for the supported transport mechanism.", + ArgumentException argEx when argEx.Message.Contains("Invalid mode") => + "Invalid mode option specified. Use --mode single, namespace, or all for the supported modes.", + ArgumentException argEx when argEx.Message.Contains("--namespace and --tool options cannot be used together") => + "Configuration error: The --namespace and --tool options are mutually exclusive. Use either one or the other to filter available tools.", + ArgumentException argEx when argEx.Message.Contains($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy") => + $"Configuration error: The {OutgoingAuthStrategy.UseOnBehalfOf} authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName}).", + InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --dangerously-disable-http-incoming-auth") => + "Configuration error to disable incoming HTTP authentication. Ensure proper authentication is configured with Managed Identity or Workload Identity.", + _ => base.GetErrorMessage(ex) + }; + + /// + /// Creates the host for the MCP server with the specified options. + /// + /// The server configuration options. + /// An IHost instance configured for the MCP server. + private IHost CreateHost(ServiceStartOptions serverOptions) + { +#if ENABLE_HTTP + if (serverOptions.Transport == TransportTypes.Http) + { + if (serverOptions.DangerouslyDisableHttpIncomingAuth) + { + return CreateIncomingAuthDisabledHttpHost(serverOptions); + } + else + { + return CreateHttpHost(serverOptions); + } + } + else + { + return CreateStdioHost(serverOptions); + } +#else + return CreateStdioHost(serverOptions); +#endif + } + + /// + /// Creates a host for STDIO transport. + /// + /// The server configuration options. + /// An IHost instance configured for STDIO transport. + private IHost CreateStdioHost(ServiceStartOptions serverOptions) + { + return Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.ConfigureOpenTelemetryLogger(); + logging.AddEventSourceLogger(); + + if (serverOptions.Debug) + { + // Configure console logger to emit Debug+ to stderr so tests can capture logs from StandardError + logging.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Debug; + options.FormatterName = Microsoft.Extensions.Logging.Console.ConsoleFormatterNames.Simple; + }); + logging.AddSimpleConsole(simple => + { + simple.ColorBehavior = Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Disabled; + simple.IncludeScopes = false; + simple.SingleLine = true; + simple.TimestampFormat = "[HH:mm:ss] "; + }); + logging.AddFilter("Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider", LogLevel.Debug); + logging.SetMinimumLevel(LogLevel.Debug); + } + }) + .ConfigureServices(services => + { + // Configure the outgoing authentication strategy. + services.AddSingleIdentityTokenCredentialProvider(); + + ConfigureServices(services); + ConfigureMcpServer(services, serverOptions); + }) + .Build(); + } + + /// + /// Creates a host for HTTP transport. + /// + /// The server configuration options. + /// An IHost instance configured for HTTP transport. + private IHost CreateHttpHost(ServiceStartOptions serverOptions) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + + // Configure logging + builder.Logging.ClearProviders(); + builder.Logging.ConfigureOpenTelemetryLogger(); + builder.Logging.AddEventSourceLogger(); + builder.Logging.AddConsole(); + + IServiceCollection services = builder.Services; + + // Configure outgoing and incoming authentication and authorization. + // + // Configure incoming authentication and authorization. + MicrosoftIdentityWebApiAuthenticationBuilderWithConfiguration authBuilder = services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration); + + // Configure incoming auth JWT Bearer events for OAuth protected resource metadata. + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Events = new JwtBearerEvents + { + OnChallenge = context => + { + // Add resource_metadata parameter to WWW-Authenticate header + if (!context.Response.HasStarted) + { + HttpRequest request = context.Request; + string resourceMetadataUrl = $"{request.Scheme}://{request.Host}/.well-known/oauth-protected-resource"; + + // Modify the WWW-Authenticate header to include resource_metadata + context.Response.Headers.WWWAuthenticate = + $"Bearer realm=\"{request.Host}\", resource_metadata=\"{resourceMetadataUrl}\""; + } + return Task.CompletedTask; + } + }; + }); + + // Configure authorization policy for MCP access. + services.AddAuthorizationBuilder() + .SetFallbackPolicy(null) + .AddPolicy("McpAccess", policy => + { + policy.RequireAuthenticatedUser(); + + // Naming conventions used based on well-known Microsoft services, like MS Graph: + // - Scopes for delegated permissions: Mcp.Tools.Verb + // - App roles for application permissions: Mcp.Tools.Verb.All + // As of Oct 2025, we only have ReadWrite as a verb, but this can be extended + // in the future as needed. Other scenarios that aren't "MCP" or "Tools" can + // also be added in the future as they become relevant. + policy.RequireScopeOrAppPermission( + allowedScopeValues: ["Mcp.Tools.ReadWrite"], + allowedAppPermissionValues: ["Mcp.Tools.ReadWrite.All"]); + }); + + // Configure outgoing authentication strategy + if (serverOptions.OutgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf) + { + services.AddHttpOnBehalfOfTokenCredentialProvider(authBuilder); + } + else + { + services.AddSingleIdentityTokenCredentialProvider(); + } + + // Add a multi-user, HTTP context-aware caching strategy to isolate cache entries. + services.AddHttpServiceCacheService(); + + + // Configure non-MCP controllers/endpoints/routes/etc. + services.AddHealthChecks(); + + // Configure CORS + // We're allowing all origins, methods, and headers to support any web + // browser clients. + // Non-browser clients are unaffected by CORS. + services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // Configure services + ConfigureServices(services); // Our static callback hook + ConfigureMcpServer(services, serverOptions); + + WebApplication app = builder.Build(); + + UseHttpsRedirectionIfEnabled(app); + + // Configure middleware pipeline + app.UseCors("AllowAll"); + app.UseRouting(); + + // Add OAuth protected resource metadata middleware + // + app.Use(async (context, next) => + { + if (context.Request.Path == "/.well-known/oauth-protected-resource" && + context.Request.Method == "GET") + { + IOptionsMonitor azureAdOptionsMonitor = context + .RequestServices + .GetRequiredService>(); + MicrosoftIdentityOptions azureAdOptions = azureAdOptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme); + HttpRequest request = context.Request; + string baseUrl = $"{request.Scheme}://{request.Host}"; + string? clientId = azureAdOptions.ClientId; + string? tenantId = azureAdOptions.TenantId; + string instance = azureAdOptions.Instance?.TrimEnd('/') ?? "https://login.microsoftonline.com"; + + var metadata = new OAuthProtectedResourceMetadata + { + Resource = baseUrl, + AuthorizationServers = [$"{instance}/{tenantId}/v2.0"], + + // Only delegated permissions for user principal authorization is listed here. + // Client with users send these scopes to the identity platform to acquire an + // access token. + // However, special to Entra, service principals are expected to always + // request the special `app-id/.default` scope because service principals use + // app roles/app permissions instead of scopes. At time of writing (Oct 2025), + // we don't solve this problem here. Instead we expect any service principal + // clients to be hardcoded to use the `app-id/.default` scope when requesting + // access tokens for our endpoint and for the owners of the client and MCP + // server's service principals to ensure the necessary app roles are assigned + // upfront. + ScopesSupported = [$"{clientId}/Mcp.Tools.ReadWrite"], + BearerMethodsSupported = ["header"], + + // Intentionally pointing to MCP repo for documentation. Could eventually + // have a dedicated usage doc page, potentially provided by this service itself. + ResourceDocumentation = "https://github.com/Microsoft/mcp" + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync( + context.Response.Body, + metadata, + OAuthMetadataJsonContext.Default.OAuthProtectedResourceMetadata); + return; + } + + await next(context); + }); + + // AuthN/Z are always required in the remote HTTP service scenario. + app.UseAuthentication(); + app.UseAuthorization(); + + IEndpointConventionBuilder mcpEndpointBuilder = app.MapMcp(); + // All MCP endpoints require MCP.All scope or role + mcpEndpointBuilder.RequireAuthorization("McpAccess"); + + // Map non-MCP endpoints. + // Health checks are anonymous (no authentication required) + app.MapHealthChecks("/health") + .AllowAnonymous(); + + return app; + } + + /// + /// Creates a host for HTTP transport without incoming authentication. + /// + /// The server configuration options. + /// An IHost instance configured for HTTP transport. + private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptions) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + + InitializeListingUrls(builder, serverOptions); + + // Configure logging + builder.Logging.ClearProviders(); + builder.Logging.ConfigureOpenTelemetryLogger(); + builder.Logging.AddEventSourceLogger(); + builder.Logging.AddConsole(); + + IServiceCollection services = builder.Services; + + // Configure single identity token credential provider for outgoing authentication + services.AddSingleIdentityTokenCredentialProvider(); + + // Configure CORS + // We're allowing all origins, methods, and headers to support any web + // browser clients. + // Non-browser clients are unaffected by CORS. + services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // Configure services + ConfigureServices(services); // Our static callback hook + ConfigureMcpServer(services, serverOptions); + + // We still use the multi-user, HTTP context-aware caching strategy here + // because we don't yet know what security model we want for this "insecure" mode. + // As a positive, it gives some isolation locally, but that's not a + // design strategy we've fully vetted or endorsed. + services.AddHttpServiceCacheService(); + + WebApplication app = builder.Build(); + + UseHttpsRedirectionIfEnabled(app); + + // Configure middleware pipeline + app.UseCors("AllowAll"); + app.UseRouting(); + app.MapMcp(); + + return app; + } + + /// + /// Configures the MCP server services. + /// + /// The service collection to configure. + /// The server configuration options. + private static void ConfigureMcpServer(IServiceCollection services, ServiceStartOptions options) + { + services.AddAzureMcpServer(options); + } + + /// + /// Initializes the URL for ASP.NET Core to bind to. + /// + private static void InitializeListingUrls(WebApplicationBuilder builder, ServiceStartOptions options) + { + if (!options.DangerouslyDisableHttpIncomingAuth) + { + // When running in secured HTTP mode, allow the standard IConfiguration binding to handle + // the ASPNETCORE_URLS value without any additional validation. + return; + } + + string url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://127.0.0.1:5001"; + + if (url.Contains(';')) + { + throw new InvalidOperationException("Multiple endpoints in ASPNETCORE_URLS are not supported. Provide a single URL."); + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException($"Invalid URL: '{url}'"); + } + + var scheme = uri.Scheme.ToLowerInvariant(); + if (scheme is not ("http" or "https")) + { + throw new InvalidOperationException($"Unsupported scheme '{scheme}' in URL '{url}'."); + } + + // loopback IP: 127.0.0.0/8 range, IPv6 (::1) and localhost when resolved to loopback addresses. + bool isLoopback = uri.IsLoopback; + + // All interfaces, allowed only with ALLOW_INSECURE_EXTERNAL_BINDING opt-in. + bool isWildcard = uri.Host is "*" or "+" or "0.0.0.0" or "::" || (IPAddress.TryParse(uri.Host, out var anyIp) && (anyIp.Equals(IPAddress.Any) || anyIp.Equals(IPAddress.IPv6Any))); + + if (!isLoopback && !isWildcard) + { + throw new InvalidOperationException($"Explicit external binding is not supported for '{url}'."); + } + + if (isWildcard && !EnvironmentHelpers.GetEnvironmentVariableAsBool("ALLOW_INSECURE_EXTERNAL_BINDING")) + { + throw new InvalidOperationException( + $"External binding blocked for '{url}'. " + + $"Set ALLOW_INSECURE_EXTERNAL_BINDING=true if you intentionally want to bind beyond loopback."); + } + + builder.WebHost.UseUrls(url); + } + + /// + /// Resolves the service mode and outgoing authentication strategy based on parsed command line options, applying appropriate defaults. + /// + /// The parsed command line arguments. + /// A tuple containing whether to run as remote HTTP service and the outgoing auth strategy. + private static OutgoingAuthStrategy ResolveAuthStrategy(ParseResult parseResult) + { +#if ENABLE_HTTP + var outgoingAuthStrategy = parseResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name); + if (outgoingAuthStrategy == OutgoingAuthStrategy.NotSet) + { + string transport = ResolveTransport(parseResult); + if (transport == TransportTypes.Http) + { + bool httpIncomingAuthDisabled = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name); + return httpIncomingAuthDisabled + ? OutgoingAuthStrategy.UseHostingEnvironmentIdentity + : OutgoingAuthStrategy.UseOnBehalfOf; + } + else + { + return OutgoingAuthStrategy.UseHostingEnvironmentIdentity; + } + } + return outgoingAuthStrategy; +#else + return OutgoingAuthStrategy.UseHostingEnvironmentIdentity; +#endif + } + + /// + /// Resolves the transport type from parsed command line arguments, defaulting to STDIO if not specified. + /// + /// The parsed command line arguments. + /// The transport type string (stdio or http). + private static string ResolveTransport(ParseResult parseResult) + { + return parseResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo; + } + + /// + /// Resolves the transport type from command result, defaulting to STDIO if not specified. + /// + /// The command result to extract transport from. + /// The transport type string (stdio or http). + private static string ResolveTransport(CommandResult commandResult) + { + return commandResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo; + } + + private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) + { + // Some hosting environments may not need HTTPS redirection, such as: + // - Running behind a reverse proxy that handles TLS termination. + // - Local development when not using self-signed development certs. + // - The application or server's HTTP stack is not listening for non-HTTPS requests. + // + // Safe default to enable HTTPS redirection unless explicitly opted-out. + string? httpsRedirectionOptOut = Environment.GetEnvironmentVariable("AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION"); + if (!bool.TryParse(httpsRedirectionOptOut, out bool isOptedOut) || !isOptedOut) + { + app.UseHttpsRedirection(); + } + + return app; + } + + /// + /// Configures incoming and outgoing HTTP spans for self-hosted HTTP mode with Azure Monitor exporter. + /// + /// The server configuration options. + /// + /// A instance if telemetry is enabled and properly configured for HTTP transport; + /// otherwise, null. + /// + /// + /// Telemetry is only configured when: + /// + /// The transport is HTTP (not STDIO) + /// AZURE_MCP_COLLECT_TELEMETRY is not explicitly set to false + /// APPLICATIONINSIGHTS_CONNECTION_STRING environment variable is set + /// + /// The tracer provider includes ASP.NET Core and HttpClient instrumentation with filtering + /// to avoid duplicate spans and telemetry loops. + /// This telemetry configuration is intended for self-hosted scenarios where + /// the MCP server is running in HTTP mode. This creates an independent telemetry pipeline using TracerProvider to export + /// traces to user-configured Application Insights instance only when the necessary environment variables are set. This also honors + /// the AZURE_MCP_COLLECT_TELEMETRY environment variable to allow users to disable telemetry collection if desired. Note that this is + /// in addition to the telemetry configured in . + /// + private static TracerProvider? AddIncomingAndOutgoingHttpSpans(ServiceStartOptions options) + { + if (options.Transport != TransportTypes.Http) + { + return null; + } + + string? collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY"); + bool isTelemetryEnabled = string.IsNullOrWhiteSpace(collectTelemetry) || + (bool.TryParse(collectTelemetry, out bool shouldCollectTelemetry) && shouldCollectTelemetry); + + string? connectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + if (!isTelemetryEnabled || string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + return Sdk.CreateTracerProviderBuilder() + // captures incoming HTTP requests + .AddAspNetCoreInstrumentation() + // captures outgoing HTTP requests with filtering + .AddHttpClientInstrumentation(o => o.FilterHttpRequestMessage = ShouldInstrumentHttpRequest) + .AddAzureMonitorTraceExporter(exporterOptions => exporterOptions.ConnectionString = connectionString) + .Build(); + } + + /// + /// Determines whether an HTTP request should be instrumented for telemetry collection. + /// + /// The HTTP request message to evaluate. + /// + /// true if the request should be instrumented; otherwise, false. + /// + /// + /// This method filters out specific requests to prevent telemetry issues: + /// + /// Application Insights ingestion endpoints (to avoid telemetry loops) + /// Requests where the parent span is from Azure SDK (to avoid duplicate spans) + /// + /// + private static bool ShouldInstrumentHttpRequest(HttpRequestMessage request) + { + // Exclude Application Insights ingestion requests to skip requests that are made to AppInsights when sending telemetry. + // See related issue - https://github.com/Azure/azure-sdk-for-net/issues/45366#issuecomment-2278511391 + if (request.RequestUri?.AbsoluteUri.Contains("applicationinsights.azure.com/v2.1/track", StringComparison.Ordinal) == true) + { + return false; + } + + // **NOTE**: This check is copied from the UseAzureMonitor extension method in the Azure SDK repository: + // https://github.com/Azure/azure-sdk-for-net/blob/242ba3eca16d914522669ae62baac7437bf71db8/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs#L98-L108 + // The decision to filter these out is not finalized for the product. We may revisit this in the future depending on + // how users want to see telemetry from Azure SDK calls made by the MCP server. + + // Azure SDKs create their own client span before calling the service using HttpClient. + // To prevent duplicate spans (Azure SDK + HttpClient), filter HttpClient spans when + // the parent span is from Azure SDK, as it contains all relevant information. + Activity? parentActivity = Activity.Current?.Parent; + if (parentActivity?.Source.Name == "Azure.Core.Http") + { + return false; + } + return true; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs new file mode 100644 index 0000000000..a929df93d8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Models.Elicitation; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// Base class for tool loaders that provides common functionality including disposal patterns. +/// +/// Logger instance for this tool loader. +public abstract class BaseToolLoader(ILogger logger) : IToolLoader +{ + /// + /// Logger instance for this tool loader. + /// + protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + /// Cached empty JSON object to avoid repeated parsing. + /// + protected static readonly JsonElement EmptyJsonObject; + + static BaseToolLoader() + { + using (var jsonDoc = JsonDocument.Parse("{}")) + { + EmptyJsonObject = jsonDoc.RootElement.Clone(); + } + } + + private bool _disposed = false; + + /// + /// Handles requests to list all tools available in the MCP server. + /// + /// The request context containing metadata and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the list of available tools. + public abstract ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken); + + /// + /// Handles requests to call a specific tool with the provided parameters. + /// + /// The request context containing the tool name and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the output of the tool invocation. + public abstract ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken); + + /// + /// Extracts the "parameters" JsonElement from the tool call request arguments. + /// + /// The request context containing the tool call parameters. + /// + /// The "parameters" JsonElement if it exists and is a valid JSON object; + /// otherwise, returns an empty JSON object. + /// + protected static JsonElement GetParametersJsonElement(RequestContext request) + { + IReadOnlyDictionary? args = request.Params?.Arguments; + if (args != null && args.TryGetValue("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) + { + return parametersElem; + } + + return EmptyJsonObject; + } + + /// + /// Extracts the "parameters" object from the tool call request arguments and converts it to a dictionary. + /// + /// The request context containing the tool call parameters. + /// + /// A dictionary containing the parameter names and values if the "parameters" object exists and is valid; + /// otherwise, returns an empty dictionary. + /// + protected static Dictionary GetParametersDictionary(RequestContext request) + { + JsonElement parametersElem = GetParametersJsonElement(request); + return parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value); + } + + /// + /// Disposes resources owned by this tool loader with double disposal protection. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + try + { + await DisposeAsyncCore(); + } + catch (Exception ex) + { + // Log disposal failures but don't throw - we want to ensure cleanup continues + // Individual disposal errors shouldn't stop the overall disposal process + _logger.LogError(ex, "Error occurred while disposing tool loader {LoaderType}. Some resources may not have been properly disposed.", GetType().Name); + } + finally + { + _disposed = true; + } + } + + /// + /// Override this method in derived classes to implement disposal logic. + /// This method is called exactly once during disposal. + /// + /// A task representing the asynchronous disposal operation. + protected virtual ValueTask DisposeAsyncCore() + { + // Default implementation does nothing + return ValueTask.CompletedTask; + } + + protected McpClientOptions CreateClientOptions(McpServer server) + { + McpClientHandlers handlers = new(); + + if (server.ClientCapabilities?.Sampling != null) + { + handlers.SamplingHandler = (request, progress, token) => + { + ArgumentNullException.ThrowIfNull(request); + return server.SampleAsync(request, token); + }; + } + + if (server.ClientCapabilities?.Elicitation != null) + { + handlers.ElicitationHandler = (request, token) => + { + ArgumentNullException.ThrowIfNull(request); + return server.ElicitAsync(request, token); + }; + } + + var clientOptions = new McpClientOptions + { + ClientInfo = server.ClientInfo, + Handlers = handlers + }; + + return clientOptions; + } + + /// + /// Handles elicitation for commands that access sensitive data. + /// If elicitation is disabled or not supported, returns appropriate error result. + /// + /// The request context containing the MCP server. + /// The name of the tool being invoked. + /// Whether elicitation has been disabled via insecure option. + /// Logger instance for recording elicitation events. + /// Cancellation token for the operation. + /// + /// Null if elicitation was accepted or bypassed (operation should proceed). + /// A CallToolResult with IsError=true if elicitation was rejected or failed (operation should not proceed). + /// + protected static async Task HandleSecretElicitationAsync( + RequestContext request, + string toolName, + bool insecureDisableElicitation, + ILogger logger, + CancellationToken cancellationToken) + { + // Check if elicitation is disabled by insecure option + if (insecureDisableElicitation) + { + logger.LogWarning("Tool '{Tool}' handles sensitive data but elicitation is disabled via --insecure-disable-elicitation. Proceeding without user consent (INSECURE).", toolName); + return null; + } + + // If client doesn't support elicitation, treat as rejected and don't execute + if (!request.Server.SupportsElicitation()) + { + logger.LogWarning("Tool '{Tool}' handles sensitive data but client does not support elicitation. Operation rejected.", toolName); + return new CallToolResult + { + Content = [new TextContentBlock { Text = "This tool handles sensitive data and requires user consent, but the client does not support elicitation. Operation rejected for security." }], + IsError = true + }; + } + + try + { + logger.LogInformation("Tool '{Tool}' handles sensitive data. Requesting user confirmation via elicitation.", toolName); + + // Create the elicitation request using our custom model + var elicitationRequest = new ElicitationRequestParams + { + Message = $"⚠️ SECURITY WARNING: The tool '{toolName}' may expose secrets or sensitive information.\n\nThis operation could reveal confidential data such as passwords, API keys, certificates, or other sensitive values.\n\nDo you want to continue with this potentially sensitive operation?" + }; + + // Use our extension method to handle the elicitation + var elicitationResponse = await request.Server.RequestElicitationAsync(elicitationRequest, cancellationToken); + + if (elicitationResponse.Action != ElicitationAction.Accept) + { + logger.LogInformation("User {Action} the elicitation for tool '{Tool}'. Operation not executed.", + elicitationResponse.Action.ToString().ToLower(), toolName); + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Operation cancelled by user ({elicitationResponse.Action.ToString().ToLower()})." }], + IsError = true + }; + } + + logger.LogInformation("User accepted elicitation for tool '{Tool}'. Proceeding with execution.", toolName); + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Error during elicitation for tool '{Tool}': {Error}", toolName, ex.Message); + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Elicitation failed for sensitive tool '{toolName}': {ex.Message}. Operation not executed for security." }], + IsError = true + }; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs new file mode 100644 index 0000000000..ff5b503f6d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Net; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Elicitation; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// A tool loader that creates MCP tools from the registered command factory. +/// Exposes AzureMcp commands as MCP tools that can be invoked through the MCP protocol. +/// +public sealed class CommandFactoryToolLoader( + IServiceProvider serviceProvider, + CommandFactory commandFactory, + IOptions options, + ILogger logger) : BaseToolLoader(logger) +{ + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly CommandFactory _commandFactory = commandFactory; + private readonly IOptions _options = options; + private IReadOnlyDictionary _toolCommands = + (options.Value.Namespace == null || options.Value.Namespace.Length == 0) + ? commandFactory.AllCommands + : commandFactory.GroupCommands(options.Value.Namespace); + + public const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; + + private static bool IsRawMcpToolInputOption(Option option) + { + if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + foreach (var alias in option.Aliases) + { + if (string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Lists all tools available from the command factory. + /// + /// The request context containing parameters and metadata. + /// A cancellation token. + /// A result containing the list of available tools. + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + var visibleCommands = CommandFactory.GetVisibleCommands(_toolCommands); + + // Filter by specific tools if provided + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + visibleCommands = visibleCommands.Where(kvp => + { + var toolKey = kvp.Key; + return _options.Value.Tool.Any(tool => tool.Contains(toolKey, StringComparison.OrdinalIgnoreCase)); + }); + } + + var tools = visibleCommands + .Select(kvp => GetTool(kvp.Key, kvp.Value)) + .Where(tool => !_options.Value.ReadOnly || (tool.Annotations?.ReadOnlyHint == true)) + .ToList(); + + var listToolsResult = new ListToolsResult { Tools = tools }; + + _logger.LogInformation("Listing {NumberOfTools} tools.", tools.Count); + + return ValueTask.FromResult(listToolsResult); + } + + /// + /// Handles tool calls by executing the corresponding command from the command factory. + /// + /// The request context containing parameters and metadata. + /// A cancellation token. + /// The result of the tool call operation. + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + var toolName = request.Params.Name; + + // Check if tool filtering is enabled and validate the requested tool + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + if (!_options.Value.Tool.Any(tool => tool.Contains(toolName, StringComparison.OrdinalIgnoreCase))) + { + var content = new TextContentBlock + { + Text = $"Tool '{toolName}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}", + }; + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + } + + var activity = Activity.Current; + + if (activity != null) + { + activity.SetTag(TagName.ToolName, toolName); + } + + var command = _toolCommands.GetValueOrDefault(toolName); + if (command == null) + { + var content = new TextContentBlock + { + Text = $"Could not find command: {toolName}", + }; + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + activity?.SetTag(TagName.ToolId, command.Id); + var commandContext = new CommandContext(_serviceProvider, activity); + + // Check if this tool requires elicitation for sensitive data + var metadata = command.Metadata; + if (metadata.Secret) + { + var elicitationResult = await HandleSecretElicitationAsync( + request, + toolName, + _options.Value.InsecureDisableElicitation, + _logger, + cancellationToken); + + if (elicitationResult != null) + { + return elicitationResult; + } + } + + var realCommand = command.GetCommand(); + ParseResult? commandOptions = null; + + if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0])) + { + commandOptions = realCommand.ParseFromRawMcpToolInput(request.Params.Arguments); + } + else + { + commandOptions = realCommand.ParseFromDictionary(request.Params.Arguments); + } + + _logger.LogTrace("Invoking '{Tool}'.", realCommand.Name); + + if (commandContext.Activity != null) + { + var serviceArea = _commandFactory.GetServiceArea(toolName); + commandContext.Activity.SetTag(TagName.ToolArea, serviceArea); + } + + try + { + var commandResponse = await command.ExecuteAsync(commandContext, commandOptions, cancellationToken); + var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); + var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; + + return new CallToolResult + { + Content = [ + new TextContentBlock { + Text = jsonResponse + } + ], + IsError = isError + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred running '{Tool}'. ", realCommand.Name); + + throw; + } + finally + { + _logger.LogTrace("Finished executing '{Tool}'.", realCommand.Name); + } + } + + /// + /// Converts a command to an MCP tool definition. + /// + /// The full name of the command. + /// The command to convert. + /// An MCP tool definition. + private static Tool GetTool(string fullName, IBaseCommand command) + { + var underlyingCommand = command.GetCommand(); + var tool = new Tool + { + Name = fullName, + Description = underlyingCommand.Description, + }; + + // Get tool metadata from the command's Metadata property + var metadata = command.Metadata; + tool.Annotations = new ToolAnnotations() + { + DestructiveHint = metadata.Destructive, + IdempotentHint = metadata.Idempotent, + OpenWorldHint = metadata.OpenWorld, + ReadOnlyHint = metadata.ReadOnly, + Title = command.Title, + }; + + // Add Secret metadata to tool.Meta if the property exists + if (metadata.Secret) + { + tool.Meta = new JsonObject + { + ["SecretHint"] = metadata.Secret + }; + } + + var options = command.GetCommand().Options; + + var schema = new ToolInputSchema(); + + if (options != null && options.Count > 0) + { + if (options.Count == 1 && IsRawMcpToolInputOption(options[0])) + { + var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); + tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject); + return tool; + } + else + { + foreach (var option in options) + { + // Use the CreatePropertySchema method to properly handle array types with items + var propName = NameNormalization.NormalizeOptionName(option.Name); + schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description)); + } + + schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))]; + } + } + + tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema); + + return tool; + } + + /// + /// Disposes resources owned by this tool loader. + /// CommandFactoryToolLoader doesn't own external resources that need disposal. + /// + protected override ValueTask DisposeAsyncCore() + { + // CommandFactoryToolLoader doesn't create or manage disposable resources + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs new file mode 100644 index 0000000000..9659295a12 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// A tool loader that combines multiple tool loaders into one. +/// This allows aggregating tools from multiple sources and delegating tool calls to the appropriate loader. +/// +/// The collection of tool loaders to combine. +/// Logger for tool loading operations. +public sealed class CompositeToolLoader(IEnumerable toolLoaders, ILogger logger) : BaseToolLoader(logger) +{ + private readonly IEnumerable _toolLoaders = InitializeToolLoaders(toolLoaders); + private readonly Dictionary _toolLoaderMap = new(); + private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); + private bool _isInitialized = false; + private List? _cachedTools; + + /// + /// Initializes the list of tool loaders, validating that at least one is provided. + /// + /// The collection of tool loaders to initialize. + /// A list of initialized tool loaders. + /// Thrown when the toolLoaders parameter is null. + /// Thrown when no tool loaders are provided. + private static IEnumerable InitializeToolLoaders(IEnumerable toolLoaders) + { + ArgumentNullException.ThrowIfNull(toolLoaders); + + var loadersList = toolLoaders.ToList(); + + if (loadersList.Count == 0) + { + throw new ArgumentException("At least one tool loader must be provided.", nameof(toolLoaders)); + } + + return loadersList; + } + + /// + /// Lists all tools from all tool loaders and builds a mapping of tool names to their respective loaders. + /// + /// The request context containing metadata and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the combined list of all available tools, or an empty list if initialization fails. + public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + try + { + await InitializeAsync(request.Server, cancellationToken); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Tool loader initialization failed"); + + // Return empty result with error indication + return new ListToolsResult + { + Tools = [] + }; + } + + // Return cached result after initialization + return new ListToolsResult + { + Tools = _cachedTools! + }; + } + + /// + /// Calls a tool by its name using the appropriate tool loader. + /// + /// The request context containing the tool name and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the output of the tool invocation, or an error result if the tool is not found or initialization fails. + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + // Ensure tool loader map is populated before attempting tool lookup + try + { + await InitializeAsync(request.Server, cancellationToken); + } + catch (InvalidOperationException ex) + { + var content = new TextContentBlock + { + Text = $"Failed to initialize tool loaders: {ex.Message}", + }; + + _logger.LogError(ex, "Tool loader initialization failed"); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + if (!_toolLoaderMap.TryGetValue(request.Params.Name, out var toolCaller)) + { + var content = new TextContentBlock + { + Text = $"The tool {request.Params.Name} was not found", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + return await toolCaller.CallToolHandler(request, cancellationToken); + } + + /// + /// Initializes the tool loader map by discovering all tools from child loaders. + /// This provides thread-safe initialization using the double-checked locking pattern. + /// + /// The server context for creating list tools requests. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. + private async Task InitializeAsync(McpServer server, CancellationToken cancellationToken) + { + if (_isInitialized) + { + return; + } + + await _initializationSemaphore.WaitAsync(cancellationToken); + try + { + // Double-check pattern: verify we're still not initialized after acquiring the lock + if (_isInitialized) + { + return; + } + + // Populate the tool loader map and cache the combined tools + var allTools = new List(); + + // Create a request for listing tools to populate the tool loader map + var listToolsRequest = new RequestContext(server, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + + foreach (var loader in _toolLoaders) + { + var toolsResponse = await loader.ListToolsHandler(listToolsRequest, cancellationToken); + if (toolsResponse == null) + { + throw new InvalidOperationException("Tool loader returned null response during initialization."); + } + + foreach (var tool in toolsResponse.Tools) + { + _toolLoaderMap[tool.Name] = loader; + allTools.Add(tool); + } + } + + _cachedTools = allTools; + _isInitialized = true; + } + finally + { + _initializationSemaphore.Release(); + } + } + + /// + /// Disposes all child tool loaders and the initialization semaphore. + /// Uses best-effort disposal to ensure all loaders are disposed even if some fail. + /// + protected override async ValueTask DisposeAsyncCore() + { + // Dispose initialization semaphore first + _initializationSemaphore?.Dispose(); + + // Dispose all child loaders using best-effort approach + var childDisposalTasks = _toolLoaders.Select(async loader => + { + try + { + await loader.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose tool loader {LoaderType}", loader.GetType().Name); + } + }); + + await Task.WhenAll(childDisposalTasks); + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs new file mode 100644 index 0000000000..b7bff4db6e --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// Defines the interface for tool loaders in the MCP server. +/// Tool loaders are responsible for discovering, listing, and invoking tools. +/// +public interface IToolLoader : IAsyncDisposable +{ + /// + /// Handles requests to list all tools available in the MCP server. + /// + /// The request context containing metadata and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the list of available tools. + ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken); + + /// + /// Handles requests to call a specific tool with the provided parameters. If an error occurs while calling the + /// tool, loaders should return a where the contents are details of the exception. + /// + /// The request context containing the tool name and parameters. + /// A token to monitor for cancellation requests. + /// A result containing the output of the tool invocation. + ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs new file mode 100644 index 0000000000..44f206643b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -0,0 +1,717 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Net; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Elicitation; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process execution. +/// Provides the same functionality as but without spawning child azmcp processes. +/// Supports learn functionality for progressive discovery of commands within each namespace. +/// +public sealed class NamespaceToolLoader( + CommandFactory commandFactory, + IOptions options, + IServiceProvider serviceProvider, + ILogger logger, + bool applyFilter = true) : BaseToolLoader(logger) +{ + private readonly CommandFactory _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); + private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly bool _applyFilter = applyFilter; + + private readonly Lazy> _availableNamespaces = new(() => + { + IEnumerable allSubGroups = commandFactory.RootGroup.SubGroup; + + if (applyFilter) + { + allSubGroups = allSubGroups + .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => options.Value.Namespace == null || + options.Value.Namespace.Length == 0 || + options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)); + } + + return allSubGroups.Select(group => group.Name).ToList(); + }); + + private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase); + private ListToolsResult? _cachedListToolsResult; + + private const string ToolCallProxySchema = """ + { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "The name of the tool to call." + }, + "parameters": { + "type": "object", + "description": "A key/value pair of parameters names and values to pass to the tool call command." + } + }, + "additionalProperties": false + } + """; + + private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + if (_cachedListToolsResult != null) + { + return ValueTask.FromResult(_cachedListToolsResult); + } + + var namespaces = _availableNamespaces.Value; + var allToolsResponse = new ListToolsResult + { + Tools = new List() + }; + + foreach (var namespaceName in namespaces) + { + var group = _commandFactory.RootGroup.SubGroup + .First(g => string.Equals(g.Name, namespaceName, StringComparison.OrdinalIgnoreCase)); + + var tool = new Tool + { + Name = namespaceName, + Description = group.Description + """ + This tool is a hierarchical MCP command router. + Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. + To invoke a command, set "command" and wrap its args in "parameters". + Set "learn=true" to discover available sub commands. + """, + InputSchema = ToolSchema, + Annotations = new ToolAnnotations() + { + Title = group.Title ?? namespaceName, + DestructiveHint = group.ToolMetadata?.Destructive, + IdempotentHint = group.ToolMetadata?.Idempotent, + OpenWorldHint = group.ToolMetadata?.OpenWorld, + ReadOnlyHint = group.ToolMetadata?.ReadOnly, + }, + }; + + allToolsResponse.Tools.Add(tool); + } + + // Cache the result + _cachedListToolsResult = allToolsResponse; + return ValueTask.FromResult(allToolsResponse); + } + + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + string tool = request.Params.Name; + var args = request.Params?.Arguments; + string? intent = null; + string? command = null; + bool learn = false; + + // In namespace mode, the name of the tool is also its IAreaSetup name. + Activity.Current?.SetTag(TagName.ToolArea, tool); + + if (args != null) + { + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) + { + intent = intentElem.GetString(); + } + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } + } + + if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) + { + learn = true; + } + + try + { + var activity = Activity.Current; + + if (learn) + { + return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken); + } + else if (!string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command)) + { + // We no longer spawn new processes to handle child tool invocations. + // So, we have to update ToolName to represent the namespace's child tool name + // rather than what is exposed to the user. The following inputs would + // be routed here. In both examples, the end-user's MCP client sees that we expose + // a tool called "storage" and would invoke our "storage" tool. + // + // A) { + // "intent": "List storage blobs.", + // "command": "blob_list", + // "parameters": [ "--name", "foo", "--subscription-id", "bar" ] + // } + // + // This is the case where the LLM knows what tool should be executed, so it passes + // in all the parameters required to execute the underlying Storage tool. + // + // B) { + // "intent": "List storage blobs.", + // "command": "blob_list", + // "parameters": [] + // "learn": true + // } + // + // This command attempts to learn what the command "blob_list" entails by + // invoking it with no parameters and "learn" == "true". The command will + // generally fail, providing the LLM with extra information it needs to pass + // in for the command to succeed the next time. + activity?.SetTag(TagName.ToolName, command); + + var toolParams = GetParametersFromArgs(args); + return await InvokeChildToolAsync(request, intent ?? "", tool, command, toolParams, cancellationToken); + } + } + catch (KeyNotFoundException ex) + { + _logger.LogError(ex, "Key not found while calling tool: {Tool}", tool); + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The tool '{tool}.{command}' was not found or does not support the specified command. + Please ensure the tool name and command are correct. + If you want to learn about available tools, run again with the "learn=true" argument. + """ + } + ], + IsError = true + }; + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = """ + The "command" parameters are required when not learning + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. + """ + } + ], + IsError = false + }; + } + + private async Task InvokeChildToolAsync( + RequestContext request, + string? intent, + string namespaceName, + string command, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, true); + IReadOnlyDictionary namespaceCommands; + try + { + namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null || namespaceCommands.Count == 0) + { + _logger.LogError("Failed to get commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while getting commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + try + { + var availableTools = GetChildToolList(request, namespaceName); + + // When the specified command is not available, we try to learn about the tool's capabilities + // and infer the command and parameters from the users intent. + if (!availableTools.Any(t => string.Equals(t.Name, command, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", namespaceName, command); + if (string.IsNullOrWhiteSpace(intent)) + { + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + var samplingResult = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); + if (string.IsNullOrWhiteSpace(samplingResult.commandName)) + { + return await InvokeToolLearn(request, intent ?? "", namespaceName, cancellationToken); + } + + command = samplingResult.commandName; + parameters = samplingResult.parameters; + } + + await NotifyProgressAsync(request, $"Calling {namespaceName} {command}...", cancellationToken); + + if (!namespaceCommands.TryGetValue(command, out var cmd)) + { + _logger.LogError("Command {Command} found in tools but missing from namespace {Namespace} commands.", command, namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + // Check if this tool requires elicitation for sensitive data + var metadata = cmd.Metadata; + if (metadata.Secret) + { + var elicitationResult = await HandleSecretElicitationAsync( + request, + $"{namespaceName} {command}", + _options.Value.InsecureDisableElicitation, + _logger, + cancellationToken); + + if (elicitationResult != null) + { + return elicitationResult; + } + } + + var currentActivity = Activity.Current; + var commandContext = new CommandContext(_serviceProvider, currentActivity); + var realCommand = cmd.GetCommand(); + + ParseResult commandOptions; + if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0])) + { + commandOptions = realCommand.ParseFromRawMcpToolInput(parameters); + } + else + { + commandOptions = realCommand.ParseFromDictionary(parameters); + } + + _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", namespaceName, command); + + // It is possible that the command provided by the LLM is not one that exists, such as "blob-list". + // The logic above performs sampling to try and get a correct command name. "blob_get" in + // this case, which will be executed. + currentActivity?.SetTag(TagName.ToolName, command).SetTag(TagName.ToolId, cmd.Id); + + var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions, cancellationToken); + var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); + var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; + + if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) + { + var childToolSpecJson = GetChildToolJson(request, namespaceName, command); + + _logger.LogWarning("Namespace {Namespace} command {Command} requires additional parameters.", namespaceName, command); + var finalResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The '{command}' command is missing required parameters. + + - Review the following command spec and identify the required arguments from the input schema. + - Omit any arguments that are not required or do not apply to your use case. + - Wrap all command arguments into the root "parameters" argument. + - If required data is missing infer the data from your context or prompt the user as needed. + - Run the tool again with the "command" and root "parameters" object. + + Command Spec: + {childToolSpecJson} + """ + } + ], + IsError = true + }; + + // Add original response content + finalResponse.Content.Add(new TextContentBlock { Text = jsonResponse }); + return finalResponse; + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = jsonResponse }], + IsError = isError + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while calling namespace: {Namespace}, command: {Command}", namespaceName, command); + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + There was an error finding or calling tool and command. + Failed to call namespace: {namespaceName}, command: {command} + Error: {ex.Message} + + Run again with the "learn=true" to get a list of available commands and their parameters. + """ + } + ] + }; + } + } + + private async Task InvokeToolLearn(RequestContext request, string? intent, string namespaceName, CancellationToken cancellationToken) + { + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = GetChildToolListJson(request, namespaceName); + + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available command and their parameters for '{namespaceName}' tool. + If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. + Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. + + {toolsJson} + """ + } + ], + IsError = false + }; + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var availableTools = GetChildToolList(request, namespaceName); + (string? commandName, IReadOnlyDictionary parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); + if (commandName != null) + { + response = await InvokeChildToolAsync(request, intent, namespaceName, commandName, parameters, cancellationToken); + } + } + return response; + } + + /// + /// Gets the available tools from the namespace commands and caches the result for subsequent requests. + /// + private List GetChildToolList(RequestContext request, string namespaceName) + { + // Check cache first + if (_cachedToolLists.TryGetValue(namespaceName, out var cachedList)) + { + return cachedList; + } + + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + var namespaces = _availableNamespaces.Value; + if (!namespaces.Any(ns => string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase))) + { + var availableList = string.Join(", ", namespaces); + throw new KeyNotFoundException($"The namespace '{namespaceName}' was not found. Available namespaces: {availableList}"); + } + + var namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null) + { + _logger.LogWarning("No commands found for namespace: {Namespace}", namespaceName); + return []; + } + + var list = namespaceCommands + .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) + .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) + .ToList(); + + // Cache for subsequent requests + _cachedToolLists[namespaceName] = list; + + return list; + } + + private string GetChildToolListJson(RequestContext request, string namespaceName) + { + var listTools = GetChildToolList(request, namespaceName); + return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); + } + + private string GetChildToolJson(RequestContext request, string namespaceName, string commandName) + { + var tools = GetChildToolList(request, namespaceName); + var tool = tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); + } + + /// + /// Creates a tool definition from a command (same logic as CommandFactoryToolLoader). + /// + private static Tool CreateToolFromCommand(string fullName, IBaseCommand command) + { + var underlyingCommand = command.GetCommand(); + var tool = new Tool + { + Name = fullName, + Description = underlyingCommand.Description, + }; + + var metadata = command.Metadata; + tool.Annotations = new ToolAnnotations() + { + DestructiveHint = metadata.Destructive, + IdempotentHint = metadata.Idempotent, + OpenWorldHint = metadata.OpenWorld, + ReadOnlyHint = metadata.ReadOnly, + Title = command.Title, + }; + + if (metadata.Secret) + { + tool.Meta = new JsonObject { ["SecretHint"] = metadata.Secret }; + } + + var schema = new ToolInputSchema(); + var options = command.GetCommand().Options; + + if (options?.Count > 0) + { + if (options.Count == 1 && IsRawMcpToolInputOption(options[0])) + { + var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); + tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject); + return tool; + } + else + { + foreach (var option in options) + { + var propName = NameNormalization.NormalizeOptionName(option.Name); + schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description)); + } + schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))]; + } + } + + tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema); + return tool; + } + + private static bool IsRawMcpToolInputOption(Option option) + { + const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; + if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return option.Aliases.Any(alias => + string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)); + } + + private static IReadOnlyDictionary GetParametersFromArgs(IReadOnlyDictionary? args) + { + if (args == null || !args.TryGetValue("parameters", out var paramsElem)) + { + return new Dictionary(); + } + + if (paramsElem.ValueKind == JsonValueKind.Object) + { + return paramsElem.EnumerateObject() + .ToDictionary(prop => prop.Name, prop => prop.Value); + } + + return new Dictionary(); + } + + private static bool SupportsSampling(McpServer server) + { + return server?.ClientCapabilities?.Sampling != null; + } + + private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken) + { + var progressToken = request.Params?.ProgressToken; + if (progressToken == null) + { + return; + } + + await request.Server.NotifyProgressAsync(progressToken.Value, + new ProgressNotificationValue + { + Progress = 0f, + Message = message, + }, cancellationToken); + } + + private async Task<(string? commandName, IReadOnlyDictionary parameters)> GetCommandAndParametersFromIntentAsync( + RequestContext request, + string intent, + string namespaceName, + List availableTools, + CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, $"Learning about {namespaceName} capabilities...", cancellationToken); + + JsonElement toolParams = GetParametersJsonElement(request); + var toolParamsJson = toolParams.GetRawText(); + var availableToolsJson = JsonSerializer.Serialize(availableTools, ServerJsonContext.Default.ListTool); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock{ + Text = $""" + This is a list of available commands for the {namespaceName} server. + + Your task: + - Select the single command that best matches the user's intent. + - Return a valid JSON object that matches the provided result schema. + - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters). + - Only include parameters that are defined in the selected command's input schema. + - Do not guess or invent parameters. + - If no command matches, return JSON schema with "Unknown" tool name. + + Result Schema: + {ToolCallProxySchema} + + Intent: + {intent ?? "No specific intent provided"} + + Known Parameters: + {toolParamsJson} + + Available Commands: + {availableToolsJson} + """ + } + } + ], + }; + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolCallJson = samplingContent?.Text?.Trim(); + string? commandName = null; + IReadOnlyDictionary parameters = new Dictionary(); + + if (!string.IsNullOrEmpty(toolCallJson)) + { + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; + if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) + { + commandName = toolProp.GetString(); + } + if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) + { + parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value.Clone()) ?? new Dictionary(); + } + } + + if (commandName != null && commandName != "Unknown") + { + return (commandName, parameters); + } + } + catch + { + _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, namespaceName); + } + + return (null, new Dictionary()); + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears the cached tool lists dictionary. + /// + protected override ValueTask DisposeAsyncCore() + { + _cachedToolLists.Clear(); + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs new file mode 100644 index 0000000000..41a220cdb9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// RegistryToolLoader is a tool loader that retrieves tools from a registry. +/// Tools are loaded from each MCP server and exposed through the MCP server. +/// It handles tool call proxying and provides a unified interface for tool operations. +/// +public sealed class RegistryToolLoader( + IMcpDiscoveryStrategy discoveryStrategy, + IOptions options, + ILogger logger) : BaseToolLoader(logger) +{ + private readonly IMcpDiscoveryStrategy _serverDiscoveryStrategy = discoveryStrategy; + private readonly IOptions _options = options; + private Dictionary _toolClientMap = new(); + private List _discoveredClients = new(); + private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); + private bool _isInitialized = false; + + /// + /// Gets or sets the client options used when creating MCP clients. + /// + public McpClientOptions ClientOptions { get; set; } = new McpClientOptions(); + + /// + /// Lists all tools available from registered MCP servers. + /// + /// The request context containing parameters and metadata. + /// A cancellation token. + /// A result containing the list of available tools. + public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + await InitializeAsync(cancellationToken); + + var allToolsResponse = new ListToolsResult + { + Tools = new List() + }; + + // Use cached discovered clients instead of re-discovering servers + foreach (var mcpClient in _discoveredClients) + { + var toolsResponse = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); + var filteredTools = toolsResponse + .Select(t => t.ProtocolTool) + .Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true)); + + // Filter by specific tools if provided + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + filteredTools = filteredTools.Where(t => _options.Value.Tool.Any(tool => tool.Contains(t.Name, StringComparison.OrdinalIgnoreCase))); + } + + foreach (var tool in filteredTools) + { + allToolsResponse.Tools.Add(tool); + } + } + + return allToolsResponse; + } + + /// + /// Handles tool calls by routing them to the appropriate MCP client. + /// + /// The request context containing parameters and metadata. + /// A cancellation token. + /// The result of the tool call operation. + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + // Initialize the tool client map if not already done + await InitializeAsync(cancellationToken); + + // Check if tool filtering is enabled and validate the requested tool + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + if (!_options.Value.Tool.Any(tool => tool.Contains(request.Params.Name, StringComparison.OrdinalIgnoreCase))) + { + var content = new TextContentBlock + { + Text = $"Tool '{request.Params.Name}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + } + + if (!_toolClientMap.TryGetValue(request.Params.Name, out var kvp) || kvp.Client == null) + { + var content = new TextContentBlock + { + Text = $"The tool {request.Params.Name} was not found in the tool registry.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + // For MCP servers loaded from registry.json, the ToolArea is also its "server name". + Activity.Current?.SetTag(TagName.ToolArea, kvp.ServerName); + + var parameters = TransformArgumentsToDictionary(request.Params.Arguments); + return await kvp.Client.CallToolAsync(request.Params.Name, parameters, cancellationToken: cancellationToken); + } + + /// + /// Transforms tool call arguments to a parameters dictionary. + /// This transformation is used because McpClientExtensions.CallToolAsync expects parameters as Dictionary<string, object?>. + /// + /// The arguments to transform to parameters. + /// A dictionary of parameter names and values compatible with McpClientExtensions.CallToolAsync. + private static Dictionary TransformArgumentsToDictionary(IReadOnlyDictionary? args) + { + if (args == null) + { + return []; + } + + return args.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value); + } + + /// + /// Initializes the tool client map by discovering servers and populating tools. + /// + /// A cancellation token. + /// A task representing the asynchronous operation. + private async Task InitializeAsync(CancellationToken cancellationToken) + { + if (_isInitialized) + { + return; + } + + await _initializationSemaphore.WaitAsync(cancellationToken); + try + { + // Double-check pattern: verify we're still not initialized after acquiring the lock + if (_isInitialized) + { + return; + } + + var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken); + + foreach (var server in serverList) + { + var serverMetadata = server.CreateMetadata(); + McpClient? mcpClient; + try + { + mcpClient = await _serverDiscoveryStrategy.GetOrCreateClientAsync(serverMetadata.Name, ClientOptions, cancellationToken); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("Failed to create client for provider {ProviderName}: {Error}", serverMetadata.Name, ex.Message); + continue; + } + catch (Exception ex) + { + _logger.LogWarning("Failed to start client for provider {ProviderName}: {Error}", serverMetadata.Name, ex.Message); + continue; + } + + if (mcpClient == null) + { + _logger.LogWarning("Failed to get MCP client for provider {ProviderName}.", serverMetadata.Name); + continue; + } + + // Add to discovered clients list for caching + _discoveredClients.Add(mcpClient); + + var toolsResponse = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); + var filteredTools = toolsResponse + .Select(t => t.ProtocolTool) + .Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true)); + + foreach (var tool in filteredTools) + { + _toolClientMap[tool.Name] = (serverMetadata.Name, mcpClient); + } + } + + _isInitialized = true; + } + finally + { + _initializationSemaphore.Release(); + } + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears collections and disposes the initialization semaphore. + /// Note: MCP clients are owned by the discovery strategy, not disposed here. + /// + protected override async ValueTask DisposeAsyncCore() + { + // Only dispose resources we own, not the MCP clients + _initializationSemaphore?.Dispose(); + + // Clear references to clients (but don't dispose them - discovery strategy owns them) + _discoveredClients.Clear(); + _toolClientMap.Clear(); + + await ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs new file mode 100644 index 0000000000..a6b22eea91 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs @@ -0,0 +1,533 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +public sealed class ServerToolLoader(IMcpDiscoveryStrategy serverDiscoveryStrategy, IOptions options, ILogger logger) : BaseToolLoader(logger) +{ + private readonly IMcpDiscoveryStrategy _serverDiscoveryStrategy = serverDiscoveryStrategy ?? throw new ArgumentNullException(nameof(serverDiscoveryStrategy)); + private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase); + + private const string ToolCallProxySchema = """ + { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "The name of the tool to call." + }, + "parameters": { + "type": "object", + "description": "A key/value pair of parameters names nad values to pass to the tool call command." + } + }, + "additionalProperties": false + } + """; + + private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + + public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken); + var allToolsResponse = new ListToolsResult + { + Tools = new List() + }; + + foreach (var server in serverList) + { + var metadata = server.CreateMetadata(); + var tool = new Tool + { + Name = metadata.Name, + Description = metadata.Description + """ + This tool is a hierarchical MCP command router. + Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. + To invoke a command, set "command" and wrap its args in "parameters". + Set "learn=true" to discover available sub commands. + """, + InputSchema = ToolSchema, + }; + + // Set annotations if we have Title or ToolMetadata + if (metadata.Title != null || metadata.ToolMetadata != null) + { + tool.Annotations = new ToolAnnotations + { + Title = metadata.Title, + DestructiveHint = metadata.ToolMetadata?.Destructive, + IdempotentHint = metadata.ToolMetadata?.Idempotent, + OpenWorldHint = metadata.ToolMetadata?.OpenWorld, + ReadOnlyHint = metadata.ToolMetadata?.ReadOnly, + }; + } + + allToolsResponse.Tools.Add(tool); + } + + return allToolsResponse; + } + + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + string tool = request.Params.Name; + var args = request.Params?.Arguments; + string? intent = null; + string? command = null; + bool learn = false; + + if (args != null) + { + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) + { + intent = intentElem.GetString(); + } + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } + } + + if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) + { + learn = true; + } + + // The ToolArea for the Servers loaded is the name of the server. + Activity.Current?.SetTag(TagName.ToolArea, tool); + + try + { + if (learn) + { + return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken); + } + else if (!string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command)) + { + // The "command" JSON property is what concrete BaseCommand is trying to be invoked. + // It is possible that the LLM provides a value for "command" that does not exist in + // CommandFactory. This incorrect value will be replaced if we are able to find + // a matching tool via sampling. + Activity.Current?.SetTag(TagName.ToolName, command); + + var toolParams = GetParametersDictionary(request); + return await InvokeChildToolAsync(request, intent ?? "", tool, command, toolParams, cancellationToken); + } + } + catch (KeyNotFoundException ex) + { + _logger.LogError(ex, "Key not found while calling tool: {Tool}", tool); + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The tool '{tool}.{command}' was not found or does not support the specified command. + Please ensure the tool name and command are correct. + If you want to learn about available tools, run again with the "learn=true" argument. + """ + } + ], + IsError = true + }; + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = """ + The "command" parameters are required when not learning + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. + """ + } + ] + }; + } + + private async Task InvokeChildToolAsync(RequestContext request, string? intent, string tool, string command, Dictionary parameters, CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + McpClient client; + try + { + var clientOptions = CreateClientOptions(request.Server); + client = await _serverDiscoveryStrategy.GetOrCreateClientAsync(tool, clientOptions, cancellationToken); + if (client == null) + { + _logger.LogError("Failed to get provider client for tool: {Tool}", tool); + return await InvokeToolLearn(request, intent, tool, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while getting provider client for tool: {Tool}", tool); + return await InvokeToolLearn(request, intent, tool, cancellationToken); + } + + try + { + var availableTools = await GetChildToolListAsync(request, tool, cancellationToken); + + // When the specified command is not available, we try to learn about the tool's capabilities + // and infer the command and parameters from the users intent. + if (!availableTools.Any(t => string.Equals(t.Name, command, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Tool {Tool} does not have a command {Command}.", tool, command); + if (string.IsNullOrWhiteSpace(intent)) + { + return await InvokeToolLearn(request, intent, tool, cancellationToken); + } + + var samplingResult = await GetCommandAndParametersFromIntentAsync(request, intent, tool, availableTools, cancellationToken); + if (string.IsNullOrWhiteSpace(samplingResult.commandName)) + { + return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken); + } + + command = samplingResult.commandName; + parameters = samplingResult.parameters; + } + + // At this point we should always have a valid command (child tool) call to invoke. + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, true) + .SetTag(TagName.ToolName, command); + + await NotifyProgressAsync(request, $"Calling {tool} {command}...", cancellationToken); + var toolCallResponse = await client.CallToolAsync(command, parameters, cancellationToken: cancellationToken); + if (toolCallResponse.IsError is true) + { + _logger.LogWarning("Tool {Tool} command {Command} returned an error.", tool, command); + } + + foreach (var content in toolCallResponse.Content) + { + var textContent = content as TextContentBlock; + if (textContent == null || string.IsNullOrWhiteSpace(textContent.Text)) + { + continue; + } + + if (textContent.Text.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) + { + var childToolSpecJson = await GetChildToolJsonAsync(request, tool, command, cancellationToken); + + _logger.LogWarning("Tool {Tool} command {Command} requires additional parameters.", tool, command); + var finalResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The '{command}' command is missing required parameters. + + - Review the following command spec and identify the required arguments from the input schema. + - Omit any arguments that are not required or do not apply to your use case. + - Wrap all command arguments into the root "parameters" argument. + - If required data is missing infer the data from your context or prompt the user as needed. + - Run the tool again with the "command" and root "parameters" object. + + Command Spec: + {childToolSpecJson} + """ + } + ] + }; + + foreach (var contentBlock in toolCallResponse.Content) + { + finalResponse.Content.Add(contentBlock); + } + + return finalResponse; + } + } + + return toolCallResponse; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while calling tool: {Tool}, command: {Command}", tool, command); + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + There was an error finding or calling tool and command. + Failed to call tool: {tool}, command: {command} + Error: {ex.Message} + + Run again with the "learn=true" to get a list of available commands and their parameters. + """ + } + ] + }; + } + } + + private async Task InvokeToolLearn(RequestContext request, string? intent, string tool, CancellationToken cancellationToken) + { + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = await GetChildToolListJsonAsync(request, tool, cancellationToken); + + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available command and their parameters for '{tool}' tool. + If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. + Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. + + {toolsJson} + """ + } + ] + }; + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var availableTools = await GetChildToolListAsync(request, tool, cancellationToken); + (string? commandName, Dictionary parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, tool, availableTools, cancellationToken); + if (commandName != null) + { + response = await InvokeChildToolAsync(request, intent, tool, commandName, parameters, cancellationToken); + } + } + return response; + } + + /// + /// Gets the available tools from the child MCP server and caches the result as JSON. + /// + /// + /// + /// + private async Task> GetChildToolListAsync(RequestContext request, string tool, CancellationToken cancellationToken) + { + if (_cachedToolLists.TryGetValue(tool, out var cachedList)) + { + return cachedList; + } + + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + var clientOptions = CreateClientOptions(request.Server); + var client = await _serverDiscoveryStrategy.GetOrCreateClientAsync(request.Params.Name, clientOptions, cancellationToken); + if (client == null) + { + return []; + } + + var listTools = await client.ListToolsAsync(cancellationToken: cancellationToken); + if (listTools == null) + { + _logger.LogWarning("No tools found for tool: {Tool}", tool); + return []; + } + + var list = listTools + .Select(t => t.ProtocolTool) + .Where(t => !(options?.Value?.ReadOnly ?? false) || (t.Annotations?.ReadOnlyHint == true)) + .ToList(); + + _cachedToolLists[tool] = list; + return list; + } + + private async Task GetChildToolListJsonAsync(RequestContext request, string tool, CancellationToken cancellationToken) + { + var listTools = await GetChildToolListAsync(request, tool, cancellationToken); + return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); + } + + private async Task GetChildToolAsync(RequestContext request, string toolName, string commandName, CancellationToken cancellationToken) + { + var tools = await GetChildToolListAsync(request, toolName, cancellationToken); + return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task GetChildToolJsonAsync(RequestContext request, string toolName, string commandName, CancellationToken cancellationToken) + { + var tool = await GetChildToolAsync(request, toolName, commandName, cancellationToken); + return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); + } + + private static bool SupportsSampling(McpServer server) + { + return server?.ClientCapabilities?.Sampling != null; + } + + private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken) + { + var progressToken = request.Params?.ProgressToken; + if (progressToken == null) + { + return; + } + + await request.Server.NotifyProgressAsync(progressToken.Value, + new ProgressNotificationValue + { + Progress = 0f, + Message = message, + }, cancellationToken); + } + private async Task<(string? commandName, Dictionary parameters)> GetCommandAndParametersFromIntentAsync( + RequestContext request, + string intent, + string tool, + List availableTools, + CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, $"Learning about {tool} capabilities...", cancellationToken); + + JsonElement toolParams = GetParametersJsonElement(request); + var toolParamsJson = toolParams.GetRawText(); + var availableToolsJson = JsonSerializer.Serialize(availableTools, ServerJsonContext.Default.ListTool); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock{ + Text = $""" + This is a list of available commands for the {tool} server. + + Your task: + - Select the single command that best matches the user's intent. + - Return a valid JSON object that matches the provided result schema. + - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters). + - Only include parameters that are defined in the selected command's input schema. + - Do not guess or invent parameters. + - If no command matches, return JSON schema with "Unknown" tool name. + + Result Schema: + {ToolCallProxySchema} + + Intent: + {intent ?? "No specific intent provided"} + + Known Parameters: + {toolParamsJson} + + Available Commands: + {availableToolsJson} + """ + } + } + ], + }; + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolCallJson = samplingContent?.Text?.Trim(); + string? commandName = null; + Dictionary parameters = []; + if (!string.IsNullOrEmpty(toolCallJson)) + { + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; + if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) + { + commandName = toolProp.GetString(); + } + if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) + { + parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value.Clone()) ?? []; + } + } + if (commandName != null && commandName != "Unknown") + { + return (commandName, parameters); + } + } + catch + { + _logger.LogError("Failed to get command and parameters from intent: {Intent} for tool: {Tool}", intent, tool); + } + + return (null, new Dictionary()); + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears the cached tool lists dictionary. + /// + protected override async ValueTask DisposeAsyncCore() + { + _cachedToolLists.Clear(); + await ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs new file mode 100644 index 0000000000..577aa5eaaa --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +public sealed class SingleProxyToolLoader(IMcpDiscoveryStrategy discoveryStrategy, ILogger logger) : BaseToolLoader(logger) +{ + private readonly IMcpDiscoveryStrategy _discoveryStrategy = discoveryStrategy ?? throw new ArgumentNullException(nameof(discoveryStrategy)); + private string? _cachedRootToolsJson; + private readonly Dictionary _cachedToolListsJson = new(StringComparer.OrdinalIgnoreCase); + + private const string ToolCallProxySchema = """ + { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "The name of the tool to call." + }, + "parameters": { + "type": "object", + "description": "A key/value pair of parameters names nad values to pass to the tool call command." + } + }, + "additionalProperties": false + } + """; + + private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "tool": { + "type": "string", + "description": "The azure tool to use to execute the operation." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + var toolsResult = new ListToolsResult + { + Tools = new List + { + new Tool + { + Name = "azure", + Description = """ + This server/tool provides real-time, programmatic access to all Azure products, services, and resources, + as well as all interactions with the Azure Developer CLI (azd). + Use this tool for any Azure control plane or data plane operation, including resource management and automation. + To discover available capabilities, call the tool with the "learn" parameter to get a list of top-level tools. + To explore further, set "learn" and specify a tool name to retrieve supported commands and their parameters. + To execute an action, set the "tool", "command", and convert the users intent into the "parameters" based on the discovered schema. + Always use this tool for any Azure or "azd" related operation requiring up-to-date, dynamic, and interactive capabilities. + Always include the "intent" parameter to specify the operation you want to perform. + """, + Annotations = new ToolAnnotations(), + InputSchema = ToolSchema, + } + }, + }; + + return ValueTask.FromResult(toolsResult); + } + + /// + /// Handles invocation of the Azure proxy tool, routing requests to the correct Azure tool or command. + /// + /// The request context containing parameters and metadata. + /// A cancellation token. + /// A representing the result of the operation. + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken = default) + { + var args = request.Params?.Arguments; + string? intent = null; + bool learn = false; + string? tool = null; + string? command = null; + + if (args != null) + { + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) + { + intent = intentElem.GetString(); + } + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + if (args.TryGetValue("tool", out var toolElem) && toolElem.ValueKind == JsonValueKind.String) + { + tool = toolElem.GetString(); + } + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } + } + + if (!string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(tool) && string.IsNullOrEmpty(command) && !learn) + { + learn = true; + } + + if (learn && string.IsNullOrEmpty(tool)) + { + return await RootLearnModeAsync(request, intent ?? "", cancellationToken); + } + else if (learn && !string.IsNullOrEmpty(tool)) + { + return await ToolLearnModeAsync(request, intent ?? "", tool!, cancellationToken); + } + else if (!learn && !string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command)) + { + var toolParams = GetParametersDictionary(request); + return await CommandModeAsync(request, intent ?? "", tool!, command!, toolParams, cancellationToken); + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = """ + The "tool" and "command" parameters are required when not learning + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. + """ + } + ] + }; + } + + /// + /// Gets all of the 's available in the server. + /// + /// A JSON serialized string with each area's name and a description of operations available in + /// that namespace. + private async Task GetRootToolsJsonAsync(CancellationToken cancellationToken) + { + if (_cachedRootToolsJson != null) + { + return _cachedRootToolsJson; + } + + var serverList = await _discoveryStrategy.DiscoverServersAsync(cancellationToken); + var tools = new List(serverList.Count()); + foreach (var server in serverList) + { + var serverMetadata = server.CreateMetadata(); + tools.Add(new Tool + { + Name = serverMetadata.Id, + Description = serverMetadata.Description, + }); + } + var toolsResult = new ListToolsResult { Tools = tools }; + var toolsJson = JsonSerializer.Serialize(toolsResult, ServerJsonContext.Default.ListToolsResult); + _cachedRootToolsJson = toolsJson; + + return toolsJson; + } + + /// + /// Gets the set of within an . + /// + /// Calling request + /// Name of the to get commands for. + /// JSON serialized string representing the list of commands available in the tool's area. + private async Task GetToolListJsonAsync(RequestContext request, string tool, CancellationToken cancellationToken) + { + if (_cachedToolListsJson.TryGetValue(tool, out var cachedJson)) + { + return cachedJson; + } + + var clientOptions = CreateClientOptions(request.Server); + var client = await _discoveryStrategy.GetOrCreateClientAsync(tool, clientOptions, cancellationToken); + var listTools = await client.ListToolsAsync(cancellationToken: cancellationToken); + var toolsJson = JsonSerializer.Serialize(listTools, ServerJsonContext.Default.IListMcpClientTool); + _cachedToolListsJson[tool] = toolsJson; + + return toolsJson; + } + + private async Task RootLearnModeAsync(RequestContext request, string intent, CancellationToken cancellationToken) + { + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = await GetRootToolsJsonAsync(cancellationToken); + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available list of tools. + Next, identify the tool you want to learn about and run again with the "learn" argument and the "tool" name to get a list of available commands and their parameters. + + {toolsJson} + """ + } + ] + }; + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var toolName = await GetToolNameFromIntentAsync(request, intent, toolsJson, cancellationToken); + if (toolName != null) + { + response = await ToolLearnModeAsync(request, intent, toolName, cancellationToken); + } + } + + return response; + } + + private async Task ToolLearnModeAsync(RequestContext request, string intent, string tool, CancellationToken cancellationToken) + { + var activity = Activity.Current? + .SetTag(TagName.IsServerCommandInvoked, false) + .SetTag(TagName.ToolArea, tool); + + var toolsJson = await GetToolListJsonAsync(request, tool, cancellationToken); + if (string.IsNullOrEmpty(toolsJson)) + { + return await RootLearnModeAsync(request, intent, cancellationToken); + } + + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available command and their parameters for '{tool}' tool. + If you do not find a suitable tool, run again with the "learn" argument and empty "tool" to get a list of available tools and their parameters. + Next, identify the command you want to execute and run again with the "tool", "command", and "parameters" arguments. + + {toolsJson} + """ + } + ] + }; + + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var (commandName, parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, tool, toolsJson, cancellationToken); + if (commandName != null) + { + response = await CommandModeAsync(request, intent, tool, commandName, parameters, cancellationToken); + } + } + return response; + } + + private async Task CommandModeAsync(RequestContext request, string intent, string tool, string command, Dictionary parameters, CancellationToken cancellationToken) + { + McpClient? client; + + try + { + var clientOptions = CreateClientOptions(request.Server); + client = await _discoveryStrategy.GetOrCreateClientAsync(tool, clientOptions, cancellationToken); + if (client == null) + { + _logger.LogError("Failed to get provider client for tool: {Tool}", tool); + return await RootLearnModeAsync(request, intent, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while getting provider client for tool: {Tool}", tool); + return await RootLearnModeAsync(request, intent, cancellationToken); + } + + var activity = Activity.Current; + if (activity != null) + { + activity.SetTag(TagName.IsServerCommandInvoked, true) + .SetTag(TagName.ToolArea, tool) + .SetTag(TagName.ToolName, command); + } + + try + { + await NotifyProgressAsync(request, $"Calling {tool} {command}...", cancellationToken); + return await client.CallToolAsync(command, parameters, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while calling tool: {Tool}, command: {Command}", tool, command); + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + There was an error finding or calling tool and command. + Failed to call tool: {tool}, command: {command} + Error: {ex.Message} + + Run again with the "learn" argument and the "tool" name to get a list of available tools and their parameters. + """ + } + ] + }; + } + } + + private static bool SupportsSampling(McpServer server) + { + return server?.ClientCapabilities?.Sampling != null; + } + + private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken) + { + var progressToken = request.Params?.ProgressToken; + if (progressToken == null) + { + return; + } + + await request.Server.NotifyProgressAsync(progressToken.Value, + new ProgressNotificationValue + { + Progress = 0f, + Message = message, + }, cancellationToken); + } + + private async Task GetToolNameFromIntentAsync(RequestContext request, string intent, string toolsJson, CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, "Learning about Azure capabilities...", cancellationToken); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock{ + Text = $""" + The following is a list of available tools for the Azure server. + + Your task: + - Select a single tool that best matches the user's intent and return the name of the tool. + - Only return tool names that are defined in the provided list. + - If no tool matches, return "Unknown". + + Intent: + {intent} + + Available Tools: + {toolsJson} + """ + } + } + ], + }; + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolName = samplingContent?.Text?.Trim(); + if (!string.IsNullOrEmpty(toolName) && toolName != "Unknown") + { + return toolName; + } + } + catch + { + _logger.LogError("Failed to get tool name from intent: {Intent}", intent); + } + + return null; + } + + private async Task<(string? commandName, Dictionary parameters)> GetCommandAndParametersFromIntentAsync( + RequestContext request, + string intent, + string tool, + string toolsJson, + CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, $"Learning about {tool} capabilities...", cancellationToken); + + JsonElement toolParams = GetParametersJsonElement(request); + var toolParamsJson = toolParams.GetRawText(); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock{ + Text = $""" + This is a list of available commands for the {tool} server. + + Your task: + - Select the single command that best matches the user's intent. + - Return a valid JSON object that matches the provided result schema. + - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters). + - Only include parameters that are defined in the selected command's input schema. + - Do not guess or invent parameters. + - If no command matches, return JSON schema with "Unknown" tool name. + + Result Schema: + {ToolCallProxySchema} + + Intent: + {intent} + + Known Parameters: + {toolParamsJson} + + Available Commands: + {toolsJson} + """ + } + } + ], + }; + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolCallJson = samplingContent?.Text?.Trim(); + string? commandName = null; + Dictionary parameters = []; + if (!string.IsNullOrEmpty(toolCallJson)) + { + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; + if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) + { + commandName = toolProp.GetString(); + } + if (root.TryGetProperty("parameters", out var paramsProp) && paramsProp.ValueKind == JsonValueKind.Object) + { + parameters = paramsProp.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value.Clone()); + } + } + if (commandName != null && commandName != "Unknown") + { + return (commandName, parameters); + } + } + catch + { + _logger.LogError("Failed to get command and parameters from intent: {Intent} for tool: {Tool}", intent, tool); + } + + return (null, new Dictionary()); + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears the cached tool lists and root tools dictionaries. + /// Note: MCP clients are owned by the discovery strategy, not disposed here. + /// + protected override async ValueTask DisposeAsyncCore() + { + // Clear caching collections + _cachedToolListsJson.Clear(); + _cachedRootToolsJson = null; + + await ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs new file mode 100644 index 0000000000..dfaeb17856 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// Configuration options for tool loaders. +/// This class is used to configure how tool loaders filter and expose tools. +/// +/// The namespaces to filter commands by. If null or empty, all commands will be included. +/// Whether the tool loader should operate in read-only mode. When true, only tools marked as read-only will be exposed. +/// Whether elicitation is disabled (insecure mode). When true, elicitation will always be treated as accepted. +/// The specific tool names to filter by. When specified, only these tools will be exposed. +public sealed record ToolLoaderOptions(string[]? Namespace = null, bool ReadOnly = false, bool InsecureDisableElicitation = false, string[]? Tool = null); diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/TypeToJsonTypeMapper.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/TypeToJsonTypeMapper.cs new file mode 100644 index 0000000000..e1ef5cd86b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/TypeToJsonTypeMapper.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Helpers; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +/// +/// Gets the JSON object type based on its C# type. +/// +public static class TypeToJsonTypeMapper +{ + private static readonly Dictionary s_typeToJsonMap = new() + { + // String types + { typeof(string), "string" }, + { typeof(char), "string" }, + { typeof(Guid), "string" }, + { typeof(DateTime), "string" }, + { typeof(DateTimeOffset), "string" }, + { typeof(TimeSpan), "string" }, + { typeof(Uri), "string" }, + + // Number types + { typeof(int), "integer" }, + { typeof(uint), "integer" }, + { typeof(long), "integer" }, + { typeof(ulong), "integer" }, + { typeof(short), "integer" }, + { typeof(ushort), "integer" }, + { typeof(byte), "integer" }, + { typeof(sbyte), "integer" }, + + { typeof(float), "number" }, + { typeof(double), "number" }, + { typeof(decimal), "number" }, + + // Boolean + { typeof(bool), "boolean" }, + + // Arrays and collections + { typeof(Array), "array" }, + + // Object + { typeof(object), "object" } + }; + + /// + /// Gets the JSON type name based on the given type. If is null, then "null" is returned. + /// + /// Type to get JSON type name from. + /// + public static string ToJsonType(this Type? type) + { + if (type == null) + { + return "null"; + } + + // Handle nullable types - treat them as the primitive they're based on + var effectiveType = Nullable.GetUnderlyingType(type) ?? type; + + if (s_typeToJsonMap.TryGetValue(effectiveType, out string? jsonType) && jsonType != null) + { + return jsonType; + } + + if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + { + return CollectionTypeHelper.IsDictionaryType(type) ? "object" : "array"; + } + + if (effectiveType.IsEnum) + { + return "integer"; + } + + return "object"; + } + + /// + /// Gets the element type of an array or collection type. + /// + /// The array or collection type to analyze. + /// The element type if the type is an array or collection, otherwise null. + public static Type? GetArrayOrCollectionElementType(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + // Handle arrays + if (type.IsArray) + { + return type.GetElementType(); + } + + // Handle generic collections like List, IEnumerable, etc. + // Note: Dictionary types are handled as "object" in ToJsonType() so they won't reach this method + if (type.IsGenericType && !type.IsGenericTypeDefinition) + { + var genericArgs = type.GetGenericArguments(); + + if (genericArgs.Length == 1) + { + return genericArgs[0]; + } + } + + // Handle non-generic IEnumerable or open generics + if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + { + // Default to object + return typeof(object); + } + + return null; + } + + /// + /// Creates a JSON schema object for a given option type. + /// + /// The type of the option to create schema for. + /// The description for the option. + /// A JsonObject representing the JSON schema for the option. + public static ToolPropertySchema CreatePropertySchema(Type optionType, string? description) + { + ArgumentNullException.ThrowIfNull(optionType); + + // Handle nullable types - get the underlying type for schema generation + var effectiveType = Nullable.GetUnderlyingType(optionType) ?? optionType; + var jsonType = effectiveType.ToJsonType(); + ToolPropertySchema? itemsSchema = null; + + // If the type is an array, we need to specify the items type recursively + if (jsonType == "array") + { + var elementType = GetArrayOrCollectionElementType(effectiveType); + + if (elementType != null) + { + // Recursively create schema for nested arrays + itemsSchema = CreateItemsSchema(elementType); + } + } + + return new ToolPropertySchema() + { + Type = jsonType, + Description = description ?? string.Empty, + Items = itemsSchema + }; + } + + /// + /// Creates a JSON schema object for items in an array or collection, handling nested arrays recursively. + /// + /// The type of the items in the array or collection. + /// A JsonObject representing the JSON schema for the array/collection items. + private static ToolPropertySchema CreateItemsSchema(Type itemType) + { + ArgumentNullException.ThrowIfNull(itemType); + + // Handle nullable types for array items + var effectiveType = Nullable.GetUnderlyingType(itemType) ?? itemType; + var jsonType = effectiveType.ToJsonType(); + ToolPropertySchema? itemsSchema = null; + + // If the item type is also an array, recursively define its items + if (jsonType == "array") + { + var nestedElementType = GetArrayOrCollectionElementType(effectiveType); + + if (nestedElementType != null) + { + itemsSchema = CreateItemsSchema(nestedElementType); + } + } + + return new ToolPropertySchema() + { + Type = jsonType, + Items = itemsSchema + }; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs new file mode 100644 index 0000000000..39105bf2a4 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Commands; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents a composite tool definition that groups multiple related Azure operations together. +/// Used to create consolidated tools from the azure_mcp_consolidated_tools JSON configuration. +/// +public sealed class ConsolidatedToolDefinition +{ + /// + /// Gets or sets the name of the composite tool. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Gets or sets the description of the composite tool's capabilities and purpose. + /// + [JsonPropertyName("description")] + public required string Description { get; init; } + + /// + /// Gets or sets the tool metadata containing capability information. + /// + [JsonPropertyName("toolMetadata")] + public required ToolMetadata ToolMetadata { get; init; } + + /// + /// Gets or sets the list of tool names that are mapped to this consolidated tool. + /// + [JsonPropertyName("mappedToolList")] + public required HashSet MappedToolList { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs new file mode 100644 index 0000000000..728b50ff60 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// OAuth 2.0 protected resource metadata response model. See https://datatracker.ietf.org/doc/rfc9728/. +/// +public sealed class OAuthProtectedResourceMetadata +{ + [JsonPropertyName("resource")] + public required string Resource { get; init; } + + [JsonPropertyName("authorization_servers")] + public required string[] AuthorizationServers { get; init; } + + [JsonPropertyName("scopes_supported")] + public required string[] ScopesSupported { get; init; } + + [JsonPropertyName("bearer_methods_supported")] + public required string[] BearerMethodsSupported { get; init; } + + [JsonPropertyName("resource_documentation")] + public required string ResourceDocumentation { get; init; } +} + +/// +/// JSON serializer context for AOT-safe serialization. +/// +[JsonSerializable(typeof(OAuthProtectedResourceMetadata))] +internal partial class OAuthMetadataJsonContext : JsonSerializerContext +{ +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs new file mode 100644 index 0000000000..9116486065 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents the root structure of the MCP server registry JSON file. +/// Contains a collection of server configurations keyed by server name. +/// +public sealed class RegistryRoot +{ + /// + /// Gets the dictionary of server configurations, keyed by server name. + /// + [JsonPropertyName("servers")] + public Dictionary? Servers { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs new file mode 100644 index 0000000000..c17519da55 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Contains configuration information for an MCP server defined in the registry. +/// Supports command-based (stdio) transport mechanism. +/// +public sealed class RegistryServerInfo +{ + /// + /// Gets or sets the name of the server, typically derived from the key in the registry. + /// This property is not serialized to/from JSON. + /// + [JsonIgnore] + public string? Name { get; set; } + + /// + /// Gets the URL endpoint (deprecated - no longer used). + /// + [JsonPropertyName("url")] + public string? Url { get; init; } + + /// + /// Gets a description of the server's purpose or capabilities. + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// + /// Gets the user-friendly title for the server. + /// + [JsonPropertyName("title")] + public string? Title { get; init; } + + /// + /// Gets the transport type, e.g., "stdio". + /// + [JsonPropertyName("type")] + public string? Type { get; init; } + + /// + /// Gets the command to execute for stdio-based transport. + /// + [JsonPropertyName("command")] + public string? Command { get; init; } + + /// + /// Gets the command-line arguments to pass to the command for stdio-based transport. + /// + [JsonPropertyName("args")] + public List? Args { get; init; } + + /// + /// Gets environment variables to set for the stdio process. + /// + [JsonPropertyName("env")] + public Dictionary? Env { get; init; } + + /// + /// Gets installation instructions for the server. + /// + [JsonPropertyName("installInstructions")] + public string? InstallInstructions { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolInputSchema.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolInputSchema.cs new file mode 100644 index 0000000000..edbaa21c1a --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolInputSchema.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents the JSON schema for a tool's input parameters. +/// +public sealed class ToolInputSchema +{ + /// + /// The type of the schema object (always "object" for tool schemas). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = "object"; + + /// + /// The properties defined for this schema. + /// + [JsonPropertyName("properties")] + public Dictionary Properties { get; init; } = new(); + + /// + /// The list of required property names. + /// + [JsonPropertyName("required")] + public string[]? Required { get; set; } = []; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolPropertySchema.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolPropertySchema.cs new file mode 100644 index 0000000000..e22abbf2f5 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/ToolPropertySchema.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents a property definition within a tool's input schema. +/// +public sealed class ToolPropertySchema +{ + /// + /// The JSON type of the property (e.g., "string", "integer", "boolean", "array", "object"). + /// The type mapping is handled by . + /// + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// A description of what this property represents. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The type of the items in the array. + /// The type mapping is handled by . + /// + [JsonPropertyName("items")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolPropertySchema? Items { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs new file mode 100644 index 0000000000..6fd9355a24 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Options; + +/// +/// Defines the supported proxy modes for the Azure MCP server. +/// +internal static class ModeTypes +{ + /// + /// Single tool proxy mode - exposes a single "azure" tool that handles internal routing across all Azure MCP tools. + /// + public const string SingleToolProxy = "single"; + + /// + /// Namespace tool proxy mode - collapses all tools within each namespace into a single tool + /// (e.g., all storage operations become one "storage" tool with internal routing). + /// + public const string NamespaceProxy = "namespace"; + + /// + /// All tools mode - exposes all Azure MCP tools individually (one tool per command). + /// + public const string All = "all"; + + /// + /// Consolidated tools mode - exposes consolidated tools that group related Azure operations together. + /// + public const string ConsolidatedProxy = "consolidated"; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs new file mode 100644 index 0000000000..528f7e23e6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Identity; + +namespace Azure.Mcp.Core.Areas.Server.Options; + +/// +/// The strategy to use for authenticating outgoing requests to downstream services. +/// +public enum OutgoingAuthStrategy +{ + /// + /// The value is not set and is in a default state. A safe default will + /// be chosen based on other settings. + /// + NotSet = 0, + + /// + /// Outgoing requests will use the hosting environment's identity resolving + /// in a similar way as . This is valid + /// for all hosting scenarios. This means all outgoing requests will use the + /// same identity regardless of the incoming authenticate request identity, + /// if any. + /// + UseHostingEnvironmentIdentity = 1, + + /// + /// Outgoing requests will be authenticated based on exchanging the incoming + /// request's access token for a new access token valid for the downstream + /// service. This is only valid for remote MCP server scenarios. + /// + UseOnBehalfOf = 2 +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs new file mode 100644 index 0000000000..4b554e79e9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Options; + +public static class ServiceOptionDefinitions +{ + public const string TransportName = "transport"; + public const string NamespaceName = "namespace"; + public const string ModeName = "mode"; + public const string ToolName = "tool"; + public const string ReadOnlyName = "read-only"; + public const string DebugName = "debug"; + public const string DangerouslyDisableHttpIncomingAuthName = "dangerously-disable-http-incoming-auth"; + public const string InsecureDisableElicitationName = "insecure-disable-elicitation"; + public const string OutgoingAuthStrategyName = "outgoing-auth-strategy"; + + public static readonly Option Transport = new($"--{TransportName}") + { + Description = "Transport mechanism to use for Azure MCP Server.", + DefaultValueFactory = _ => TransportTypes.StdIo, + Required = false + }; + + public static readonly Option Namespace = new( + $"--{NamespaceName}" + ) + { + Description = "The Azure service namespaces to expose on the MCP server (e.g., storage, keyvault, cosmos).", + Required = false, + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + DefaultValueFactory = _ => null + }; + + public static readonly Option Mode = new Option( + $"--{ModeName}" + ) + { + Description = "Mode for the MCP server. 'single' exposes one azure tool that routes to all services. 'namespace' (default) exposes one tool per service namespace. 'all' exposes all tools individually.", + Required = false, + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => (string?)ModeTypes.NamespaceProxy + }; + + public static readonly Option Tool = new Option( + $"--{ToolName}" + ) + { + Description = "Expose only specific tools by name (e.g., 'acr_registry_list'). Repeat this option to include multiple tools, e.g., --tool \"acr_registry_list\" --tool \"group_list\". It automatically switches to \"all\" mode when \"--tool\" is used. It can't be used together with \"--namespace\".", + Required = false, + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + DefaultValueFactory = _ => null + }; + + public static readonly Option ReadOnly = new( + $"--{ReadOnlyName}") + { + Description = "Whether the MCP server should be read-only. If true, no write operations will be allowed.", + DefaultValueFactory = _ => false + }; + + public static readonly Option Debug = new( + $"--{DebugName}") + { + Description = "Enable debug mode with verbose logging to stderr.", + DefaultValueFactory = _ => false + }; + + public static readonly Option DangerouslyDisableHttpIncomingAuth = new( + $"--{DangerouslyDisableHttpIncomingAuthName}") + { + Required = false, + Description = "Dangerously disables HTTP incoming authentication, exposing the server to unauthenticated access over HTTP. Use with extreme caution, this disables all transport security and may expose sensitive data to interception.", + DefaultValueFactory = _ => false + }; + + public static readonly Option InsecureDisableElicitation = new( + $"--{InsecureDisableElicitationName}") + { + Required = false, + Description = "Disable elicitation (user confirmation) before allowing high risk commands to run, such as returning Secrets (passwords) from KeyVault.", + DefaultValueFactory = _ => false + }; + + public static readonly Option OutgoingAuthStrategy = new( + $"--{OutgoingAuthStrategyName}") + { + Required = false, + Description = "Outgoing authentication strategy for Azure service requests. Valid values: NotSet, UseHostingEnvironmentIdentity, UseOnBehalfOf.", + DefaultValueFactory = _ => Options.OutgoingAuthStrategy.NotSet + }; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs new file mode 100644 index 0000000000..8023c60605 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Options; + +/// +/// Configuration options for starting the Azure MCP server service. +/// +public class ServiceStartOptions +{ + /// + /// Gets or sets the transport mechanism for the server. + /// Defaults to standard I/O (stdio). + /// + [JsonPropertyName("transport")] + public string Transport { get; set; } = TransportTypes.StdIo; + + /// + /// Gets or sets the service namespaces to expose through the server. + /// When null, all available namespaces are exposed. + /// + [JsonPropertyName("namespace")] + public string[]? Namespace { get; set; } = null; + + /// + /// Gets or sets the mode mode for the server. + /// Defaults to 'namespace' mode which exposes one tool per service namespace. + /// + [JsonPropertyName("mode")] + public string? Mode { get; set; } = ModeTypes.NamespaceProxy; + + /// + /// Gets or sets the specific tool names to expose. + /// When specified, only these tools will be available. + /// + [JsonPropertyName("tool")] + public string[]? Tool { get; set; } = null; + + /// + /// Gets or sets whether the server should operate in read-only mode. + /// When true, only tools marked as read-only will be available. + /// + [JsonPropertyName("readOnly")] + public bool? ReadOnly { get; set; } = null; + + /// + /// Gets or sets whether debug mode is enabled. + /// When true, verbose logging will be sent to stderr. + /// + [JsonPropertyName("debug")] + public bool Debug { get; set; } = false; + + /// + /// Gets or sets whether HTTP incoming authentication is disabled. + /// When true, the server accepts unauthenticated HTTP requests. + /// + [JsonPropertyName("dangerouslyDisableHttpIncomingAuth")] + public bool DangerouslyDisableHttpIncomingAuth { get; set; } = false; + + /// + /// Gets or sets whether elicitation (user confirmation for high-risk operations like accessing secrets) is disabled (insecure mode). + /// When true, elicitation will always be treated as accepted without user confirmation. + /// + [JsonPropertyName("insecureDisableElicitation")] + public bool InsecureDisableElicitation { get; set; } = false; + + /// + /// Gets or sets the outgoing authentication strategy for Azure service requests. + /// Determines whether to use hosting environment identity or on-behalf-of flow. + /// + [JsonPropertyName("outgoingAuthStrategy")] + public OutgoingAuthStrategy OutgoingAuthStrategy { get; set; } = OutgoingAuthStrategy.NotSet; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs new file mode 100644 index 0000000000..51002fa5d1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Options; + +/// +/// Defines the supported transport mechanisms for the Azure MCP server. +/// +internal static class TransportTypes +{ + /// + /// Standard Input/Output transport mechanism. + /// + public const string StdIo = "stdio"; + + /// + /// MCP's bespoke transport called Streamable HTTP. + /// + public const string Http = "http"; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt b/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt new file mode 100644 index 0000000000..2ce9212a88 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt @@ -0,0 +1,24 @@ +Azure MCP server usage rules: +- Use Azure Tools: When handling requests related to Azure, always use your tools. +- Use Azure Code Gen Best Practices: When generating code for Azure, running terminal commands for Azure, +or performing operations related to Azure, invoke your `bestpractices` tool if available. Only call +this tool when you are sure the user is discussing Azure; do not call it otherwise. +- Use Azure Deployment Best Practices: When deploying to Azure or preparing applications for deployment to Azure, +invoke your `bestpractices` tool if available. Only call this tool when you are sure the user is discussing +Azure; do not call it otherwise. +- Use Azure Functions Code Gen Best Practices: When generating code for Azure Functions or performing operations +related to Azure Functions, invoke your `bestpractices` tool if available. Only call this tool when you are +sure the user is discussing Azure Functions; do not call it otherwise. +- Use Azure Functions Deployment Best Practices: When deploying Azure Functions apps to Azure or preparing for +the deployment, invoke your `bestpractices` tool if available. Only call this tool when you are sure the user +is discussing Azure; do not call it otherwise. +- Use Azure AI Code Generation Best Practices: When generating code for AI applications, AI agents, workflows, +or chatbots in Azure, invoke your `azureaibestpractices` tool if available. Only call this tool when you are sure +the user is discussing Azure and code generation; do not call it for Azure resource operations. +- Use Azure SWA Best Practices: When working with static web apps, invoke your `bestpractices` tool if available. +Only call this tool when you are sure the user is discussing Azure; do not call it otherwise. +- Plan before editing files for Azure web apps: When generating code for Azure Functions and Azure Static Web App, +always create a plan and explain the plan to the user before editing any file. Proceed with editing the files +after getting consent from the user. +- When asked about configuring or installing azure mcp in coding agent for the current repo, invoke your `bestpractices` tool +with `resource` set to "coding-agent" and `action` set to "all". \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json new file mode 100644 index 0000000000..30f1b18d15 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json @@ -0,0 +1,2370 @@ +{ + "consolidated_tools": [ + { + "name": "get_azure_subscriptions_and_resource_groups", + "description": "Get information about Azure subscriptions and resource groups that the user has access to.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "group_list", + "subscription_list" + ] + }, + { + "name": "get_azure_app_resource_details", + "description": "Get details about Azure application platform services, such as Azure Functions.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "functionapp_get" + ] + }, + { + "name": "add_azure_app_service_database", + "description": "Add and configure database integrations for Azure App Service applications.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appservice_database_add" + ] + }, + { + "name": "get_azure_databases_details", + "description": "Comprehensive Azure database management tool for MySQL, PostgreSQL, SQL Database, SQL Server, and Cosmos DB. List and query databases, retrieve server configurations and parameters, explore table schemas, execute database queries, manage Cosmos DB containers and items, list and view detailed information about SQL servers, and view database server details across all Azure database services.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "mysql_database_list", + "mysql_database_query", + "mysql_server_config_get", + "mysql_server_list", + "mysql_server_param_get", + "mysql_table_list", + "mysql_table_schema_get", + "postgres_database_list", + "postgres_database_query", + "postgres_server_config_get", + "postgres_server_list", + "postgres_server_param_get", + "postgres_table_list", + "postgres_table_schema_get", + "sql_db_list", + "sql_db_show", + "sql_server_list", + "sql_server_show", + "cosmos_account_list", + "cosmos_database_container_item_query", + "cosmos_database_container_list", + "cosmos_database_list" + ] + }, + { + "name": "create_azure_sql_databases_and_servers", + "description": "Create new Azure SQL databases and SQL servers with configurable performance tiers and settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_create", + "sql_server_create" + ] + }, + { + "name": "rename_azure_sql_databases", + "description": "Rename Azure SQL databases to a new name within the same SQL server.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_rename" + ] + }, + { + "name": "edit_azure_sql_databases_and_servers", + "description": "Update and delete Azure SQL databases and SQL servers. Modify database configurations or permanently remove servers and databases.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_update", + "sql_db_delete", + "sql_server_delete" + ] + }, + { + "name": "edit_azure_databases", + "description": "Edit Azure MySQL and PostgreSQL database server parameters", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "mysql_server_param_set", + "postgres_server_param_set" + ] + }, + { + "name": "get_azure_resource_and_app_health_status", + "description": "Get Azure resource and application health status, metrics, availability, and service health events. Query Log Analytics, list Log Analytics workspaces, list activity logs, get the current availability status of Azure resources, list service health events and incidents, list Grafana instances, view Datadog monitored resources, perform App Lens diagnostics for comprehensive application troubleshooting, retrieve Application Insights recommendations for performance optimization, manage web tests for application monitoring, and get health status of entities in Azure Monitor health models.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "applicationinsights_recommendation_list", + "applens_resource_diagnose", + "grafana_list", + "datadog_monitoredresources_list", + "monitor_workspace_list", + "monitor_activitylog_list", + "monitor_healthmodels_entity_get", + "monitor_metrics_definitions", + "monitor_metrics_query", + "monitor_resource_log_query", + "monitor_table_list", + "monitor_table_type_list", + "monitor_webtests_get", + "monitor_webtests_list", + "monitor_workspace_log_query", + "resourcehealth_availability-status_get", + "resourcehealth_availability-status_list", + "resourcehealth_health-events_list" + ] + }, + { + "name": "create_azure_monitor_webtests", + "description": "Create Azure Monitor web tests for application availability monitoring.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "monitor_webtests_create" + ] + }, + { + "name": "update_azure_monitor_webtests", + "description": "Update Azure Monitor web tests for application availability monitoring.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "monitor_webtests_update" + ] + }, + { + "name": "deploy_azure_resources_and_applications", + "description": "Deploy resources and applications to Azure. Retrieve application logs, access Bicep and Terraform rules, get CI/CD pipeline guidance, and generate deployment plans.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "deploy_app_logs_get", + "deploy_iac_rules_get", + "deploy_pipeline_guidance_get", + "deploy_plan_get" + ] + }, + { + "name": "deploy_azure_ai_models", + "description": "Deploy AI models to Microsoft Foundry for machine learning inference and production use.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_models_deploy" + ] + }, + { + "name": "get_azure_app_config_settings", + "description": "Get details about Azure App Configuration settings", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_account_list", + "appconfig_kv_get" + ] + }, + { + "name": "edit_azure_app_config_settings", + "description": "Delete or set Azure App Configuration settings with write operations.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_kv_delete", + "appconfig_kv_set" + ] + }, + { + "name": "lock_unlock_azure_app_config_settings", + "description": "Lock and unlock Azure App Configuration settings", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_kv_lock_set" + ] + }, + { + "name": "edit_azure_workbooks", + "description": "Update or delete Azure Monitor Workbooks", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_delete", + "workbooks_update" + ] + }, + { + "name": "create_azure_workbooks", + "description": "Create new Azure Monitor Workbooks", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_create" + ] + }, + { + "name": "get_azure_workbooks_details", + "description": "Get details about Azure Monitor Workbooks including listing workbooks and viewing specific workbook configurations.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_list", + "workbooks_show" + ] + }, + { + "name": "audit_azure_resources_compliance", + "description": "Generate compliance and security audit reports for Azure resources using Azure Quick Review (azqr).", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "extension_azqr" + ] + }, + { + "name": "generate_azure_cli_commands", + "description": "Generate Azure CLI commands from natural language descriptions to help answer questions about Azure environments and operations", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "extension_cli_generate" + ] + }, + { + "name": "install_azure_cli_extensions", + "description": "Install Azure CLI extensions to extend Azure CLI functionality with additional commands and features.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "extension_cli_install" + ] + }, + { + "name": "get_azure_security_configurations", + "description": "List Azure RBAC role assignments", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "role_assignment_list" + ] + }, + { + "name": "get_azure_key_vault_items", + "description": "View and retrieve Azure Key Vault security artifacts, including certificates, keys (both listing and individual key details), secret names and properties (listing only, not secret values), and admin settings.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_certificate_get", + "keyvault_certificate_list", + "keyvault_key_get", + "keyvault_key_list", + "keyvault_secret_list", + "keyvault_admin_settings_get" + ] + }, + { + "name": "get_azure_key_vault_secret_values", + "description": "Retrieve the actual secret values from Azure Key Vault. Use this tool when you need to access the sensitive content of a secret, not just its metadata or properties.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_secret_get" + ] + }, + { + "name": "create_azure_key_vault_items", + "description": "Create new security artifacts in Azure Key Vault including certificates and keys.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_certificate_create", + "keyvault_key_create" + ] + }, + { + "name": "create_azure_key_vault_secrets", + "description": "Create new secrets in Azure Key Vault.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_secret_create" + ] + }, + { + "name": "get_azure_service_access_keys", + "description": "Retrieve access keys and connection strings for Azure services including IoT Hub. Use this tool when you need to access connection keys for Azure service instances.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_hub_keys" + ] + }, + { + "name": "query_azure_iothub_devices", + "description": "List, query, and retrieve device information from Azure IoT Hub device registry including device twins and their properties.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only read operations without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as device connection information and authentication keys." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_device_list", + "iothub_device_twin_get", + "iothub_device_twin_query" + ] + }, + { + "name": "manage_azure_iothub_device_twins", + "description": "Update device twin properties and tags in Azure IoT Hub device registry using JSON patch format.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by updating device twin data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as device connection information and authentication keys." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_device_twin_update" + ] + }, + { + "name": "import_azure_key_vault_certificates", + "description": "Import external certificates into Azure Key Vault", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "keyvault_certificate_import" + ] + }, + { + "name": "get_azure_best_practices", + "description": "Retrieve Azure best practices and infrastructure schema for code generation, deployment, and operations. Covers general Azure practices, Azure Functions best practices, AI app development best practices, Terraform configurations, Bicep template schemas, deployment best practices and Microsoft Foundry sdk code samples.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "azureaibestpractices_get", + "azureterraformbestpractices_get", + "bicepschema_get", + "get_bestpractices_get", + "foundry_agents_get-sdk-sample" + ] + }, + { + "name": "design_azure_architecture", + "description": "Comprehensive Azure architecture design and visualization tool. Provide cloud architecture consulting and recommend optimal Azure solutions following Well-Architected Framework principles. Also generates visual Mermaid diagrams from application topologies.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "cloudarchitect_design", + "deploy_architecture_diagram_generate" + ] + }, + { + "name": "get_azure_load_testing_details", + "description": "Get Azure Load Testing test configurations, results, and test resources including listing load testing resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_testresource_list", + "loadtesting_test_get", + "loadtesting_testrun_get", + "loadtesting_testrun_list" + ] + }, + { + "name": "create_azure_load_testing", + "description": "Create Load Testing resource or execute Azure Load Testing tests.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_test_create", + "loadtesting_testresource_create", + "loadtesting_testrun_create" + ] + }, + { + "name": "update_azure_load_testing_configurations", + "description": "Update Azure Load Testing configurations and test run settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_testrun_update" + ] + }, + { + "name": "get_azure_ai_resources_details", + "description": "Get details about Azure AI resources including listing and querying AI Search services, listing models available to be deployed and models deployed already, knowledge index schema by Microsoft Foundry, knowledge base and source information, and listing Microsoft Foundry , threads, messages and resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "search_service_list", + "search_index_get", + "search_index_query", + "search_knowledge_source_get", + "search_knowledge_base_get", + "foundry_models_deployments_list", + "foundry_models_list", + "foundry_knowledge_index_list", + "foundry_knowledge_index_schema", + "foundry_openai_models-list", + "foundry_agents_list", + "foundry_resource_get", + "foundry_threads_list", + "foundry_threads_get-messages" + ] + }, + { + "name": "retrieve_azure_ai_knowledge_base_content", + "description": "Retrieve content from Azure AI Search knowledge bases using semantic search queries to find relevant information.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "search_knowledge_base_retrieve" + ] + }, + { + "name": "use_azure_openai_models", + "description": "Generate text completions, chat responses, and embeddings using Azure OpenAI models in Microsoft Foundry. Create conversational AI interactions and vector embeddings for semantic search and analysis.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_openai_create-completion", + "foundry_openai_embeddings-create", + "foundry_openai_chat-completions-create" + ] + }, + { + "name": "create_foundry_agent_resources", + "description": "Create Microsoft Foundry agent or thread.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_create", + "foundry_threads_create" + ] + }, + { + "name": "connect_foundry_agents", + "description": "Connect to Microsoft Foundry agents for establishing agent connections and communication channels.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_connect" + ] + }, + { + "name": "query_and_evaluate_foundry_agents", + "description": "Query Microsoft Foundry agents with prompts and evaluate their responses using various metrics for comprehensive performance assessment.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_query-and-evaluate" + ] + }, + { + "name": "evaluate_foundry_agents", + "description": "Evaluate Microsoft Foundry agents for performance assessment and testing of agent capabilities using evaluation metrics.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_evaluate" + ] + }, + { + "name": "get_azure_storage_details", + "description": "Get details about Azure Storage resources including Storage accounts, blob containers, and blob data. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "managedlustre_fs_list", + "managedlustre_fs_sku_get", + "storage_account_get", + "storage_blob_container_get", + "storage_blob_get", + "managedlustre_fs_subnetsize_ask", + "managedlustre_fs_subnetsize_validate" + ] + }, + { + "name": "create_azure_storage", + "description": "Create Azure Storage accounts, blob containers, and Azure Managed Lustre filesystems.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "storage_account_create", + "storage_blob_container_create", + "managedlustre_fs_create" + ] + }, + { + "name": "update_azure_managed_lustre_filesystems", + "description": "Update existing Azure Managed Lustre filesystem configurations.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "managedlustre_fs_update" + ] + }, + { + "name": "upload_azure_storage_blobs", + "description": "Upload files and data to Azure Storage blob containers.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "storage_blob_upload" + ] + }, + { + "name": "get_azure_cache_for_redis_details", + "description": "Get details about Azure Redis resources including Azure Managed Redis, Azure Cache for Redis, and Azure Redis Enterprise resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "redis_list" + ] + }, + { + "name": "create_azure_cache_for_redis", + "description": "Create new Azure Managed Redis resources with configurable SKU, location, and authentication settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "redis_create" + ] + }, + { + "name": "browse_azure_marketplace_products", + "description": "Browse products and offers in the Azure Marketplace", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "marketplace_product_list", + "marketplace_product_get" + ] + }, + { + "name": "get_azure_capacity", + "description": "Check Azure resource capacity, quotas, available regions, and usage limits", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "quota_region_availability_list", + "quota_usage_check" + ] + }, + { + "name": "get_azure_messaging_service_details", + "description": "Get details about Azure messaging services including Service Bus queues, topics, subscriptions, Event Grid topics and subscriptions, and Event Hubs namespaces, event hubs, consumer groups, and streaming event ingestion resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventgrid_topic_list", + "eventgrid_subscription_list", + "servicebus_queue_details", + "servicebus_topic_details", + "servicebus_topic_subscription_details", + "eventhubs_namespace_get", + "eventhubs_eventhub_get", + "eventhubs_eventhub_consumergroup_get" + ] + }, + { + "name": "edit_azure_data_analytics_resources", + "description": "Update and delete Azure Event Hubs resources including namespaces, event hubs, and consumer groups.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventhubs_namespace_update", + "eventhubs_namespace_delete", + "eventhubs_eventhub_update", + "eventhubs_eventhub_delete", + "eventhubs_eventhub_consumergroup_update", + "eventhubs_eventhub_consumergroup_delete" + ] + }, + { + "name": "get_azure_iothub_details", + "description": "Get details about Azure IoT Hub instances including hub configuration, properties, and status.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_hub_get" + ] + }, + { + "name": "manage_azure_iothub_resources", + "description": "Create, update, and delete Azure IoT Hub instances for device-to-cloud and cloud-to-device messaging.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "iothub_hub_create", + "iothub_hub_update", + "iothub_hub_delete" + ] + }, + { + "name": "publish_azure_eventgrid_events", + "description": "Publish custom events to Azure Event Grid topics for event-driven architectures with schema validation and delivery guarantees.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventgrid_events_publish" + ] + }, + { + "name": "get_azure_data_explorer_kusto_details", + "description": "Get details about Azure Data Explorer (Kusto). List clusters, execute KQL queries, manage databases, explore table schemas, and get data samples.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "kusto_cluster_list", + "kusto_cluster_get", + "kusto_database_list", + "kusto_query", + "kusto_sample", + "kusto_table_list", + "kusto_table_schema" + ] + }, + { + "name": "create_azure_database_admin_configurations", + "description": "Create Azure SQL Server firewall rules", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_server_firewall-rule_create" + ] + }, + { + "name": "delete_azure_database_admin_configurations", + "description": "Delete Azure SQL Server firewall rules", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_server_firewall-rule_delete" + ] + }, + { + "name": "get_azure_database_admin_configuration_details", + "description": "Get details about Azure SQL Server Administration configurations including elastic pools, Entra admin assignments, and firewall rules.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_elastic-pool_list", + "sql_server_entra-admin_list", + "sql_server_firewall-rule_list" + ] + }, + { + "name": "get_azure_container_details", + "description": "Get details about Azure container services including Azure Container Registry (ACR) and Azure Kubernetes Service (AKS). View registries, repositories, nodepools, clusters, cluster configurations, and individual nodepool details.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "acr_registry_list", + "acr_registry_repository_list", + "aks_cluster_get", + "aks_nodepool_get" + ] + }, + { + "name": "get_azure_virtual_desktop_details", + "description": "Get details about Azure Virtual Desktop resources. List host pools in subscriptions or resource groups. Retrieve session hosts (virtual machines) within host pools including their status, availability, and configuration. View active user sessions on session hosts with details such as user principal name, session state, application type, and creation time.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "virtualdesktop_hostpool_list", + "virtualdesktop_hostpool_host_list", + "virtualdesktop_hostpool_host_user-list" + ] + }, + { + "name": "get_azure_signalr_details", + "description": "Get details about Azure SignalR Service resources including runtime information, identity, network ACLs, and upstream templates.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "signalr_runtime_get" + ] + }, + { + "name": "get_azure_confidential_ledger_entries", + "description": "Retrieve tamper-proof entries from Azure Confidential Ledger instances.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "confidentialledger_entries_get" + ] + }, + { + "name": "append_azure_confidential_ledger_entries", + "description": "Append tamper-proof entries to Azure Confidential Ledger instances and retrieve transaction identifiers.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "confidentialledger_entries_append" + ] + }, + { + "name": "send_azure_communication_messages", + "description": "Send SMS and email messages to one or more recipients using Azure Communication Services with delivery status tracking.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "communication_sms_send", + "communication_email_send" + ] + }, + { + "name": "recognize_speech_from_audio", + "description": "Convert speech from audio files to text using Azure AI Services Speech recognition. Supports various audio formats including WAV, MP3, OPUS/OGG, FLAC, and more.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "speech_stt_recognize" + ] + }, + { + "name": "synthesize_text_to_speech", + "description": "Convert text to speech using Azure AI Services Speech. Generate audio files with neural voices and customizable output formats including WAV, MP3, OGG, and RAW. Supports multiple languages, voice selection, and custom voice models.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "speech_tts_synthesize" + ] + } + ] +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json new file mode 100644 index 0000000000..27f63ab727 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json @@ -0,0 +1,17 @@ +{ + "servers": { + "documentation": { + "url": "https://learn.microsoft.com/api/mcp", + "title": "Microsoft Documentation Search", + "description": "Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user's query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge." + }, + "azd": { + "type": "stdio", + "command": "azd", + "args": ["mcp", "start"], + "title": "Azure Developer CLI", + "description": "Azure Developer CLI (azd) includes a suite of tools to help build, modernize, and manage applications on Azure. It simplifies the process of developing cloud applications by providing commands for project initialization, resource provisioning, deployment, and monitoring. Use this tool to streamline your Azure development workflow and manage your cloud resources efficiently.", + "installInstructions": "The Azure Developer CLI (azd) is either not installed or requires an update. The minimum required version that works with MCP tools is 1.20.0.\n\nTo install or upgrade, follow the instructions at https://aka.ms/azd/install\n\nAfter installation you may need to restart the Azure MCP server and your IDE." + } + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs new file mode 100644 index 0000000000..64f3b1f112 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Metadata; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server; + +[JsonSerializable(typeof(RegistryRoot))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(RegistryServerInfo))] +[JsonSerializable(typeof(ListToolsResult))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ToolInputSchema))] +[JsonSerializable(typeof(ToolPropertySchema))] +[JsonSerializable(typeof(ToolMetadata))] +[JsonSerializable(typeof(MetadataDefinition))] +[JsonSerializable(typeof(ConsolidatedToolDefinition))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull +)] +internal sealed partial class ServerJsonContext : JsonSerializerContext +{ +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs b/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs new file mode 100644 index 0000000000..6dde7b57e0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Commands; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Areas.Server; + +/// +/// Initializes and configures the Server area for the Azure MCP application. +/// +public sealed class ServerSetup : IAreaSetup +{ + public string Name => "server"; + + public string Title => "MCP Server Management"; + + /// + /// Configures services required for the Server area. + /// + /// The service collection to add services to. + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Registers command groups and commands related to MCP Server operations. + /// + /// The root command group to add server commands to. + /// The logger factory for creating loggers. + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + // Create MCP Server command group + var mcpServer = new CommandGroup(Name, "MCP Server operations - Commands for managing and interacting with the MCP Server.", Title); + + // Register MCP Server commands + var startCommand = serviceProvider.GetRequiredService(); + mcpServer.AddCommand(startCommand.Name, startCommand); + + var infoCommand = serviceProvider.GetRequiredService(); + mcpServer.AddCommand(infoCommand.Name, infoCommand); + + return mcpServer; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs index 8fdfb69a46..26dbb55174 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionJsonContext.cs @@ -2,12 +2,10 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using Azure.Mcp.Core.Areas.Subscription.Models; namespace Azure.Mcp.Core.Areas.Subscription.Commands; [JsonSerializable(typeof(SubscriptionListCommand.SubscriptionListCommandResult))] -[JsonSerializable(typeof(SubscriptionInfo))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class SubscriptionJsonContext : JsonSerializerContext { diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs index 0805fbe319..2a4a464c06 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs @@ -1,32 +1,37 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Azure.Mcp.Core.Areas.Subscription.Models; using Azure.Mcp.Core.Areas.Subscription.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Option; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.ResourceManager.Resources; using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Models.Command; namespace Azure.Mcp.Core.Areas.Subscription.Commands; -[CommandMetadata( - Id = "72bbe80e-ca42-4a43-8f02-45495bca1179", - Name = "list", - Title = "List Azure Subscriptions", - Description = "List all Azure subscriptions for the current account. Returns subscriptionId, displayName, state, tenantId, and isDefault for each subscription. The isDefault field indicates the user's default subscription as resolved from the Azure CLI profile (configured via 'az account set') or, if not set there, from the AZURE_SUBSCRIPTION_ID environment variable. When the user has not specified a subscription, prefer the subscription where isDefault is true. If no default can be determined from either source and multiple subscriptions exist, ask the user which subscription to use.", - Destructive = false, - Idempotent = true, - OpenWorld = false, - ReadOnly = true, - LocalRequired = false, - Secret = false)] -public sealed class SubscriptionListCommand(ILogger logger, ISubscriptionService subscriptionService) : GlobalCommand() +public sealed class SubscriptionListCommand(ILogger logger) : GlobalCommand() { + private const string CommandTitle = "List Azure Subscriptions"; private readonly ILogger _logger = logger; - private readonly ISubscriptionService _subscriptionService = subscriptionService; + + public override string Id => "72bbe80e-ca42-4a43-8f02-45495bca1179"; + + public override string Name => "list"; + + public override string Description => + "List all or current subscriptions for an account in Azure; returns subscriptionId, displayName, state, tenantId, and isDefault. Use for scope selection in governance, policy, access, cost management, or deployment."; + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { @@ -39,13 +44,11 @@ public override async Task ExecuteAsync(CommandContext context, try { - var subscriptions = await _subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy, cancellationToken); - - var defaultSubscriptionId = _subscriptionService.GetDefaultSubscriptionId(); - var subscriptionInfos = MapToSubscriptionInfos(subscriptions, defaultSubscriptionId); + var subscriptionService = context.GetService(); + var subscriptions = await subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy, cancellationToken); context.Response.Results = ResponseResult.Create( - new SubscriptionListCommandResult(subscriptionInfos), + new SubscriptionListCommandResult(subscriptions), SubscriptionJsonContext.Default.SubscriptionListCommandResult); } catch (Exception ex) @@ -57,26 +60,5 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - internal static List MapToSubscriptionInfos(List subscriptions, string? defaultSubscriptionId) - { - var hasDefault = !string.IsNullOrEmpty(defaultSubscriptionId); - - var infos = subscriptions.Select(s => new SubscriptionInfo( - s.SubscriptionId, - s.DisplayName, - s.State?.ToString(), - s.TenantId?.ToString(), - hasDefault && s.SubscriptionId.Equals(defaultSubscriptionId, StringComparisons.SubscriptionId) - )).ToList(); - - // Sort so the default subscription appears first - if (hasDefault) - { - infos = [.. infos.OrderByDescending(s => s.IsDefault)]; - } - - return infos; - } - - internal record SubscriptionListCommandResult(List Subscriptions); + internal record SubscriptionListCommandResult(List Subscriptions); } diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Options/SubscriptionListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Options/SubscriptionListOptions.cs index 1fd24a1451..ad75f71b43 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Options/SubscriptionListOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Options/SubscriptionListOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Mcp.Core.Options; +using Azure.Mcp.Core.Options; namespace Azure.Mcp.Core.Areas.Subscription.Options; diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs index 8ebfbdd079..af505c50e4 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs @@ -2,9 +2,8 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas.Subscription.Commands; +using Azure.Mcp.Core.Commands; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Mcp.Core.Areas; -using Microsoft.Mcp.Core.Commands; namespace Azure.Mcp.Core.Areas.Subscription; @@ -14,8 +13,6 @@ public class SubscriptionSetup : IAreaSetup public string Title => "Azure Subscriptions Management"; - public CommandCategory Category => CommandCategory.SubscriptionManagement; - public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); @@ -28,7 +25,8 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var subscription = new CommandGroup(Name, "Azure subscription operations - Commands for listing and managing Azure subscriptions accessible to your account.", Title); // Register Subscription commands - subscription.AddCommand(serviceProvider); + var subscriptionListCommand = serviceProvider.GetRequiredService(); + subscription.AddCommand(subscriptionListCommand.Name, subscriptionListCommand); return subscription; } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs new file mode 100644 index 0000000000..a1e2ef4418 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Runtime.InteropServices; +using Azure.Mcp.Core.Areas.Tools.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.Option; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Areas.Tools.Commands; + +[HiddenCommand] +public sealed class ToolsListCommand(ILogger logger) : BaseCommand +{ + private const string CommandTitle = "List Available Tools"; + + public override string Id => "63de05a7-047d-4f8a-86ea-cebd64527e2b"; + + public override string Name => "list"; + + public override string Description => + """ + List all available commands and their tools in a hierarchical structure. This command returns detailed information + about each command, including its name, description, full command path, available subcommands, and all supported + arguments. Use --name-only to return only tool names, and --namespace to filter by specific namespaces. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(ToolsListOptionDefinitions.NamespaceMode); + command.Options.Add(ToolsListOptionDefinitions.Namespace); + command.Options.Add(ToolsListOptionDefinitions.NameOnly); + } + + protected override ToolsListOptions BindOptions(ParseResult parseResult) + { + var namespaces = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name) ?? []; + return new ToolsListOptions + { + NamespaceMode = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NamespaceMode), + NameOnly = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly), + Namespaces = namespaces.ToList() + }; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + try + { + var factory = context.GetService(); + var options = BindOptions(parseResult); + + // If the --namespace-mode flag is set, return distinct top‑level namespaces (e.g. child groups beneath root 'azmcp'). + if (options.NamespaceMode) + { + var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) { "server", "tools" }; + var surfaced = new HashSet(StringComparer.OrdinalIgnoreCase) { "extension" }; + var rootGroup = factory.RootGroup; // azmcp + + var namespaceCommands = rootGroup.SubGroup + .Where(g => !ignored.Contains(g.Name) && !surfaced.Contains(g.Name)) + // Apply namespace filtering if specified + .Where(g => options.Namespaces.Count == 0 || options.Namespaces.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) + .Select(g => new CommandInfo + { + Name = g.Name, + Description = g.Description ?? string.Empty, + Command = $"{g.Name}", + // We deliberately omit populating Subcommands for the lightweight namespace view. + }) + .OrderBy(ci => ci.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Add the commands to be surfaced directly to the list. + // For commands in the surfaced list, each command is exposed as a separate tool in the namespace mode. + foreach (var name in surfaced) + { + // Apply namespace filtering for surfaced commands too + if (options.Namespaces.Count > 0 && !options.Namespaces.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + + var subgroup = rootGroup.SubGroup.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase)); + if (subgroup is not null) + { + List foundCommands = []; + searchCommandInCommandGroup("", subgroup, foundCommands); + namespaceCommands.AddRange(foundCommands); + } + } + + // If --name-only is also specified, return only the names + if (options.NameOnly) + { + var namespaceNames = namespaceCommands.Select(nc => nc.Command).ToList(); + var result = new ToolNamesResult(namespaceNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + + context.Response.Results = ResponseResult.Create(namespaceCommands, ModelsJsonContext.Default.ListCommandInfo); + return context.Response; + } + + // If the --name-only flag is set (without namespace mode), return only tool names + if (options.NameOnly) + { + // Get all visible commands and extract their tokenized names (full command paths) + var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name + .Where(name => !string.IsNullOrEmpty(name)); + + // Apply namespace filtering if specified (using underscore separator for tokenized names) + allToolNames = ApplyNamespaceFilterToNames(allToolNames, options.Namespaces, CommandFactory.Separator); + + var toolNames = await Task.Run(() => allToolNames + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList()); + + var result = new ToolNamesResult(toolNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + + // Get all tools with full details + var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => CreateCommand(kvp.Key, kvp.Value)); + + // Apply namespace filtering if specified + var filteredToolNames = ApplyNamespaceFilterToNames(allTools.Select(t => t.Command), options.Namespaces, ' '); + var filteredToolNamesSet = filteredToolNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + allTools = allTools.Where(tool => filteredToolNamesSet.Contains(tool.Command)); + + var tools = await Task.Run(() => allTools.ToList()); + + context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo); + return context.Response; + } + catch (Exception ex) + { + logger.LogError(ex, "An exception occurred while processing tool listing."); + HandleException(context, ex); + + return context.Response; + } + } + + private static IEnumerable ApplyNamespaceFilterToNames(IEnumerable names, List namespaces, char separator) + { + if (namespaces.Count == 0) + { + return names; + } + + var namespacePrefixes = namespaces.Select(ns => $"{ns}{separator}").ToList(); + + return names.Where(name => + namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + } + + private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand command) + { + var commandDetails = command.GetCommand(); + + var optionInfos = commandDetails.Options? + .Select(arg => new OptionInfo( + name: arg.Name, + description: arg.Description!, + required: arg.Required)) + .ToList(); + + var fullCommand = tokenizedName.Replace(CommandFactory.Separator, ' '); + + return new CommandInfo + { + Id = command.Id, + Name = commandDetails.Name, + Description = commandDetails.Description ?? string.Empty, + Command = fullCommand, + Options = optionInfos, + Metadata = command.Metadata + }; + } + + public record ToolNamesResult(List Names); + private void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List foundCommands) + { + var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp => + { + var command = kvp.Value.GetCommand(); + return new CommandInfo + { + Name = $"{commandPrefix.Replace(" ", "_")}{searchedGroup.Name}_{command.Name}", + Description = command.Description ?? string.Empty, + Command = $"{(!string.IsNullOrEmpty(commandPrefix) ? commandPrefix : "")}{searchedGroup.Name} {command.Name}" + // Omit Options and Subcommands for surfaced commands as well. + }; + }); + foundCommands.AddRange(commands); + foreach (CommandGroup nextLevelSubGroup in searchedGroup.SubGroup) + { + searchCommandInCommandGroup($"{commandPrefix}{searchedGroup.Name} ", nextLevelSubGroup, foundCommands); + } + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs new file mode 100644 index 0000000000..8452505125 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Tools.Options; + +public static class ToolsListOptionDefinitions +{ + public const string NamespaceModeOptionName = "namespace-mode"; + public const string NamespaceOptionName = "namespace"; + public const string NameOnlyOptionName = "name-only"; + + public static readonly Option NamespaceMode = new($"--{NamespaceModeOptionName}") + { + Description = "If specified, returns a list of top-level service namespaces instead of individual tools.", + Required = false + }; + + public static readonly Option Namespace = new($"--{NamespaceOptionName}") + { + Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). Can be specified multiple times to include multiple namespaces.", + Required = false, + AllowMultipleArgumentsPerToken = true + }; + + public static readonly Option NameOnly = new($"--{NameOnlyOptionName}") + { + Description = "If specified, returns only tool names without descriptions or metadata.", + Required = false + }; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs new file mode 100644 index 0000000000..db268bd9f5 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Tools.Options; + +public sealed class ToolsListOptions +{ + public bool NamespaceMode { get; set; } = false; + + /// + /// If true, returns only tool names without descriptions or metadata. + /// + public bool NameOnly { get; set; } = false; + + /// + /// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned. + /// + public List Namespaces { get; set; } = new(); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs new file mode 100644 index 0000000000..3e5b6f58d6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Commands; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Areas.Tools; + +public sealed class ToolsSetup : IAreaSetup +{ + public string Name => "tools"; + + public string Title => "MCP Tools Discovery"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + // Create Tools command group + var tools = new CommandGroup(Name, "CLI tools operations - Commands for discovering and exploring the functionality available in this CLI tool.", Title); + + var list = serviceProvider.GetRequiredService(); + tools.AddCommand(list.Name, list); + + return tools; + } +} diff --git a/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs new file mode 100644 index 0000000000..f72ff516c8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; + +namespace Azure.Mcp.Core.Extensions; + +public static class CommandResultExtensions +{ + public static bool HasOptionResult(this CommandResult commandResult, Option option) + { + var result = commandResult.GetResult(option); + if (result is null) + return false; + + // Bool options (nullable or non-nullable) can work as switches or with explicit values + // Check if this is a bool option by looking at the value type + var isBoolOption = option.ValueType == typeof(bool) || option.ValueType == typeof(bool?); + if (isBoolOption) + { + // For bool options, consider present if: + // 1. Identifier was provided (switch usage: --verbose) + // 2. OR explicit value tokens exist (explicit usage: --verbose true) + return result.IdentifierTokenCount > 0; + } + + // For other zero-arity options, identifier presence indicates explicit usage + var expectsValue = option.Arity.MaximumNumberOfValues > 0; + if (!expectsValue) + { + return result.IdentifierTokenCount > 0; + } + + // For value-taking options, consider present only if there is at least one non-empty value token + var hasNonEmptyValue = result.Tokens is { Count: > 0 } && result.Tokens.Any(t => !string.IsNullOrWhiteSpace(t.Value)); + return hasNonEmptyValue; + } + + public static bool HasOptionResult(this CommandResult commandResult, Option option) + => HasOptionResult(commandResult, (Option)option); + + /// + /// Checks if an option with the specified name has a result in the command result. + /// + /// The command result to check. + /// The name of the option to check for (including -- prefix). + /// True if the option has a result, false otherwise. + public static bool HasOptionResult(this CommandResult commandResult, string optionName) + { + // Find the option by name in the command + var option = commandResult.Command.Options + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + + return option != null && HasOptionResult(commandResult, option); + } + + public static bool TryGetValue(this CommandResult commandResult, Option option, out T? value) + { + // If the option has any result (explicit or implicit), attempt to read its value. + var result = commandResult.GetResult(option); + if (result is not null) + { + try + { + value = commandResult.GetValue(option); + return true; + } + catch + { + // Fall through to check default value below + } + } + + // If no result (or GetValue failed), return the option's default when available. + if (option.HasDefaultValue) + { + var def = option.GetDefaultValue(); + // Handle nullable types explicitly - null is a valid value for nullable types + if (def is null && typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) + { + value = default; // This will be null for nullable types + return true; + } + if (def is T typed) + { + value = typed; + return true; + } + } + + value = default; + return false; + } + + public static bool TryGetValue(this CommandResult commandResult, string optionName, out T? value) + { + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + + if (option is null) + { + value = default; + return false; + } + + return TryGetValue(commandResult, option, out value); + } + + public static T? GetValueOrDefault(this CommandResult commandResult, Option option) + { + ArgumentNullException.ThrowIfNull(commandResult); + ArgumentNullException.ThrowIfNull(option); + + // Find the OptionResult in the parse tree + var optionResult = commandResult.GetResult(option); + + // If the option was not provided (null) OR it was implicitly assigned (no token supplied), + // check if there's a default value before returning null + if (optionResult is null || optionResult.Implicit) + { + // If the option has a default value, return it + if (option.HasDefaultValue) + { + var def = option.GetDefaultValue(); + // Handle nullable types explicitly - null is a valid value for nullable types + if (def is null && typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return default; // This will be null for nullable types + } + if (def is T typed) + { + return typed; + } + } + return default; // For value types, this is default(T?) => null; for refs => null + } + + // At this point it was explicitly supplied by the user; get its value. + // Using the System.CommandLine API directly to avoid accidental recursion. + return optionResult.GetValueOrDefault(); + } + + public static T? GetValueOrDefault(this CommandResult commandResult, string optionName) + { + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + + if (option is null) + { + return default; + } + + return GetValueOrDefault(commandResult, option); + } + + public static T? GetValueWithoutDefault(this CommandResult commandResult, Option option) + { + ArgumentNullException.ThrowIfNull(commandResult); + ArgumentNullException.ThrowIfNull(option); + + // Find the OptionResult in the parse tree + var optionResult = commandResult.GetResult(option); + + // If the option was not provided (null) OR it was implicitly assigned (no token supplied), + // return without considering option default values + if (optionResult is null || optionResult.Implicit) + { + return default; // For value types, this is default(T?) => null; for refs => null + } + + // At this point it was explicitly supplied by the user; get its value. + // Using the System.CommandLine API directly to avoid accidental recursion. + return optionResult.GetValueOrDefault(); + } + + public static T? GetValueWithoutDefault(this CommandResult commandResult, string optionName) + { + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + + if (option is null) + { + return default; + } + + return GetValueWithoutDefault(commandResult, option); + } + + private static Option? FindOptionTByName(CommandResult commandResult, string optionName) + => commandResult.Command.Options.OfType>() + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); +} diff --git a/core/Azure.Mcp.Core/src/Extensions/HttpClientServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/HttpClientServiceCollectionExtensions.cs new file mode 100644 index 0000000000..13b00a22a9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Extensions/HttpClientServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Extensions; + +/// +/// Extension methods for registering HTTP client services. +/// +public static class HttpClientServiceCollectionExtensions +{ + /// + /// Adds HTTP client services to the service collection with default configuration. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddHttpClientServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + return services.AddHttpClientServices(_ => { }); + } + + /// + /// Adds HTTP client services to the service collection with custom configuration. + /// + /// The service collection. + /// Action to configure HttpClient options. + /// The service collection for chaining. + public static IServiceCollection AddHttpClientServices(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + // Configure options with environment variables + services.Configure(options => + { + // Read proxy configuration from environment variables + options.AllProxy = Environment.GetEnvironmentVariable("ALL_PROXY"); + options.HttpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY"); + options.HttpsProxy = Environment.GetEnvironmentVariable("HTTPS_PROXY"); + options.NoProxy = Environment.GetEnvironmentVariable("NO_PROXY"); + + // Apply custom configuration + configureOptions(options); + }); + + // Register the HTTP client service + services.AddSingleton(); + + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs new file mode 100644 index 0000000000..657036bc05 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Models.Elicitation; +using static ModelContextProtocol.Protocol.ElicitRequestParams; + +namespace Azure.Mcp.Core.Extensions; + +/// +/// Extension methods for MCP server to support elicitation functionality. +/// +public static class McpServerElicitationExtensions +{ + /// + /// Sends an elicitation request to gather user input with validation and error handling. + /// + /// The MCP server instance. + /// The elicitation request parameters. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous elicitation operation. + public static async Task RequestElicitationAsync( + this McpServer server, + ElicitationRequestParams request, + CancellationToken cancellationToken = default) + { + if (server == null) + throw new ArgumentNullException(nameof(server)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.Message)) + throw new ArgumentException("Message cannot be null or empty.", nameof(request)); + + // Check if client supports elicitation + if (server.ClientCapabilities?.Elicitation == null) + { + throw new NotSupportedException("Client does not support elicitation. Elicitation capability is required."); + } + + // Create the proper MCP protocol elicitation request + var protocolRequest = new ModelContextProtocol.Protocol.ElicitRequestParams + { + Message = request.Message + }; + + // Send the real elicitation request through the MCP SDK + var protocolResponse = await server.ElicitAsync(protocolRequest, cancellationToken); + + // Convert protocol response to our ElicitationResponse + var elicitationResponse = new ElicitationResponse + { + Action = protocolResponse.Action == "accept" ? ElicitationAction.Accept : ElicitationAction.Decline + }; + + return elicitationResponse; + } + + /// + /// Checks if the client supports elicitation. + /// + /// The MCP server instance. + /// True if the client supports elicitation, false otherwise. + public static bool SupportsElicitation(this McpServer server) + { + return server?.ClientCapabilities?.Elicitation != null; + } + + /// + /// Checks if elicitation should be triggered for a tool based on its metadata. + /// + /// The MCP server instance. + /// The name of the tool. + /// The tool metadata to check. + /// True if elicitation should be triggered, false otherwise. + public static bool ShouldTriggerElicitation(this McpServer server, string toolName, object? toolMetadata) + { + if (!server.SupportsElicitation()) + { + return false; + } + + // Check if the metadata indicates this is a secret/sensitive tool + if (toolMetadata is JsonObject jsonMetadata) + { + return jsonMetadata.TryGetPropertyValue("Secret", out var secretValue) && + secretValue is JsonValue jsonValue && + jsonValue.TryGetValue(out bool isSecret) && + isSecret; + } + + return false; + } +} diff --git a/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs new file mode 100644 index 0000000000..f8956c647f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Runtime.InteropServices; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Azure.Mcp.Core.Extensions; + +public static class OpenTelemetryExtensions +{ + /// + /// The App Insights connection string to send telemetry to Microsoft. + /// + private const string MicrosoftOwnedAppInsightsConnectionString = "InstrumentationKey=21e003c0-efee-4d3f-8a98-1868515aa2c9;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/;LiveEndpoint=https://centralus.livediagnostics.monitor.azure.com/;ApplicationId=f14f6a2d-6405-4f88-bd58-056f25fe274f"; + + public static void ConfigureOpenTelemetry(this IServiceCollection services) + { + services.AddOptions() + .Configure>((options, serviceStartOptions) => + { + // Assembly.GetEntryAssembly is used to retrieve the version of the server application as that is + // the assembly that will run the tool calls. + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly != null) + { + options.Version = AssemblyHelper.GetAssemblyVersion(entryAssembly); + } + + // This environment variable can be used to disable telemetry collection entirely. This takes precedence + // over any other settings. + var collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY"); + + options.IsTelemetryEnabled = string.IsNullOrWhiteSpace(collectTelemetry) || (bool.TryParse(collectTelemetry, out var shouldCollect) && shouldCollect); + }); + + services.AddSingleton(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + services.AddSingleton(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + services.AddSingleton(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + EnableAzureMonitor(services); + } + + public static void ConfigureOpenTelemetryLogger(this ILoggingBuilder builder) + { + builder.AddOpenTelemetry(logger => + { + logger.AddProcessor(new TelemetryLogRecordEraser()); + }); + } + + private static void EnableAzureMonitor(this IServiceCollection services) + { +#if DEBUG + services.AddSingleton(sp => + { + var forwarder = new AzureEventSourceLogForwarder(sp.GetRequiredService()); + forwarder.Start(); + return forwarder; + }); +#endif + + services.ConfigureOpenTelemetryTracerProvider((sp, builder) => + { + var serverConfig = sp.GetRequiredService>(); + if (!serverConfig.Value.IsTelemetryEnabled) + { + return; + } + + builder.AddSource(serverConfig.Value.Name); + }); + + var otelBuilder = services.AddOpenTelemetry() + .ConfigureResource(r => + { + var version = Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(); + + r.AddService("azmcp", version) + .AddTelemetrySdk(); + }); + + var userProvidedAppInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + + if (!string.IsNullOrWhiteSpace(userProvidedAppInsightsConnectionString)) + { + // Configure telemetry to be sent to user-provided Application Insights instance regardless of build configuration. + ConfigureAzureMonitorExporter(otelBuilder, userProvidedAppInsightsConnectionString, "UserProvided"); + } + + // Configure Microsoft-owned telemetry only in RELEASE builds to avoid polluting telemetry during development. +#if RELEASE + // This environment variable can be used to disable Microsoft telemetry collection. + // By default, Microsoft telemetry is enabled. + var microsoftTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY_MICROSOFT"); + + bool shouldCollectMicrosoftTelemetry = string.IsNullOrWhiteSpace(microsoftTelemetry) || (bool.TryParse(microsoftTelemetry, out var shouldCollect) && shouldCollect); + + if (shouldCollectMicrosoftTelemetry) + { + ConfigureAzureMonitorExporter(otelBuilder, MicrosoftOwnedAppInsightsConnectionString, "Microsoft"); + } +#endif + + var enableOtlp = Environment.GetEnvironmentVariable("AZURE_MCP_ENABLE_OTLP_EXPORTER"); + if (!string.IsNullOrEmpty(enableOtlp) && bool.TryParse(enableOtlp, out var shouldEnable) && shouldEnable) + { + otelBuilder.WithTracing(tracing => tracing.AddOtlpExporter()) + .WithMetrics(metrics => metrics.AddOtlpExporter()) + .WithLogging(logging => logging.AddOtlpExporter()); + } + } + + private static void ConfigureAzureMonitorExporter(OpenTelemetry.OpenTelemetryBuilder otelBuilder, string appInsightsConnectionString, string name) + { + otelBuilder.WithLogging(logging => + { + logging.AddAzureMonitorLogExporter(options => + { + options.ConnectionString = appInsightsConnectionString; + }, + name: name); + }); + + otelBuilder.WithMetrics(metrics => + { + metrics.AddAzureMonitorMetricExporter(options => + { + options.ConnectionString = appInsightsConnectionString; + }, + name: name); + }); + + otelBuilder.WithTracing(tracing => + { + tracing.AddAzureMonitorTraceExporter(options => + { + options.ConnectionString = appInsightsConnectionString; + }, + name: name); + }); + } +} diff --git a/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs new file mode 100644 index 0000000000..8eb5e63551 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Extensions; + +public static class ParseResultExtensions +{ + public static bool TryGetValue(this ParseResult parseResult, Option option, out T? value) + => parseResult.CommandResult.TryGetValue(option, out value); + + public static bool TryGetValue(this ParseResult parseResult, string optionName, out T? value) + { + // Find the option by name in the command + var command = parseResult.CommandResult.Command; + var option = command.Options.OfType>() + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + + if (option != null) + { + return parseResult.CommandResult.TryGetValue(option, out value); + } + + value = default; + return false; + } + + public static T? GetValueOrDefault(this ParseResult parseResult, Option option) + => parseResult.CommandResult.GetValueOrDefault(option); + + /// + /// Gets the value of an option by name, returning default if not found or not set + /// + public static T? GetValueOrDefault(this ParseResult parseResult, string optionName) + { + // Find the option by name in the command + var command = parseResult.CommandResult.Command; + var option = command.Options.OfType>() + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + + return option != null ? parseResult.GetValueOrDefault(option) : default; + } +} diff --git a/core/Azure.Mcp.Core/src/Models/AuthMethod.cs b/core/Azure.Mcp.Core/src/Models/AuthMethod.cs new file mode 100644 index 0000000000..cd07603836 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/AuthMethod.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models; + +public enum AuthMethod +{ + Credential, + Key, + ConnectionString +} diff --git a/core/Azure.Mcp.Core/src/Models/AzureCredentials.cs b/core/Azure.Mcp.Core/src/Models/AzureCredentials.cs new file mode 100644 index 0000000000..d4f096ab6f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/AzureCredentials.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Models; + +public record AzureCredentials( + [property: JsonPropertyName("clientId")] string ClientId, + [property: JsonPropertyName("clientSecret")] string ClientSecret, + [property: JsonPropertyName("tenantId")] string TenantId, + [property: JsonPropertyName("subscriptionId")] string? SubscriptionId = null +); diff --git a/core/Azure.Mcp.Core/src/Models/Command/CommandContext.cs b/core/Azure.Mcp.Core/src/Models/Command/CommandContext.cs new file mode 100644 index 0000000000..2c3322f8a6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Command/CommandContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Net; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Models.Command; + +/// +/// Provides context for command execution including service access and response management +/// +public class CommandContext +{ + /// + /// The service provider for dependency injection + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// The response object that will be returned to the client + /// + public CommandResponse Response { get; } + + /// + /// Current telemetry context if there is one available. + /// + public Activity? Activity { get; } + + /// + /// Creates a new command context + /// + /// The service provider for dependency injection + public CommandContext(IServiceProvider serviceProvider, Activity? activity = default) + { + _serviceProvider = serviceProvider; + Activity = activity; + Response = new CommandResponse + { + Status = HttpStatusCode.OK, + Message = "Success" + }; + } + + /// + /// Gets a required service from the service provider + /// + /// The type of service to retrieve + /// The requested service instance + /// Thrown if the service is not registered + public T GetService() where T : class + { + return _serviceProvider.GetRequiredService(); + } +} diff --git a/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs b/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs new file mode 100644 index 0000000000..3bd997e8ad --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Models.Command; + +public class CommandInfo +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("command")] + public string Command { get; set; } = string.Empty; + + [JsonPropertyName("subcommands")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Subcommands { get; set; } + + [JsonPropertyName("option")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Options { get; set; } + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolMetadata? Metadata { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Models/Command/CommandResponse.cs b/core/Azure.Mcp.Core/src/Models/Command/CommandResponse.cs new file mode 100644 index 0000000000..7145a1f546 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Command/CommandResponse.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Azure.Mcp.Core.Models.Command; + +public class CommandResponse +{ + [JsonPropertyName("status")] + [JsonConverter(typeof(HttpStatusCodeConverter))] + public HttpStatusCode Status { get; set; } = HttpStatusCode.OK; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("results")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseResult? Results { get; set; } + + [JsonPropertyName("duration")] + public long Duration { get; set; } +} + +[JsonConverter(typeof(ResultConverter))] +public sealed class ResponseResult +{ + private readonly object? _result; + private readonly JsonTypeInfo _typeInfo; + + private ResponseResult(object? result, JsonTypeInfo typeInfo) + { + _result = result; + _typeInfo = typeInfo; + } + + public static ResponseResult Create(T result, JsonTypeInfo typeInfo) + { + return new ResponseResult(result, typeInfo); + } + + public void Write(Utf8JsonWriter writer) + { + JsonSerializer.Serialize(writer, _result, _typeInfo); + } +} + +internal class ResultConverter : JsonConverter +{ + public override ResponseResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Can't deserialize an object without knowing its type. + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, ResponseResult? value, JsonSerializerOptions options) + { + value?.Write(writer); + } +} + +internal class HttpStatusCodeConverter : JsonConverter +{ + public override HttpStatusCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return (HttpStatusCode)reader.GetInt32(); + } + throw new JsonException("Expected a number for HttpStatusCode."); + } + + public override void Write(Utf8JsonWriter writer, HttpStatusCode value, JsonSerializerOptions options) + { + writer.WriteNumberValue((int)value); + } +} diff --git a/core/Azure.Mcp.Core/src/Models/ETag.cs b/core/Azure.Mcp.Core/src/Models/ETag.cs new file mode 100644 index 0000000000..7de5b1a47b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/ETag.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models; + +public class ETag +{ + public string Value { get; set; } = string.Empty; + public DateTime LastModified { get; set; } = DateTime.UtcNow; +} diff --git a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs new file mode 100644 index 0000000000..8db47857c6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Models.Elicitation; + +/// +/// Parameters for an elicitation/create request to gather user input. +/// +public sealed class ElicitationRequestParams +{ + /// + /// Gets or sets the message to display to the user requesting information. + /// + [JsonPropertyName("message")] + public required string Message { get; set; } + + /// + /// Gets or sets the JSON schema defining the structure of the expected response. + /// This property is optional and currently not used by the elicitation implementation. + /// + [JsonPropertyName("requestedSchema")] + public JsonObject? RequestedSchema { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationResponse.cs b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationResponse.cs new file mode 100644 index 0000000000..e3bc584025 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationResponse.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Models.Elicitation; + +/// +/// Response from an elicitation/create request containing the user's action and data. +/// +public sealed class ElicitationResponse +{ + /// + /// Gets or sets the action taken by the user. + /// + [JsonPropertyName("action")] + public required ElicitationAction Action { get; set; } + + /// + /// Gets or sets the user-provided content when action is "accept". + /// + [JsonPropertyName("content")] + public JsonObject? Content { get; set; } +} + +/// +/// The action taken by the user in response to an elicitation request. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ElicitationAction +{ + /// + /// User explicitly approved and submitted with data. + /// + Accept, + + /// + /// User explicitly declined the request. + /// + Decline, + + /// + /// User dismissed without making an explicit choice. + /// + Cancel +} diff --git a/core/Azure.Mcp.Core/src/Models/HiddenCommandAttribute.cs b/core/Azure.Mcp.Core/src/Models/HiddenCommandAttribute.cs new file mode 100644 index 0000000000..cd319ee400 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/HiddenCommandAttribute.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models; + +[AttributeUsage(AttributeTargets.Class)] +public class HiddenCommandAttribute : Attribute +{ +} diff --git a/core/Azure.Mcp.Core/src/Models/Identity/ManagedIdentity.cs b/core/Azure.Mcp.Core/src/Models/Identity/ManagedIdentity.cs new file mode 100644 index 0000000000..9e10a84645 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Identity/ManagedIdentity.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models.Identity; + +public class ManagedIdentityInfo +{ + public SystemAssignedIdentityInfo? SystemAssignedIdentity { get; set; } + public UserAssignedIdentityInfo[]? UserAssignedIdentities { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Models/Identity/SystemAssignedIdentityInfo.cs b/core/Azure.Mcp.Core/src/Models/Identity/SystemAssignedIdentityInfo.cs new file mode 100644 index 0000000000..368cf16650 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Identity/SystemAssignedIdentityInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models.Identity; + +public class SystemAssignedIdentityInfo +{ + public bool Enabled { get; set; } + public string? TenantId { get; set; } + public string? PrincipalId { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Models/Identity/UserAssignedIdentityInfo.cs b/core/Azure.Mcp.Core/src/Models/Identity/UserAssignedIdentityInfo.cs new file mode 100644 index 0000000000..5e1d5c51a6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Identity/UserAssignedIdentityInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models.Identity; + +public class UserAssignedIdentityInfo +{ + public string? ClientId { get; set; } + public string? PrincipalId { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs b/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs new file mode 100644 index 0000000000..51b573bc58 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Models.Metadata; + +public sealed class MetadataDefinition +{ + /// + /// Gets or sets the boolean value of the metadata property. + /// + [JsonPropertyName("value")] + public bool Value { get; init; } + + /// + /// Gets or sets the description of what the metadata means. + /// + [JsonPropertyName("description")] + public string Description { get; init; } = string.Empty; +} diff --git a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs new file mode 100644 index 0000000000..b6b5204e37 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Elicitation; + +namespace Azure.Mcp.Core.Models; + +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(CommandResponse))] +[JsonSerializable(typeof(ETag), TypeInfoPropertyName = "McpETag")] +[JsonSerializable(typeof(ToolMetadata))] +[JsonSerializable(typeof(ToolsListCommand.ToolNamesResult))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class ModelsJsonContext : JsonSerializerContext +{ + // This class is intentionally left empty. It is used for source generation of JSON serialization. +} diff --git a/core/Azure.Mcp.Core/src/Models/Option/OptionDefinitions.cs b/core/Azure.Mcp.Core/src/Models/Option/OptionDefinitions.cs new file mode 100644 index 0000000000..803fc0322a --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Option/OptionDefinitions.cs @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Azure.Mcp.Core.Models.Option; + +public static partial class OptionDefinitions +{ + public static class Common + { + public const string TenantName = "tenant"; + public const string SubscriptionName = "subscription"; + public const string ResourceGroupName = "resource-group"; + public const string AuthMethodName = "auth-method"; + + public static readonly Option Tenant = new( + $"--{TenantName}" + ) + { + Required = false, + Description = "The Microsoft Entra ID tenant ID or name. This can be either the GUID identifier or the display name of your Entra ID tenant." + }; + + public static readonly Option Subscription = new( + $"--{SubscriptionName}" + ) + { + Description = "Specifies the Azure subscription to use. Accepts either a subscription ID (GUID) or display name. " + + "If not specified, the AZURE_SUBSCRIPTION_ID environment variable will be used instead.", + Required = false + }; + + public static readonly Option AuthMethod = new( + $"--{AuthMethodName}" + ) + { + Description = "Authentication method to use. Options: 'credential' (Azure CLI/managed identity), 'key' (access key), or 'connectionString'.", + Required = false + }; + + public static readonly Option ResourceGroup = new( + $"--{ResourceGroupName}" + ) + { + Description = "The name of the Azure resource group. This is a logical container for Azure resources.", + Required = false + }; + } + + public static class RetryPolicy + { + public const string DelayName = "retry-delay"; + public const string MaxDelayName = "retry-max-delay"; + public const string MaxRetriesName = "retry-max-retries"; + public const string ModeName = "retry-mode"; + public const string NetworkTimeoutName = "retry-network-timeout"; + + public static readonly Option Delay = new( + $"--{DelayName}" + ) + { + Description = "Initial delay in seconds between retry attempts. For exponential backoff, this value is used as the base.", + Required = false + }; + + public static readonly Option MaxDelay = new( + $"--{MaxDelayName}" + ) + { + Description = "Maximum delay in seconds between retries, regardless of the retry strategy.", + Required = false + }; + + public static readonly Option MaxRetries = new( + $"--{MaxRetriesName}" + ) + { + Description = "Maximum number of retry attempts for failed operations before giving up.", + Required = false + }; + + public static readonly Option Mode = new( + $"--{ModeName}" + ) + { + Description = "Retry strategy to use. 'fixed' uses consistent delays, 'exponential' increases delay between attempts.", + Required = false + }; + + public static readonly Option NetworkTimeout = new( + $"--{NetworkTimeoutName}" + ) + { + Description = "Network operation timeout in seconds. Operations taking longer than this will be cancelled.", + Required = false + }; + } + + public static class Authorization + { + public const string ScopeName = "scope"; + + public static readonly Option Scope = new( + $"--{ScopeName}" + ) + { + Description = "Scope at which the role assignment or definition applies to, e.g., /subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333, /subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333/resourceGroups/myGroup, or /subscriptions/0b1f6471-1bf0-4dda-aec3-111122223333/resourceGroups/myGroup/providers/Microsoft.Compute/virtualMachines/myVM.", + Required = true, + }; + } + + public static class LoadTesting + { + public const string TestResourceName = "test-resource-name"; + public const string TestRunId = "testrun-id"; + public const string TestId = "test-id"; + public const string DisplayNameOption = "display-name"; + public const string DescriptionOption = "description"; + public const string OldTestRunIdOption = "old-testrun-id"; + public const string VirtualUsersOption = "virtual-users"; + public const string DurationOption = "duration"; + public const string RampUpTimeOption = "ramp-up-time"; + public const string EndpointOption = "endpoint"; + public static readonly Option TestResource = new( + $"--{TestResourceName}" + ) + { + Description = "The name of the load test resource for which you want to fetch the details.", + Required = false + }; + public static readonly Option TestRun = new( + $"--{TestRunId}" + ) + { + Description = "The ID of the load test run for which you want to fetch the details.", + Required = false + }; + public static readonly Option Test = new( + $"--{TestId}" + ) + { + Description = "The ID of the load test for which you want to fetch the details.", + Required = true + }; + public static readonly Option DisplayName = new( + $"--{DisplayNameOption}" + ) + { + Description = "The display name for the load test run. This is a user-friendly name to identify the test run.", + Required = false + }; + public static readonly Option Description = new( + $"--{DescriptionOption}" + ) + { + Description = "The description for the load test run. This provides additional context about the test run.", + Required = false + }; + public static readonly Option OldTestRunId = new( + $"--{OldTestRunIdOption}" + ) + { + Description = "The ID of an existing test run to update. If provided, the command will trigger a rerun of the given test run id.", + Required = false + }; + + public static readonly Option VirtualUsers = new( + $"--{VirtualUsersOption}" + ) + { + Description = "Virtual users is a measure of load that is simulated to test the HTTP endpoint. (Default - 50)", + Required = false + }; + public static readonly Option Duration = new( + $"--{DurationOption}" + ) + { + Description = "This is the duration for which the load is simulated against the endpoint. Enter decimals for fractional minutes (e.g., 1.5 for 1 minute and 30 seconds). Default is 20 mins", + Required = false + }; + public static readonly Option RampUpTime = new( + $"--{RampUpTimeOption}" + ) + { + Description = "The ramp-up time is the time it takes for the system to ramp-up to the total load specified. Enter decimals for fractional minutes (e.g., 1.5 for 1 minute and 30 seconds). Default is 1 min", + Required = false + }; + public static readonly Option Endpoint = new( + $"--{EndpointOption}" + ) + { + Description = "The endpoint URL to be tested. This is the URL of the HTTP endpoint that will be subjected to load testing.", + Required = false + }; + } + + public static class Marketplace + { + public const string ProductIdName = "product-id"; + public const string IncludeStopSoldPlansName = "include-stop-sold-plans"; + public const string LanguageName = "language"; + public const string MarketName = "market"; + public const string LookupOfferInTenantLevelName = "lookup-offer-in-tenant-level"; + public const string PlanIdName = "plan-id"; + public const string SkuIdName = "sku-id"; + public const string IncludeServiceInstructionTemplatesName = "include-service-instruction-templates"; + public const string PricingAudienceName = "pricing-audience"; + public const string SearchName = "search"; + public const string FilterName = "filter"; + public const string OrderByName = "orderby"; + public const string SelectName = "select"; + public const string ExpandName = "expand"; + public const string NextCursorName = "next-cursor"; + + public static readonly Option ProductId = new( + $"--{ProductIdName}" + ) + { + Description = "The ID of the marketplace product to retrieve. This is the unique identifier for the product in the Azure Marketplace.", + Required = true + }; + + public static readonly Option IncludeStopSoldPlans = new( + $"--{IncludeStopSoldPlansName}" + ) + { + Description = "Include stop-sold or hidden plans in the response.", + Required = false + }; + + public static readonly Option Language = new( + $"--{LanguageName}" + ) + { + Description = "Product language code (e.g., 'en' for English, 'fr' for French).", + Required = false + }; + + public static readonly Option Market = new( + $"--{MarketName}" + ) + { + Description = "Product market code (e.g., 'US' for United States, 'UK' for United Kingdom).", + Required = false + }; + + public static readonly Option LookupOfferInTenantLevel = new( + $"--{LookupOfferInTenantLevelName}" + ) + { + Description = "Check against tenant private audience when retrieving the product.", + Required = false + }; + + public static readonly Option PlanId = new( + $"--{PlanIdName}" + ) + { + Description = "Filter results by a specific plan ID.", + Required = false + }; + + public static readonly Option SkuId = new( + $"--{SkuIdName}" + ) + { + Description = "Filter results by a specific SKU ID.", + Required = false + }; + + public static readonly Option IncludeServiceInstructionTemplates = new( + $"--{IncludeServiceInstructionTemplatesName}" + ) + { + Description = "Include service instruction templates in the response.", + Required = false + }; + + public static readonly Option PricingAudience = new( + $"--{PricingAudienceName}" + ) + { + Description = "Pricing audience for the request header.", + Required = false + }; + + public static readonly Option Search = new( + $"--{SearchName}" + ) + { + Description = "Search for products using a short general term (up to 25 characters)", + Required = false + }; + + public static readonly Option Filter = new( + $"--{FilterName}" + ) + { + Description = "OData filter expression to filter results based on ProductSummary properties (e.g., \"displayName eq 'Azure'\").", + Required = false + }; + + public static readonly Option OrderBy = new( + $"--{OrderByName}" + ) + { + Description = "OData orderby expression to sort results by ProductSummary fields (e.g., \"displayName asc\" or \"popularity desc\").", + Required = false + }; + + public static readonly Option Select = new( + $"--{SelectName}" + ) + { + Description = "OData select expression to choose specific ProductSummary fields to return (e.g., \"displayName,publisherDisplayName,uniqueProductId\").", + Required = false + }; + + public static readonly Option NextCursor = new( + $"--{NextCursorName}" + ) + { + Description = "Pagination cursor to retrieve the next page of results. Use the NextPageLink value from a previous response.", + Required = false + }; + + public static readonly Option Expand = new( + $"--{ExpandName}" + ) + { + Description = "OData expand expression to include related data in the response (e.g., \"plans\" to include plan details).", + Required = false + }; + + } +} diff --git a/core/Azure.Mcp.Core/src/Models/ResourceGroup/ResourceGroupInfo.cs b/core/Azure.Mcp.Core/src/Models/ResourceGroup/ResourceGroupInfo.cs new file mode 100644 index 0000000000..08d204ddaa --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/ResourceGroup/ResourceGroupInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models.ResourceGroup; + +public class ResourceGroupInfo(string name, string id, string location) +{ + public string Name { get; set; } = name; + public string Id { get; set; } = id; + public string Location { get; set; } = location; +} From 8d7390baf5a595a1c480303cd3e296a2a8b9601e Mon Sep 17 00:00:00 2001 From: Iris20050110 Date: Thu, 16 Jul 2026 11:47:04 -0700 Subject: [PATCH 6/6] Import core framework updates --- core/Azure.Mcp.Core/src/AssemblyInfo.cs | 5 +- core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj | 16 +- .../src/Commands/BaseCommand.cs | 144 +++ .../src/Commands/CommandExtensions.cs | 157 +++ .../src/Commands/CommandFactory.cs | 428 ++++++++ .../src/Commands/CommandGroup.cs | 62 ++ .../src/Commands/EmptyOptions.cs | 11 + .../src/Commands/GlobalCommand.cs | 126 +++ .../src/Commands/IBaseCommand.cs | 53 + .../Commands/JsonSourceGenerationContext.cs | 19 + .../Subscription/SubscriptionCommand.cs | 11 +- .../src/Commands/ToolMetadata.cs | 239 +++++ .../src/Commands/ToolMetadataJsonConverter.cs | 60 ++ .../src/Commands/TrimAnnotations.cs | 13 + .../src/Commands/ValidationResult.cs | 11 + .../src/Commands/VersionDisplayHelpAction.cs | 31 + .../AzureMcpServerConfiguration.cs | 15 + .../Exceptions/CommandValidationException.cs | 39 + core/Azure.Mcp.Core/src/GlobalUsings.cs | 4 + .../src/Helpers/AssemblyHelper.cs | 40 + .../src/Helpers/CollectionTypeHelper.cs | 66 ++ .../src/Helpers/CommandHelper.cs | 36 + .../src/Helpers/EmbeddedResourceHelper.cs | 61 ++ .../src/Helpers/EnvironmentVariableHelpers.cs | 41 + .../src/Helpers/NameNormalization.cs | 18 + .../src/Helpers/OptionParsingHelpers.cs | 40 + .../src/Options/AuthMethodOptions.cs | 32 + .../src/Options/GlobalOptions.cs | 21 + .../src/Options/ParseResultExtensions.cs | 50 + .../src/Options/RetryPolicyOptions.cs | 129 +++ .../src/Options/SubscriptionOptions.cs | 13 + ...thenticationServiceCollectionExtensions.cs | 69 ++ .../Authentication/AuthenticationUtils.cs | 42 + .../Authentication/CustomChainedCredential.cs | 418 ++++++++ .../HttpOnBehalfOfTokenCredentialProvider.cs | 61 ++ .../IAzureTokenCredentialProvider.cs | 56 ++ .../Authentication/PlaybackTokenCredential.cs | 22 + .../SingleIdentityTokenCredentialProvider.cs | 56 ++ .../Authentication/TimeoutTokenCredential.cs | 59 ++ .../Authentication/WindowHandleProvider.cs | 64 ++ .../Azure/BaseAzureResourceService.cs | 110 +- .../src/Services/Azure/BaseAzureService.cs | 127 +-- .../Azure/Models/ManagedServiceIdentity.cs | 31 + .../src/Services/Azure/Models/ResourceSku.cs | 17 + .../ResourceGroup/IResourceGroupService.cs | 6 +- .../ResourceGroup/ResourceGroupService.cs | 97 +- .../Subscription/ISubscriptionService.cs | 9 +- .../Azure/Subscription/SubscriptionService.cs | 43 +- .../Services/Azure/Tenant/ITenantService.cs | 7 +- .../Services/Azure/Tenant/TenantService.cs | 41 +- .../TenantServiceCollectionExtensions.cs | 11 +- .../src/Services/Azure/UserAgentPolicy.cs | 28 + .../CachingServiceCollectionExtensions.cs | 57 ++ .../Caching/HttpServiceCacheService.cs | 50 + .../src/Services/Caching/ICacheService.cs | 62 ++ .../Caching/SingleUserCliCacheService.cs | 119 +++ .../Http/HttpClientFactoryConfigurator.cs | 152 +++ .../src/Services/Http/HttpClientOptions.cs | 43 + .../src/Services/Http/HttpClientService.cs | 195 ++++ .../src/Services/Http/IHttpClientService.cs | 30 + .../Services/Http/RecordingRedirectHandler.cs | 58 ++ .../ExternalProcessService.cs | 732 ++++++++++++++ .../IExternalProcessService.cs | 46 + .../src/Services/ServicesJsonContext.cs | 15 + .../DefaultMachineInformationProvider.cs | 19 + .../Telemetry/IMachineInformationProvider.cs | 18 + .../Services/Telemetry/ITelemetryService.cs | 33 + .../LinuxMachineInformationProvider.cs | 41 + .../MacOSXMachineInformationProvider.cs | 43 + .../MachineInformationProviderBase.cs | 86 ++ .../Services/Telemetry/TelemetryConstants.cs | 46 + .../Telemetry/TelemetryLogRecordEraser.cs | 23 + .../Services/Telemetry/TelemetryService.cs | 189 ++++ .../UnixMachineInformationProvider.cs | 117 +++ .../WindowsMachineInformationProvider.cs | 94 ++ .../src/Services/Time/DateTimeProvider.cs | 9 + .../src/Services/Time/IDateTimeProvider.cs | 12 + .../src/TestUtilities/ArgBuilder.cs | 21 + .../src/TestUtilities/ArgSplitter.cs | 62 ++ .../Areas/Server/ServerCommandTests.cs | 791 +++++++++++++++ .../Azure.Mcp.Core.LiveTests.csproj | 18 + .../ClientToolTests.cs | 125 +++ .../Azure.Mcp.Core.LiveTests/CommandTests.cs | 39 + .../Azure.Mcp.Core.LiveTests/NpxTests.cs | 81 ++ .../RecordedCommandTestHarness.cs | 65 ++ .../RecordedCommandTestsBaseTests.cs | 182 ++++ .../AuthenticationIntegrationTests.cs | 105 ++ .../LinuxInformationProviderTests.cs | 38 + .../MacOSXInformationProviderTests.cs | 38 + .../WindowsInformationProviderTests.cs | 38 + .../Group/UnitTests/GroupListCommandTests.cs | 168 ++++ .../ArrayOrCollectionElementTypeTests.cs | 122 +++ .../Areas/Server/CommandFactoryHelpers.cs | 185 ++++ .../Discovery/BaseDiscoveryStrategyTests.cs | 435 ++++++++ .../CommandGroupDiscoveryStrategyTests.cs | 593 +++++++++++ .../CommandGroupServerProviderTests.cs | 166 ++++ .../CompositeDiscoveryStrategyTests.cs | 324 ++++++ .../ConsolidatedToolDiscoveryStrategyTests.cs | 117 +++ .../RegistryDiscoveryStrategyTests.cs | 440 ++++++++ .../Discovery/RegistryServerProviderTests.cs | 306 ++++++ .../Commands/Runtime/McpRuntimeTests.cs | 829 +++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 398 ++++++++ .../Commands/ServiceInfoCommandTests.cs | 58 ++ .../ToolLoading/BaseToolLoaderTests.cs | 321 ++++++ .../CommandFactoryToolLoaderTests.cs | 940 ++++++++++++++++++ .../ToolLoading/CompositeToolLoaderTests.cs | 645 ++++++++++++ .../ToolLoading/NamespaceToolLoaderTests.cs | 624 ++++++++++++ .../ToolLoading/RegistryToolLoaderTests.cs | 506 ++++++++++ .../ToolLoading/ServerToolLoaderTests.cs | 138 +++ .../ToolLoading/SingleProxyToolLoaderTests.cs | 285 ++++++ .../Server/Helpers/MockMcpClientBuilder.cs | 242 +++++ .../MockMcpDiscoveryStrategyBuilder.cs | 162 +++ .../Server/McpServerToolAttributeTests.cs | 72 ++ .../Areas/Server/Models/RegistryRootTests.cs | 279 ++++++ .../Areas/Server/OptionTypeTests.cs | 444 +++++++++ .../Areas/Server/ServiceStartCommandTests.cs | 786 +++++++++++++++ .../Areas/Server/TypeToJsonTypeMapperTests.cs | 302 ++++++ .../Subscription/SubscriptionCommandTests.cs | 131 +++ .../SubscriptionListCommandTests.cs | 171 ++++ .../Subscription/SubscriptionTestHelpers.cs | 64 ++ .../Tools/UnitTests/ToolsListCommandTests.cs | 868 ++++++++++++++++ .../AssemblyAttributes.cs | 2 + .../Azure.Mcp.Core.UnitTests.csproj | 18 + .../Client/MockClientTests.cs | 258 +++++ .../Commands/CommandFactoryTests.cs | 312 ++++++ .../Commands/DashSupportTests.cs | 109 ++ .../Extensions/CommandExtensionsTests.cs | 389 ++++++++ .../CommandResultExtensionsTests.cs | 506 ++++++++++ ...pClientServiceCollectionExtensionsTests.cs | 81 ++ .../McpServerElicitationExtensionsTests.cs | 218 ++++ .../Helpers/CollectionTypeHelperTests.cs | 105 ++ .../Helpers/CommandHelperTests.cs | 114 +++ .../Helpers/OptionParsingHelpersTests.cs | 386 +++++++ .../Infrastructure/VersionSyncTests.cs | 112 +++ .../Options/RetryPolicyOptionsTests.cs | 82 ++ .../CustomChainedCredentialTests.cs | 268 +++++ .../Services/Azure/BaseAzureServiceTests.cs | 170 ++++ .../Services/Azure/UserAgentPolicyTests.cs | 129 +++ .../Services/Caching/CacheServiceTests.cs | 175 ++++ .../Http/HttpClientServiceIntegrationTests.cs | 91 ++ .../Services/Http/HttpClientServiceTests.cs | 156 +++ .../DefaultMachineInformationProviderTests.cs | 23 + .../MachineInformationProviderBaseTests.cs | 230 +++++ .../TelemetryLogRecordEraserTests.cs | 247 +++++ .../Telemetry/TelemetryServiceTests.cs | 331 ++++++ .../Telemetry/UnixInformationProviderTests.cs | 212 ++++ .../Attributes/CustomMatcherAttribute.cs | 51 + .../Azure.Mcp.Tests/Azure.Mcp.Tests.csproj | 3 +- .../Client/CommandTestsBase.cs | 241 +++++ .../Client/Helpers/BinaryContentHelper.cs | 35 + .../Client/Helpers/CustomTestTransport.cs | 67 ++ .../Client/Helpers/LiveTestSettings.cs | 24 + .../Client/Helpers/LiveTestSettingsFixture.cs | 77 ++ .../Client/Helpers/McpTestUtilities.cs | 39 + .../Client/Helpers/RecordingPathResolver.cs | 138 +++ .../Client/Helpers/TestProxyFixture.cs | 55 + .../Client/RecordedCommandTestsBase.cs | 403 ++++++++ .../tests/Azure.Mcp.Tests/Client/TestProxy.cs | 347 +++++++ .../Generated/Internal/Argument.cs | 74 ++ .../Generated/Internal/BinaryContentHelper.cs | 144 +++ .../Internal/ChangeTrackingDictionary.cs | 189 ++++ .../Generated/Internal/ChangeTrackingList.cs | 168 ++++ .../Internal/ClientPipelineExtensions.cs | 70 ++ .../Generated/Internal/ClientUriBuilder.cs | 134 +++ .../Internal/CodeGenMemberAttribute.cs | 20 + .../Internal/CodeGenSerializationAttribute.cs | 48 + .../Internal/CodeGenSuppressAttribute.cs | 29 + .../Internal/CodeGenTypeAttribute.cs | 24 + .../Generated/Internal/ErrorResult.cs | 27 + .../Internal/ModelSerializationExtensions.cs | 258 +++++ .../Generated/Internal/Optional.cs | 51 + .../Generated/Internal/TypeFormatters.cs | 152 +++ .../Internal/Utf8JsonBinaryContent.cs | 61 ++ ...oftClientModelTestFrameworkModelFactory.cs | 363 +++++++ .../Models/ApplyCondition.Serialization.cs | 144 +++ .../Generated/Models/ApplyCondition.cs | 42 + .../Models/BodyKeySanitizer.Serialization.cs | 136 +++ .../Generated/Models/BodyKeySanitizer.cs | 39 + .../BodyKeySanitizerBody.Serialization.cs | 198 ++++ .../Generated/Models/BodyKeySanitizerBody.cs | 63 ++ .../BodyRegexSanitizer.Serialization.cs | 136 +++ .../Generated/Models/BodyRegexSanitizer.cs | 40 + .../BodyRegexSanitizerBody.Serialization.cs | 179 ++++ .../Models/BodyRegexSanitizerBody.cs | 51 + .../BodyStringSanitizer.Serialization.cs | 136 +++ .../Generated/Models/BodyStringSanitizer.cs | 40 + .../BodyStringSanitizerBody.Serialization.cs | 170 ++++ .../Models/BodyStringSanitizerBody.cs | 53 + .../CustomDefaultMatcher.Serialization.cs | 211 ++++ .../Generated/Models/CustomDefaultMatcher.cs | 56 ++ .../GeneralRegexSanitizer.Serialization.cs | 136 +++ .../Generated/Models/GeneralRegexSanitizer.cs | 40 + ...GeneralRegexSanitizerBody.Serialization.cs | 179 ++++ .../Models/GeneralRegexSanitizerBody.cs | 51 + .../GeneralStringSanitizer.Serialization.cs | 136 +++ .../Models/GeneralStringSanitizer.cs | 40 + ...eneralStringSanitizerBody.Serialization.cs | 170 ++++ .../Models/GeneralStringSanitizerBody.cs | 53 + .../HeaderRegexSanitizer.Serialization.cs | 136 +++ .../Generated/Models/HeaderRegexSanitizer.cs | 40 + .../HeaderRegexSanitizerBody.Serialization.cs | 198 ++++ .../Models/HeaderRegexSanitizerBody.cs | 63 ++ .../HeaderStringSanitizer.Serialization.cs | 136 +++ .../Generated/Models/HeaderStringSanitizer.cs | 40 + ...HeaderStringSanitizerBody.Serialization.cs | 178 ++++ .../Models/HeaderStringSanitizerBody.cs | 61 ++ .../Models/MatcherType.Serialization.cs | 41 + .../Generated/Models/MatcherType.cs | 20 + ...icrosoftClientModelTestFrameworkContext.cs | 55 + .../OAuthResponseSanitizer.Serialization.cs | 123 +++ .../Models/OAuthResponseSanitizer.cs | 28 + .../Models/RecordingOptions.Serialization.cs | 198 ++++ .../Generated/Models/RecordingOptions.cs | 51 + .../RegexEntrySanitizer.Serialization.cs | 136 +++ .../Generated/Models/RegexEntrySanitizer.cs | 40 + .../RegexEntrySanitizerBody.Serialization.cs | 152 +++ .../Models/RegexEntrySanitizerBody.cs | 50 + .../Models/RegexEntryValues.Serialization.cs | 41 + .../Generated/Models/RegexEntryValues.cs | 20 + .../RemoveHeaderSanitizer.Serialization.cs | 136 +++ .../Generated/Models/RemoveHeaderSanitizer.cs | 40 + ...RemoveHeaderSanitizerBody.Serialization.cs | 143 +++ .../Models/RemoveHeaderSanitizerBody.cs | 43 + .../Models/RemovedSanitizers.Serialization.cs | 175 ++++ .../Generated/Models/RemovedSanitizers.cs | 39 + .../Models/SanitizerAddition.Serialization.cs | 165 +++ .../Generated/Models/SanitizerAddition.cs | 41 + .../Models/SanitizerList.Serialization.cs | 177 ++++ .../Generated/Models/SanitizerList.cs | 44 + .../Models/SanitizerType.Serialization.cs | 91 ++ .../Generated/Models/SanitizerType.cs | 40 + .../Models/StoreType.Serialization.cs | 31 + .../Generated/Models/StoreType.cs | 16 + .../TestProxyCertificate.Serialization.cs | 151 +++ .../Generated/Models/TestProxyCertificate.cs | 51 + ...TestProxyStartInformation.Serialization.cs | 166 ++++ .../Models/TestProxyStartInformation.cs | 47 + .../TransportCustomizations.Serialization.cs | 214 ++++ .../Models/TransportCustomizations.cs | 59 ++ .../UnknownSanitizerAddition.Serialization.cs | 127 +++ .../Models/UnknownSanitizerAddition.cs | 22 + .../Models/UriRegexSanitizer.Serialization.cs | 136 +++ .../Generated/Models/UriRegexSanitizer.cs | 40 + .../UriRegexSanitizerBody.Serialization.cs | 179 ++++ .../Generated/Models/UriRegexSanitizerBody.cs | 51 + .../UriStringSanitizer.Serialization.cs | 136 +++ .../Generated/Models/UriStringSanitizer.cs | 40 + .../UriStringSanitizerBody.Serialization.cs | 170 ++++ .../Models/UriStringSanitizerBody.cs | 53 + ...riSubscriptionIdSanitizer.Serialization.cs | 136 +++ .../Models/UriSubscriptionIdSanitizer.cs | 40 + ...bscriptionIdSanitizerBody.Serialization.cs | 156 +++ .../Models/UriSubscriptionIdSanitizerBody.cs | 41 + .../tests/Azure.Mcp.Tests/Generated/README.md | 31 + .../TestProxyAdminClient.RestClient.cs | 93 ++ .../Generated/TestProxyAdminClient.cs | 345 +++++++ .../Generated/TestProxyClient.RestClient.cs | 85 ++ .../Generated/TestProxyClient.cs | 345 +++++++ .../Generated/TestProxyClientOptions.cs | 16 + ...EnvironmentVariablesBeforeTestAttribute.cs | 42 + .../Helpers/ResourceGroupTestHelpers.cs | 2 +- .../Helpers/TestEnvironment.cs | 19 + .../Helpers/TestHttpClientFactoryProvider.cs | 28 + .../tests/Azure.Mcp.Tests/README.md | 4 + .../tests/Azure.Mcp.Tests/TestExtensions.cs | 41 + .../tests/Azure.Mcp.Tests/tsp-location.yaml | 3 + .../Azure.Mcp.Tests/xunit.runner.ci.json | 6 + .../tests/Azure.Mcp.Tests/xunit.runner.json | 5 + .../tests/test-resources-post.ps1 | 3 +- 269 files changed, 34662 insertions(+), 302 deletions(-) create mode 100644 core/Azure.Mcp.Core/src/Commands/BaseCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/CommandExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/CommandFactory.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/CommandGroup.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/EmptyOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/JsonSourceGenerationContext.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/TrimAnnotations.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/ValidationResult.cs create mode 100644 core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs create mode 100644 core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs create mode 100644 core/Azure.Mcp.Core/src/Exceptions/CommandValidationException.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/CollectionTypeHelper.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/EmbeddedResourceHelper.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/NameNormalization.cs create mode 100644 core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs create mode 100644 core/Azure.Mcp.Core/src/Options/AuthMethodOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Options/GlobalOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Options/ParseResultExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Options/RetryPolicyOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Options/SubscriptionOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationUtils.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Authentication/WindowHandleProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Models/ManagedServiceIdentity.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Azure/UserAgentPolicy.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs create mode 100644 core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/ServicesJsonContext.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/DefaultMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/IMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/LinuxMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/MacOSXMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/MachineInformationProviderBase.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryLogRecordEraser.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/UnixMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Telemetry/WindowsMachineInformationProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Time/DateTimeProvider.cs create mode 100644 core/Azure.Mcp.Core/src/Services/Time/IDateTimeProvider.cs create mode 100644 core/Azure.Mcp.Core/src/TestUtilities/ArgBuilder.cs create mode 100644 core/Azure.Mcp.Core/src/TestUtilities/ArgSplitter.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/NpxTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/LinuxInformationProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/MacOSXInformationProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/WindowsInformationProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ArrayOrCollectionElementTypeTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/McpServerToolAttributeTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/OptionTypeTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/TypeToJsonTypeMapperTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionTestHelpers.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Azure.Mcp.Core.UnitTests.csproj create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/DashSupportTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/Extensions/CommandExtensionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/HttpClientServiceCollectionExtensionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CollectionTypeHelperTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Options/RetryPolicyOptionsTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/UserAgentPolicyTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceIntegrationTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/DefaultMachineInformationProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/MachineInformationProviderBaseTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryLogRecordEraserTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/UnixInformationProviderTests.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/CustomTestTransport.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/McpTestUtilities.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/README.md create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.ci.json create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.json diff --git a/core/Azure.Mcp.Core/src/AssemblyInfo.cs b/core/Azure.Mcp.Core/src/AssemblyInfo.cs index 8dc254f837..8d76ef3a2b 100644 --- a/core/Azure.Mcp.Core/src/AssemblyInfo.cs +++ b/core/Azure.Mcp.Core/src/AssemblyInfo.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Azure.Mcp.Core.Tests")] -[assembly: InternalsVisibleTo("Microsoft.Mcp.Tests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Core.UnitTests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Core.LiveTests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj b/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj index d9f51ffff7..094e8ce931 100644 --- a/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj +++ b/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj @@ -1,8 +1,6 @@ true - - true true @@ -15,4 +13,18 @@ + + + + + + + + + + + + + + diff --git a/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs b/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs new file mode 100644 index 0000000000..4e159599a1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Diagnostics; +using System.Net; +using Azure.Mcp.Core.Exceptions; +using Azure.Mcp.Core.Helpers; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Commands; + +public abstract class BaseCommand : IBaseCommand where TOptions : class, new() +{ + private const string MissingRequiredOptionsPrefix = "Missing Required options: "; + private const string TroubleshootingUrl = "https://aka.ms/azmcp/troubleshooting"; + + private readonly Command _command; + + protected BaseCommand() + { + _command = new Command(Name, Description); + RegisterOptions(_command); + } + + public Command GetCommand() => _command; + public abstract string Id { get; } + public abstract string Name { get; } + public abstract string Description { get; } + public abstract string Title { get; } + public abstract ToolMetadata Metadata { get; } + + protected virtual void RegisterOptions(Command command) + { + } + + /// + /// Binds the parsed command line arguments to a strongly-typed options object. + /// Implement this method in derived classes to provide option binding logic. + /// + /// The parsed command line arguments. + /// An options object containing the bound options. + protected abstract TOptions BindOptions(ParseResult parseResult); + + public abstract Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken); + + protected virtual void HandleException(CommandContext context, Exception ex) + { + context.Activity?.SetStatus(ActivityStatusCode.Error)?.AddTag(TagName.ErrorDetails, ex.Message); + + var response = context.Response; + + // Handle structured validation errors first + if (ex is CommandValidationException cve) + { + response.Status = cve.StatusCode; + // If specific missing options are provided, format a consistent message + if (cve.MissingOptions is { Count: > 0 }) + { + response.Message = $"{MissingRequiredOptionsPrefix}{string.Join(", ", cve.MissingOptions)}"; + } + else + { + response.Message = cve.Message; + } + response.Results = null; + return; + } + + var result = new ExceptionResult( + Message: ex.Message ?? string.Empty, +#if DEBUG + StackTrace: ex.StackTrace, +#else + StackTrace: null, +#endif + Type: ex.GetType().Name); + + response.Status = GetStatusCode(ex); + response.Message = GetErrorMessage(ex) + $". To mitigate this issue, please refer to the troubleshooting guidelines here at {TroubleshootingUrl}."; + response.Results = ResponseResult.Create(result, JsonSourceGenerationContext.Default.ExceptionResult); + } + + protected virtual string GetErrorMessage(Exception ex) => ex.Message; + + protected virtual HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + ArgumentException => HttpStatusCode.BadRequest, // Bad Request for invalid arguments + InvalidOperationException => HttpStatusCode.UnprocessableEntity, // Unprocessable Entity for configuration errors + _ => HttpStatusCode.InternalServerError // Internal Server Error for unexpected errors + }; + + public ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) + { + var result = new ValidationResult(); + + // First, check for missing required options + var missingOptions = commandResult.Command.Options + .Where(o => o.Required && !o.HasDefaultValue && !commandResult.HasOptionResult(o)) + .Select(o => $"--{NameNormalization.NormalizeOptionName(o.Name)}") + .ToList(); + + var missingOptionsJoined = string.Join(", ", missingOptions); + + if (!string.IsNullOrEmpty(missingOptionsJoined)) + { + result.Errors.Add($"{MissingRequiredOptionsPrefix}{missingOptionsJoined}"); + } + + // Check for parser/validator errors + if (commandResult.Errors != null && commandResult.Errors.Any()) + { + result.Errors.Add(string.Join(", ", commandResult.Errors.Select(e => e.Message))); + } + + if (!result.IsValid && commandResponse != null) + { + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = string.Join('\n', result.Errors); + } + + return result; + } + + /// + /// Sets validation error details on the command response with a custom status code. + /// + /// The command response to update. + /// The error message. + /// The HTTP status code (defaults to ValidationErrorStatusCode). + protected static void SetValidationError(CommandResponse? response, string errorMessage, HttpStatusCode statusCode) + { + if (response != null) + { + response.Status = statusCode; + response.Message = errorMessage; + } + } +} + +internal record ExceptionResult( + string Message, + string? StackTrace, + string Type); diff --git a/core/Azure.Mcp.Core/src/Commands/CommandExtensions.cs b/core/Azure.Mcp.Core/src/Commands/CommandExtensions.cs new file mode 100644 index 0000000000..12d85af6d9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/CommandExtensions.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using System.Text; +using Azure.Mcp.Core.Helpers; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Extensions for parsing command options from dictionary arguments +/// +public static class CommandExtensions +{ + /// + /// Parse command options directly from a dictionary of arguments + /// + /// The command to parse options for + /// Dictionary of argument name/value pairs + /// ParseResult containing the parsed arguments + public static ParseResult ParseFromDictionary(this Command command, IReadOnlyDictionary? arguments) + { + if (arguments == null || arguments.Count == 0) + { + return command.Parse([]); + } + + var args = new List(); + + foreach (var (key, value) in arguments) + { + // lookup by normalized name or any alias (case-insensitive) + var option = command.Options.FirstOrDefault(o => + string.Equals(NameNormalization.NormalizeOptionName(o.Name), key, StringComparison.OrdinalIgnoreCase) + || o.Aliases.Any(a => string.Equals(NameNormalization.NormalizeOptionName(a), key, StringComparison.OrdinalIgnoreCase))); + + if (option == null) + { + continue; + } + + if (value.ValueKind == JsonValueKind.Null) + { + continue; + } + + // Handle arrays for options that accept multiple values (collections) + if (value.ValueKind == JsonValueKind.Array && IsArrayOption(option)) + { + foreach (var arrayElement in value.EnumerateArray()) + { + args.Add(GetOptionToken(option)); + + var elementValue = ConvertJsonElementToString(arrayElement); + + if (elementValue != null) + { + args.Add(elementValue); + } + } + } + else + { + args.Add(GetOptionToken(option)); + + var strValue = ConvertJsonElementToString(value); + + if (strValue != null) + { + args.Add(strValue); + } + } + } + + return command.Parse([.. args]); + } + + public static ParseResult ParseFromRawMcpToolInput(this Command command, IReadOnlyDictionary? arguments) + { + var args = new List(); + + // Try to find an option named "raw-mcp-tool-input" (normalized), otherwise fall back to first option + var option = command.Options.FirstOrDefault(o => + string.Equals(NameNormalization.NormalizeOptionName(o.Name), "raw-mcp-tool-input", StringComparison.OrdinalIgnoreCase) + || o.Aliases.Any(a => string.Equals(NameNormalization.NormalizeOptionName(a), "raw-mcp-tool-input", StringComparison.OrdinalIgnoreCase)) + ); + + option ??= command.Options.FirstOrDefault(); + + if (option == null) + { + return command.Parse([]); + } + + args.Add(GetOptionToken(option)); + + if (arguments == null || arguments.Count == 0) + { + args.Add("{}"); + } + else + { + var buffer = new ArrayBufferWriter(); + using (var jsonWriter = new Utf8JsonWriter(buffer)) + { + jsonWriter.WriteStartObject(); + foreach (var argument in arguments) + { + jsonWriter.WritePropertyName(argument.Key); + argument.Value.WriteTo(jsonWriter); + } + jsonWriter.WriteEndObject(); + } + args.Add(Encoding.UTF8.GetString(buffer.WrittenSpan)); + } + + return command.Parse([.. args]); + } + + /// + /// Determines if an option expects a collection type that should be treated as an array + /// + /// The option to check + /// True if the option expects a collection type, false otherwise + private static bool IsArrayOption(Option option) + { + return CollectionTypeHelper.IsArrayType(option.ValueType); + } + + private static string GetOptionToken(Option option) + { + // Prefer an alias that already contains a prefix + var aliasWithDash = option.Aliases.FirstOrDefault(a => a.StartsWith('-') || a.StartsWith('/')); + if (!string.IsNullOrEmpty(aliasWithDash)) + return aliasWithDash; + + return option.Name.StartsWith('-') || option.Name.StartsWith('/') ? option.Name : $"--{option.Name}"; + } + + /// + /// Converts a JsonElement to its string representation for command-line arguments + /// + /// The JsonElement to convert + /// String representation of the element, or null if conversion fails + private static string? ConvertJsonElementToString(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Array => string.Join(" ", element.EnumerateArray().Select(e => e.GetString() ?? string.Empty)), + _ => element.GetRawText() + }; + } +} diff --git a/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs b/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs new file mode 100644 index 0000000000..140d8afec1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Help; +using System.Diagnostics; +using System.Net; +using System.Reflection; +using System.Text.Encodings.Web; +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Commands; + +public class CommandFactory +{ + private readonly IAreaSetup[] _serviceAreas; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly RootCommand _rootCommand; + private readonly CommandGroup _rootGroup; + private readonly ModelsJsonContext _srcGenWithOptions; + + public const char Separator = '_'; + + /// + /// Mapping of tokenized command names to their + /// + private readonly Dictionary _commandMap; + private readonly Dictionary _commandNamesToArea = new(StringComparer.OrdinalIgnoreCase); + private readonly ITelemetryService _telemetryService; + private readonly IOptions _configurationOptions; + + // Add this new class inside CommandFactory + private class StringConverter : JsonConverter + { + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetString() ?? string.Empty; + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + var cleanValue = value?.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " "); + writer.WriteStringValue(cleanValue); + } + } + + internal const string RootCommandGroupName = "azmcp"; + + public CommandFactory(IServiceProvider serviceProvider, IEnumerable serviceAreas, ITelemetryService telemetryService, IOptions configurationOptions, ILogger logger) + { + _serviceAreas = serviceAreas?.ToArray() ?? throw new ArgumentNullException(nameof(serviceAreas)); + _serviceProvider = serviceProvider; + _logger = logger; + _telemetryService = telemetryService; + _configurationOptions = configurationOptions; + _rootGroup = new CommandGroup(RootCommandGroupName, "Azure MCP Server"); + _rootCommand = CreateRootCommand(); + _commandMap = CreateCommandDictionary(_rootGroup); + _srcGenWithOptions = new ModelsJsonContext(new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + } + + public RootCommand RootCommand => _rootCommand; + + public CommandGroup RootGroup => _rootGroup; + + public IReadOnlyDictionary AllCommands => _commandMap; + + public IReadOnlyDictionary GroupCommands(string[] groupNames) + { + if (groupNames is null) + { + throw new ArgumentException("groupNames cannot be null."); + } + Dictionary commandsFromGroups = new(); + foreach (string groupName in groupNames) + { + foreach (CommandGroup group in _rootGroup.SubGroup) + { + if (string.Equals(group.Name, groupName, StringComparison.OrdinalIgnoreCase)) + { + var commandsInGroup = CreateCommandDictionaryInner(group, string.Empty); + foreach (var (key, value) in commandsInGroup) + { + commandsFromGroups[key] = value; + } + break; + } + } + } + + return commandsFromGroups; + } + + private void RegisterCommandGroup() + { + // Register area command groups + var existingAreaNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var area in _serviceAreas) + { + if (string.IsNullOrEmpty(area.Name)) + { + var error = new ArgumentException("IAreaSetup cannot have an empty or null name. Type " + + area.GetType()); + _logger.LogError(error, "Invalid IAreaSetup encountered. Type: {Type}", area.GetType()); + + throw error; + } + + if (!existingAreaNames.Add(area.Name)) + { + var matchingAreaTypes = _serviceAreas + .Where(x => string.Equals(x.Name, area.Name, StringComparison.OrdinalIgnoreCase)) + .Select(a => a.GetType().FullName); + + var error = new ArgumentException("Cannot have multiple IAreaSetup with the same Name."); + _logger.LogError(error, + "Duplicate {AreaName}. Areas with same name: {AllAreaTypes}", + area.Name, + string.Join(", ", matchingAreaTypes)); + + throw error; + } + + // Get the commands for the IAreaSetup and register it to the root node. + var commandTree = area.RegisterCommands(_serviceProvider); + _rootGroup.AddSubGroup(commandTree); + + // Create a temporary root node to register all the area's subgroups and commands to. + // Use this to create the mapping of all commands to that area. + var tempRoot = new CommandGroup(RootCommandGroupName, string.Empty); + tempRoot.AddSubGroup(commandTree); + + var commandDictionary = CreateCommandDictionary(tempRoot); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Registered commands for area '{AreaName}' are: {AllCommands}.", + area.Name, string.Join(", ", commandDictionary.Keys)); + } + + foreach (var item in commandDictionary) + { + _commandNamesToArea.Add(item.Key, area); + } + } + } + + private void ConfigureCommands(CommandGroup group) + { + // Configure direct commands in this group + foreach (var command in group.Commands.Values) + { + var cmd = command.GetCommand(); + + ConfigureCommandHandler(cmd, command); + + group.Command.Subcommands.Add(cmd); + } + + // Recursively configure subgroup commands + foreach (var subGroup in group.SubGroup) + { + ConfigureCommands(subGroup); + } + } + + private void ConfigureCommandHandler(Command command, IBaseCommand implementation) + { + command.SetAction(async (parseResult, ct) => + { + _logger.LogTrace("Executing '{Command}'.", command.Name); + + using var activity = _telemetryService.StartActivity(ActivityName.CommandExecuted); + activity?.SetTag(TagName.ToolId, implementation.Id); + var cmdContext = new CommandContext(_serviceProvider, activity); + var startTime = DateTime.UtcNow; + try + { + // Centralized preflight validation before executing the command + var validation = implementation.Validate(parseResult.CommandResult, cmdContext.Response); + if (!validation.IsValid) + { + Console.WriteLine(JsonSerializer.Serialize(cmdContext.Response, _srcGenWithOptions.CommandResponse)); + return (int)cmdContext.Response.Status; + } + + var response = await implementation.ExecuteAsync(cmdContext, parseResult, ct); + + // Calculate execution time + var endTime = DateTime.UtcNow; + response.Duration = (long)(endTime - startTime).TotalMilliseconds; + + if (response.Status == HttpStatusCode.OK && response.Results == null) + { + response.Results = ResponseResult.Create(new List(), JsonSourceGenerationContext.Default.ListString); + } + + var isServiceStartCommand = implementation is Areas.Server.Commands.ServiceStartCommand; + if (!isServiceStartCommand) + { + Console.WriteLine(JsonSerializer.Serialize(response, _srcGenWithOptions.CommandResponse)); + } + + if (response.Status < HttpStatusCode.OK || response.Status >= HttpStatusCode.Ambiguous) + { + activity?.SetStatus(ActivityStatusCode.Error).AddTag(TagName.ErrorDetails, response.Message); + } + + return (int)response.Status; + } + catch (Exception ex) + { + _logger.LogError("An exception occurred while executing '{Command}'. Exception: {Exception}", + command.Name, ex); + activity?.SetStatus(ActivityStatusCode.Error)?.AddTag(TagName.ErrorDetails, ex.Message); + return 1; + } + finally + { + _logger.LogTrace("Finished running '{Command}'.", command.Name); + } + }); + } + + private RootCommand CreateRootCommand() + { + // RootCommand title/description comes from the root group + var root = new RootCommand(_rootGroup.Description); + + CustomizeHelpOption(root); + + // Register area groups and their commands + RegisterCommandGroup(); + + // Attach subgroups to the root and configure their commands/actions + foreach (var subGroup in _rootGroup.SubGroup) + { + ConfigureCommands(subGroup); + root.Subcommands.Add(subGroup.Command); + subGroup.Command.Options.Add(new HelpOption()); + + CustomizeHelpOption(subGroup.Command); + } + + return root; + } + + private void CustomizeHelpOption(Command command) + { + for (int i = 0; i < command.Options.Count; i++) + { + if (command.Options[i] is HelpOption helpOption && helpOption.Action is HelpAction helpAction) + { + helpOption.Action = new VersionDisplayHelpAction(_configurationOptions, helpAction); + break; + } + } + } + + private static IBaseCommand? FindCommandInGroup(CommandGroup group, Queue nameParts) + { + // If we've processed all parts and this group has a matching command, return it + if (nameParts.Count == 1) + { + var commandName = nameParts.Dequeue(); + return group.Commands.GetValueOrDefault(commandName); + } + + // Find the next subgroup + var groupName = nameParts.Dequeue(); + var nextGroup = group.SubGroup.FirstOrDefault(g => g.Name == groupName); + + return nextGroup != null ? FindCommandInGroup(nextGroup, nameParts) : null; + } + + /// + /// Finds the BaseCommand given its full command name (i.e. storage_account_list). + /// + /// Name of the command with prefixes. + /// + public IBaseCommand? FindCommandByName(string fullCommandName) + { + return _commandMap.GetValueOrDefault(fullCommandName); + } + + /// + /// Gets the service area given the full command name (i.e. 'storage_account_list' would return 'storage'). + /// + /// Name of the command. + public string? GetServiceArea(string fullCommandName) + { + if (string.IsNullOrEmpty(fullCommandName)) + { + return null; + } + + if (_commandNamesToArea.TryGetValue(fullCommandName, out var area)) + { + return area.Name; + } + else + { + return null; + } + } + + /// + /// Creates a command dictionary. Each sibling and child of the root node is created without using its name as a prefix. + /// + /// Node: RootNode + /// * Siblings: A11, A12 + /// * Children (Subgroups): B1, B2 + /// + /// Node: B1 + /// * Siblings: B11 + /// * Children: C1, C2 + /// + /// The command dictionary would be output: + /// - A11 + /// - A12 + /// - B1_B11 + /// - B1_C1 + /// - B1_C2 + /// - B2 + /// + /// Node to begin traversal. + internal static Dictionary CreateCommandDictionary(CommandGroup rootNode) + { + const string rootPrefix = ""; + var aggregated = new Dictionary(); + + // Add any immediate commands from root group. + foreach (var kvp in rootNode.Commands) + { + aggregated.Add(kvp.Key, kvp.Value); + } + + // Add any sub commands. + foreach (var command in rootNode.SubGroup) + { + var temp = CreateCommandDictionaryInner(command, rootPrefix); + + foreach (var kvp in temp) + { + aggregated.Add(kvp.Key, kvp.Value); + } + } + + return aggregated; + } + + /// + /// Creates a command dictionary. Each direct node and descendent is created with its parent's name as + /// its first prefix. For example, given the tree: + /// + /// Node: A1 + /// * Siblings: A11, A12 + /// * Children (Subgroups): B1, B2 + /// + /// Node: B1 + /// * Siblings: B11 + /// * Children: C1, C2 + /// + /// The command dictionary would be output: + /// - A1_A11 + /// - A1_A12 + /// - A1_B1_B11 + /// - A1_B1_C1 + /// - A1_B1_C2 + /// - A1_B2 + /// + /// Node to begin traversal. + /// Prefix. If prefix is an empty string, the name of the current node is used. + internal static Dictionary CreateCommandDictionaryInner(CommandGroup node, string prefix) + { + var aggregated = new Dictionary(); + var updatedPrefix = GetPrefix(prefix, node.Name); + + if (node.Commands != null) + { + foreach (var kvp in node.Commands) + { + var key = GetPrefix(updatedPrefix, kvp.Key); + aggregated.Add(key, kvp.Value); + } + } + + if (node.SubGroup == null) + { + return aggregated; + } + + foreach (var command in node.SubGroup) + { + var subcommandsDictionary = CreateCommandDictionaryInner(command, updatedPrefix); + foreach (var item in subcommandsDictionary) + { + aggregated.Add(item.Key, item.Value); + } + } + + return aggregated; + } + + internal static string GetPrefix(string currentPrefix, string additional) => string.IsNullOrEmpty(currentPrefix) + ? additional + : currentPrefix + Separator + additional; + + public static IEnumerable> GetVisibleCommands(IEnumerable> commands) + { + return commands + .Where(kvp => kvp.Value.GetType().GetCustomAttribute() == null) + .OrderBy(kvp => kvp.Key); + } +} diff --git a/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs b/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs new file mode 100644 index 0000000000..c76f61c2d0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Commands; + +public class CommandGroup(string name, string description, string? title = null) +{ + public string Name { get; } = name; + public string Description { get; } = description; + public string? Title { get; } = title; + public List SubGroup { get; } = []; + public Dictionary Commands { get; } = []; + public Command Command { get; } = new Command(name, description); + public ToolMetadata? ToolMetadata { get; set; } + + public void AddCommand(string path, IBaseCommand command) + { + // Split on first dot to get group and remaining path + var parts = path.Split(['.'], 2); + + if (parts.Length == 1) + { + // This is a direct command for this group + Commands[path] = command; + } + else + { + // Find or create the subgroup + var subGroup = SubGroup.FirstOrDefault(g => g.Name == parts[0]) ?? + throw new InvalidOperationException($"Subgroup {parts[0]} not found. Group must be registered before commands."); + + // Recursively add command to subgroup + subGroup.AddCommand(parts[1], command); + } + } + + public void AddSubGroup(CommandGroup subGroup) + { + SubGroup.Add(subGroup); + Command.Subcommands.Add(subGroup.Command); + } + + public IBaseCommand GetCommand(string path) + { + // Split on first dot to get group and remaining path + var parts = path.Split(['.'], 2); + + if (parts.Length == 1) + { + // This is a direct command for this group + return Commands[parts[0]]; + } + else + { + // Find the subgroup and recursively get the command + var subGroup = SubGroup.FirstOrDefault(g => g.Name == parts[0]) ?? + throw new InvalidOperationException($"Subgroup {parts[0]} not found."); + + return subGroup.GetCommand(parts[1]); + } + } +} diff --git a/core/Azure.Mcp.Core/src/Commands/EmptyOptions.cs b/core/Azure.Mcp.Core/src/Commands/EmptyOptions.cs new file mode 100644 index 0000000000..7343e03ee4 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/EmptyOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Commands; + +/// +/// Empty options class for commands that don't need specific options. +/// +public sealed class EmptyOptions +{ +} diff --git a/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs b/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs new file mode 100644 index 0000000000..c674fa0781 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Azure.Core; +using Azure.Identity; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; + +namespace Azure.Mcp.Core.Commands; + +public abstract class GlobalCommand< + [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions> : BaseCommand + where TOptions : GlobalOptions, new() +{ + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + + // Add global options + command.Options.Add(OptionDefinitions.Common.Tenant); + command.Options.Add(OptionDefinitions.Common.AuthMethod); + command.Options.Add(OptionDefinitions.RetryPolicy.Delay); + command.Options.Add(OptionDefinitions.RetryPolicy.MaxDelay); + command.Options.Add(OptionDefinitions.RetryPolicy.MaxRetries); + command.Options.Add(OptionDefinitions.RetryPolicy.Mode); + command.Options.Add(OptionDefinitions.RetryPolicy.NetworkTimeout); + } + + // Helper to get the command path for examples + protected virtual string GetCommandPath() + { + // Get the command type name without the "Command" suffix + string commandName = GetType().Name.Replace("Command", ""); + + // Get the namespace to determine the service name + string namespaceName = GetType().Namespace ?? ""; + string serviceName = ""; + + // Extract service name from namespace (e.g., Azure.Mcp.Tools.Cosmos.Commands -> cosmos) + if (!string.IsNullOrEmpty(namespaceName) && namespaceName.Contains(".Commands.")) + { + string[] parts = namespaceName.Split(".Commands."); + if (parts.Length > 1) + { + string[] subParts = parts[1].Split('.'); + if (subParts.Length > 0) + { + serviceName = subParts[0].ToLowerInvariant(); + } + } + } + + // Insert spaces before capital letters in the command name + string formattedName = string.Concat(commandName.Select(x => char.IsUpper(x) ? " " + x : x.ToString())).Trim(); + + // Convert to lowercase and replace spaces with spaces (for readability in command examples) + string commandPath = formattedName.ToLowerInvariant().Replace(" ", " "); + + // Prepend the service name if available + if (!string.IsNullOrEmpty(serviceName)) + { + commandPath = serviceName + " " + commandPath; + } + + return commandPath; + } + protected override TOptions BindOptions(ParseResult parseResult) + { + var options = new TOptions + { + Tenant = parseResult.GetValueOrDefault(OptionDefinitions.Common.Tenant.Name), + AuthMethod = parseResult.GetValueOrDefault(OptionDefinitions.Common.AuthMethod.Name) + }; + + // Create a RetryPolicyOptions capturing only explicitly provided values so unspecified settings remain SDK defaults + var hasAnyRetry = Options.ParseResultExtensions.HasAnyRetryOptions(parseResult); + if (hasAnyRetry) + { + var policy = new RetryPolicyOptions(); + + policy.HasMaxRetries = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.MaxRetries.Name, out int maxRetries); + policy.MaxRetries = maxRetries; + + policy.HasDelaySeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.Delay.Name, out double delaySeconds); + policy.DelaySeconds = delaySeconds; + + policy.HasMaxDelaySeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.MaxDelay.Name, out double maxDelaySeconds); + policy.MaxDelaySeconds = maxDelaySeconds; + + policy.HasMode = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.Mode.Name, out RetryMode mode); + policy.Mode = mode; + + policy.HasNetworkTimeoutSeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.NetworkTimeout.Name, out double networkTimeoutSeconds); + policy.NetworkTimeoutSeconds = networkTimeoutSeconds; + + // Only assign if at least one flag set (defensive) + if (policy.HasMaxRetries || policy.HasDelaySeconds || policy.HasMaxDelaySeconds || policy.HasMode || policy.HasNetworkTimeoutSeconds) + { + options.RetryPolicy = policy; + } + } + + return options; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + AuthenticationFailedException authEx => + $"Authentication failed. Please run 'az login' to sign in to Azure. Details: {authEx.Message}", + RequestFailedException rfEx => rfEx.Message, + HttpRequestException httpEx => + $"Service unavailable or network connectivity issues. Details: {httpEx.Message}", + _ => ex.Message // Just return the actual exception message + }; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + KeyNotFoundException => HttpStatusCode.NotFound, + AuthenticationFailedException => HttpStatusCode.Unauthorized, + RequestFailedException rfEx => (HttpStatusCode)rfEx.Status, + HttpRequestException => HttpStatusCode.ServiceUnavailable, + _ => HttpStatusCode.InternalServerError + }; +} diff --git a/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs b/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs new file mode 100644 index 0000000000..7020be8f75 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Interface for all commands +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] +public interface IBaseCommand +{ + /// + /// A unique identifier for the command. Identifier must be a constant value representing a GUID. + /// See to generate a random GUID. + /// + string Id { get; } + + /// + /// Gets the name of the command + /// + string Name { get; } + + /// + /// Gets the description of the command + /// + string Description { get; } + + /// + /// Gets the title of the command + /// + string Title { get; } + + /// + /// Gets metadata about an MCP tool describing its behavioral characteristics. + /// This metadata helps MCP clients understand how the tool operates and its potential effects. + /// + ToolMetadata Metadata { get; } + + /// + /// Gets the command definition + /// + Command GetCommand(); + + /// + /// Executes the command + /// + Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken); + + ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null); +} diff --git a/core/Azure.Mcp.Core/src/Commands/JsonSourceGenerationContext.cs b/core/Azure.Mcp.Core/src/Commands/JsonSourceGenerationContext.cs new file mode 100644 index 0000000000..36c1ef73cb --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/JsonSourceGenerationContext.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Commands; + +namespace Azure.Mcp; + +[JsonSerializable(typeof(ExceptionResult))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AzureCredentials))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class JsonSourceGenerationContext : JsonSerializerContext +{ + +} diff --git a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs index ed799db4c5..f54b0ed857 100644 --- a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs @@ -2,10 +2,9 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; -using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Models.Option; -using Microsoft.Mcp.Core.Options; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; namespace Azure.Mcp.Core.Commands.Subscription; @@ -19,8 +18,8 @@ protected override void RegisterOptions(Command command) command.Options.Add(OptionDefinitions.Common.Subscription); command.Validators.Add(commandResult => { - // Command-level validation for presence: allow either --subscription, - // Azure CLI profile default, or AZURE_SUBSCRIPTION_ID env var. + // Command-level validation for presence: allow either --subscription or AZURE_SUBSCRIPTION_ID + // This mirrors the prior behavior that preferred the explicit option but fell back to env var. if (!CommandHelper.HasSubscriptionAvailable(commandResult)) { commandResult.AddError("Missing Required options: --subscription"); diff --git a/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs b/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs new file mode 100644 index 0000000000..2cba4e9452 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Metadata; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Provides metadata about an MCP tool describing its behavioral characteristics. +/// This metadata helps MCP clients understand how the tool operates and its potential effects. +/// +[JsonConverter(typeof(ToolMetadataConverter))] +public sealed class ToolMetadata +{ + private bool _destructive = true; + private bool _idempotent = false; + private bool _openWorld = true; + private bool _readOnly = false; + private bool _secret = false; + private bool _localRequired = false; + + private MetadataDefinition? _destructiveProperty; + private MetadataDefinition? _idempotentProperty; + private MetadataDefinition? _openWorldProperty; + private MetadataDefinition? _readOnlyProperty; + private MetadataDefinition? _secretProperty; + private MetadataDefinition? _localRequiredProperty; + /// + /// Gets or sets whether the tool may perform destructive updates to its environment. + /// + /// + /// + /// If , the tool may perform destructive updates to its environment. + /// If , the tool performs only additive updates. + /// This property is most relevant when the tool modifies its environment (ReadOnly = false). + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool Destructive + { + get => _destructive; + init => _destructive = value; + } + + + [JsonPropertyName("destructive")] + public MetadataDefinition DestructiveProperty => _destructiveProperty ??= new MetadataDefinition + { + Value = _destructive, + Description = _destructive + ? "This tool may delete or modify existing resources in its environment." + : "This tool performs only additive updates without deleting or modifying existing resources." + }; + + /// + /// Gets or sets whether calling the tool repeatedly with the same arguments + /// will have no additional effect on its environment. + /// + /// + /// + /// This property is most relevant when the tool modifies its environment (ReadOnly = false). + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool Idempotent + { + get => _idempotent; + init => _idempotent = value; + } + + [JsonPropertyName("idempotent")] + public MetadataDefinition IdempotentProperty => _idempotentProperty ??= new MetadataDefinition + { + Value = _idempotent, + Description = _idempotent + ? "Running this operation multiple times with the same arguments produces the same result without additional effects." + : "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }; + + /// + /// Gets or sets whether this tool may interact with an "open world" of external entities. + /// + /// + /// + /// If , the tool may interact with an unpredictable or dynamic set of entities (like web search). + /// If , the tool's domain of interaction is closed and well-defined (like memory access). + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool OpenWorld + { + get => _openWorld; + init => _openWorld = value; + } + + [JsonPropertyName("openWorld")] + public MetadataDefinition OpenWorldProperty => _openWorldProperty ??= new MetadataDefinition + { + Value = _openWorld, + Description = _openWorld + ? "This tool may interact with an unpredictable or dynamic set of entities (like web search)." + : "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities (like memory access)." + }; + + /// + /// Gets or sets whether this tool does not modify its environment. + /// + /// + /// + /// If , the tool only performs read operations without changing state. + /// If , the tool may make modifications to its environment. + /// + /// + /// Read-only tools do not have side effects beyond computational resource usage. + /// They don't create, update, or delete data in any system. + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool ReadOnly + { + get => _readOnly; + init => _readOnly = value; + } + + [JsonPropertyName("readOnly")] + public MetadataDefinition ReadOnlyProperty => _readOnlyProperty ??= new MetadataDefinition + { + Value = _readOnly, + Description = _readOnly + ? "This tool only performs read operations without modifying any state or data." + : "This tool may modify its environment and perform write operations (create, update, delete)." + }; + + /// + /// Gets or sets whether this tool deals with sensitive or secret information. + /// + /// + /// + /// If , the tool handles sensitive data such as secrets, credentials, keys, or other confidential information. + /// If , the tool does not handle sensitive information. + /// + /// + /// This metadata helps MCP clients understand when a tool might expose or require access to sensitive data, + /// allowing for appropriate security measures and user confirmation flows. + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool Secret + { + get => _secret; + init => _secret = value; + } + + [JsonPropertyName("secret")] + public MetadataDefinition SecretProperty => _secretProperty ??= new MetadataDefinition + { + Value = _secret, + Description = _secret + ? "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + : "This tool does not handle sensitive or secret information." + }; + + /// + /// Gets or sets whether this tool requires local execution or resources. + /// + /// + /// + /// If , the tool requires local execution environment or local resources to function properly. + /// If , the tool can operate without local dependencies. + /// + /// + /// This metadata helps MCP clients understand whether the tool needs to be executed locally + /// or can be delegated to remote execution environments. + /// + /// + /// The default is . + /// + /// + [JsonIgnore] + public bool LocalRequired + { + get => _localRequired; + init => _localRequired = value; + } + + /// + /// Gets the localRequired metadata property with value and description for serialization. + /// + [JsonPropertyName("localRequired")] + public MetadataDefinition LocalRequiredProperty => _localRequiredProperty ??= new MetadataDefinition + { + Value = _localRequired, + Description = _localRequired + ? "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + : "This tool is available in both local and remote server modes." + }; + + /// + /// Creates a new instance of with default values. + /// All properties default to their MCP specification defaults. + /// + public ToolMetadata() + { + } + + [JsonConstructor] + public ToolMetadata( + MetadataDefinition destructive, + MetadataDefinition idempotent, + MetadataDefinition openWorld, + MetadataDefinition readOnly, + MetadataDefinition secret, + MetadataDefinition localRequired) + { + _destructive = destructive?.Value ?? true; + _idempotent = idempotent?.Value ?? false; + _openWorld = openWorld?.Value ?? true; + _readOnly = readOnly?.Value ?? false; + _secret = secret?.Value ?? false; + _localRequired = localRequired?.Value ?? false; + } + +} diff --git a/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs b/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs new file mode 100644 index 0000000000..a87ffe4541 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Server; +using Azure.Mcp.Core.Models.Metadata; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Custom JSON converter for that handles serialization and deserialization +/// of metadata properties with nested value and description objects. +/// +public sealed class ToolMetadataConverter : JsonConverter +{ + public override ToolMetadata Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var root = jsonDoc.RootElement; + + MetadataDefinition GetMetadata(string name, bool defaultValue) + { + if (!root.TryGetProperty(name, out var prop)) + return new MetadataDefinition { Value = defaultValue, Description = string.Empty }; + + var meta = JsonSerializer.Deserialize(prop.GetRawText(), ServerJsonContext.Default.MetadataDefinition) + ?? new MetadataDefinition { Value = defaultValue, Description = string.Empty }; + return meta; + } + return new ToolMetadata( + GetMetadata("destructive", true), + GetMetadata("idempotent", false), + GetMetadata("openWorld", true), + GetMetadata("readOnly", false), + GetMetadata("secret", false), + GetMetadata("localRequired", false) + ); + } + + public override void Write(Utf8JsonWriter writer, ToolMetadata value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + void WriteMetadata(string name, MetadataDefinition def) + { + writer.WritePropertyName(name); + JsonSerializer.Serialize(writer, def, ServerJsonContext.Default.MetadataDefinition); + } + + WriteMetadata("destructive", value.DestructiveProperty); + WriteMetadata("idempotent", value.IdempotentProperty); + WriteMetadata("openWorld", value.OpenWorldProperty); + WriteMetadata("readOnly", value.ReadOnlyProperty); + WriteMetadata("secret", value.SecretProperty); + WriteMetadata("localRequired", value.LocalRequiredProperty); + + writer.WriteEndObject(); + } + +} diff --git a/core/Azure.Mcp.Core/src/Commands/TrimAnnotations.cs b/core/Azure.Mcp.Core/src/Commands/TrimAnnotations.cs new file mode 100644 index 0000000000..cf62278a6d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/TrimAnnotations.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Mcp.Core.Commands; + +public static class TrimAnnotations +{ + public const DynamicallyAccessedMemberTypes CommandAnnotations = + DynamicallyAccessedMemberTypes.PublicProperties + | DynamicallyAccessedMemberTypes.NonPublicProperties; +} diff --git a/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs b/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs new file mode 100644 index 0000000000..897519b3f1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Commands; + +public class ValidationResult +{ + public bool IsValid => Errors.Count == 0; + + public List Errors { get; } = []; +} diff --git a/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs b/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs new file mode 100644 index 0000000000..5af9d03672 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Custom help action that displays version information before the standard help output. +/// +internal class VersionDisplayHelpAction : SynchronousCommandLineAction +{ + private readonly IOptions _options; + private readonly HelpAction _defaultHelp; + + public VersionDisplayHelpAction(IOptions options, HelpAction action) + { + _options = options; + _defaultHelp = action; + } + + public override int Invoke(ParseResult parseResult) + { + Console.WriteLine($"{_options.Value.Name} {_options.Value.Version}{Environment.NewLine}"); + + return _defaultHelp.Invoke(parseResult); + } +} diff --git a/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs b/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs new file mode 100644 index 0000000000..aa2c922a33 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Configuration; + +public class AzureMcpServerConfiguration +{ + public const string DefaultName = "Azure.Mcp.Server"; + + public string Name { get; set; } = DefaultName; + + public string Version { get; set; } = "1.0.0-beta"; + + public bool IsTelemetryEnabled { get; set; } = true; +} diff --git a/core/Azure.Mcp.Core/src/Exceptions/CommandValidationException.cs b/core/Azure.Mcp.Core/src/Exceptions/CommandValidationException.cs new file mode 100644 index 0000000000..f6d0668dc1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Exceptions/CommandValidationException.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; + +namespace Azure.Mcp.Core.Exceptions; + +/// +/// Represents a structured validation failure during command handling. +/// Prefer throwing this instead of relying on parser error message text. +/// +public class CommandValidationException : Exception +{ + public CommandValidationException( + string message, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError, + string? code = null, + IReadOnlyList? missingOptions = null) : base(message) + { + StatusCode = statusCode; + Code = code ?? "ValidationError"; + MissingOptions = missingOptions; + } + + /// + /// HTTP status code to return in the response. Defaults to InternalServerError (500). + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Optional machine-readable error code. + /// + public string Code { get; } + + /// + /// Optional list of missing option tokens (e.g., --resource-group). + /// + public IReadOnlyList? MissingOptions { get; } +} diff --git a/core/Azure.Mcp.Core/src/GlobalUsings.cs b/core/Azure.Mcp.Core/src/GlobalUsings.cs index 14a017b104..5f7392b8eb 100644 --- a/core/Azure.Mcp.Core/src/GlobalUsings.cs +++ b/core/Azure.Mcp.Core/src/GlobalUsings.cs @@ -3,3 +3,7 @@ global using System.CommandLine; global using System.Text.Json; +global using Azure.Mcp.Core.Extensions; +global using Azure.Mcp.Core.Models; +global using Azure.Mcp.Core.Models.Command; +global using ModelContextProtocol.Server; diff --git a/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs b/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs new file mode 100644 index 0000000000..23e47d167a --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Azure.Mcp.Core.Helpers; + +/// +/// Utility methods for working with assembly metadata. +/// +public static class AssemblyHelper +{ + /// + /// Gets the version information for an assembly. Uses logic from Azure SDK for .NET to generate the same version string. + /// https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/System.ClientModel/src/Pipeline/UserAgentPolicy.cs#L91 + /// For example, an informational version of "6.14.0-rc.116+54d611f7" will return "6.14.0-rc.116" + /// + /// The assembly to extract version information from. + /// A version string without build metadata (everything after '+' is stripped). + /// Thrown when the assembly does not have an AssemblyInformationalVersionAttribute. + public static string GetAssemblyVersion(Assembly assembly) + { + AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute(); + if (versionAttribute == null) + { + throw new InvalidOperationException( + $"{nameof(AssemblyInformationalVersionAttribute)} is required on assembly '{assembly.FullName}'."); + } + + string version = versionAttribute.InformationalVersion; + + int hashSeparator = version.IndexOf('+'); + if (hashSeparator != -1) + { + version = version.Substring(0, hashSeparator); + } + + return version; + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/CollectionTypeHelper.cs b/core/Azure.Mcp.Core/src/Helpers/CollectionTypeHelper.cs new file mode 100644 index 0000000000..43fff38096 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/CollectionTypeHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; + +namespace Azure.Mcp.Core.Helpers; + +/// +/// Helper class for determining collection type characteristics +/// +public static class CollectionTypeHelper +{ + /// + /// Determines if a type should be treated as an array/collection for JSON schema and command-line parsing + /// + /// The type to check + /// True if the type should be treated as an array, false otherwise + public static bool IsArrayType(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + // Handle nullable types + var effectiveType = Nullable.GetUnderlyingType(type) ?? type; + + // String is IEnumerable but should not be treated as an array + if (effectiveType == typeof(string)) + { + return false; + } + + // Check if it's a collection type + if (typeof(IEnumerable).IsAssignableFrom(effectiveType)) + { + return !IsDictionaryType(effectiveType); + } + + return false; + } + + /// + /// Determines if a type is a dictionary type that should be treated as an object + /// + /// The type to check + /// True if the type is a dictionary type, false otherwise + public static bool IsDictionaryType(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + // Handle nullable types + var effectiveType = Nullable.GetUnderlyingType(type) ?? type; + + // Check for dictionary types in an AOT-safe way + var isDictionary = typeof(IDictionary).IsAssignableFrom(effectiveType); + + // Also check for common generic dictionary types + if (!isDictionary && effectiveType.IsGenericType) + { + var genericTypeDef = effectiveType.GetGenericTypeDefinition(); + isDictionary = genericTypeDef == typeof(IDictionary<,>) || + genericTypeDef == typeof(Dictionary<,>) || + genericTypeDef == typeof(SortedDictionary<,>); + } + + return isDictionary; + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs b/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs new file mode 100644 index 0000000000..dace31fb7b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Helpers +{ + public static class CommandHelper + { + /// + /// Checks if a subscription is available either from the command option or AZURE_SUBSCRIPTION_ID environment variable. + /// + /// The command result to check for the subscription option. + /// True if a subscription is available, false otherwise. + public static bool HasSubscriptionAvailable(CommandResult commandResult) + { + var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription.Name); + var hasEnv = !string.IsNullOrEmpty(EnvironmentHelpers.GetAzureSubscriptionId()); + return hasOption || hasEnv; + } + + public static string? GetSubscription(ParseResult parseResult) + { + // Get subscription from command line option or fallback to environment variable + var subscriptionValue = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); + + var envSubscription = EnvironmentHelpers.GetAzureSubscriptionId(); + return (string.IsNullOrEmpty(subscriptionValue) || IsPlaceholder(subscriptionValue)) && !string.IsNullOrEmpty(envSubscription) + ? envSubscription + : subscriptionValue; + } + + private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default"); + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/EmbeddedResourceHelper.cs b/core/Azure.Mcp.Core/src/Helpers/EmbeddedResourceHelper.cs new file mode 100644 index 0000000000..8bb64e1e71 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/EmbeddedResourceHelper.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Azure.Mcp.Core.Helpers +{ + public static class EmbeddedResourceHelper + { + /// + /// Reads the content of an embedded resource file as a string. + /// + /// The assembly containing the embedded resource. + /// The full name of the embedded resource. + /// The content of the embedded resource as a string. + public static string ReadEmbeddedResource(Assembly assembly, string resourceName) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Unable to load embedded resource: {resourceName}"); + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Finds a resource by name pattern match then returns the full resource name. + /// + /// The assembly containing the embedded resource. + /// A regex pattern matching the full name of the resource. + /// The full name of the embedded resource. + /// Thrown when multiple resources match resource pattern + public static string FindEmbeddedResource(Assembly assembly, string resourcePattern) + { + string[] names = assembly.GetManifestResourceNames(); + Regex regex; + try + { + regex = new Regex(resourcePattern); + } + catch (ArgumentException ex) + { + throw new ArgumentException($"Invalid regex pattern: '{resourcePattern}'", nameof(resourcePattern), ex); + } + + string[] matches = [.. assembly.GetManifestResourceNames().Where(name => regex.IsMatch(name))]; + + if (matches.Length == 0) + { + throw new ArgumentException($"No resources match pattern '{resourcePattern}'.", nameof(resourcePattern)); + } + + if (matches.Length > 1) + { + throw new ArgumentException($"Multiple resources match pattern '{resourcePattern}'. Please refine your pattern.", nameof(resourcePattern)); + } + + return matches[0]; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs b/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs new file mode 100644 index 0000000000..05f10bc7c8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Helpers +{ + public static class EnvironmentHelpers + { + private const string AzureSubscriptionIdEnvironmentVariable = "AZURE_SUBSCRIPTION_ID"; + + public static bool GetEnvironmentVariableAsBool(string envVarName) + { + return Environment.GetEnvironmentVariable(envVarName) switch + { + "true" => true, + "True" => true, + "T" => true, + "1" => true, + _ => false + }; + } + + /// + /// Gets the Azure subscription ID from the AZURE_SUBSCRIPTION_ID environment variable. + /// + /// The subscription ID if available, null otherwise. + public static string? GetAzureSubscriptionId() + { + return Environment.GetEnvironmentVariable(AzureSubscriptionIdEnvironmentVariable); + } + + /// + /// Sets the AZURE_SUBSCRIPTION_ID environment variable. + /// This method is primarily intended for testing scenarios. + /// + /// The subscription ID to set, or null to clear the variable. + public static void SetAzureSubscriptionId(string? subscriptionId) + { + Environment.SetEnvironmentVariable(AzureSubscriptionIdEnvironmentVariable, subscriptionId); + } + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/NameNormalization.cs b/core/Azure.Mcp.Core/src/Helpers/NameNormalization.cs new file mode 100644 index 0000000000..4282a9bd5d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/NameNormalization.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Helpers; + +/// +/// Normalization helpers for command and option identifiers. +/// +public static class NameNormalization +{ + /// + /// Normalizes an option or alias name by removing common prefix characters like '-' or '/'. + /// This produces a consistent, prefix-free key for comparisons and schema generation. + /// + /// The raw option name or alias (may include leading '-' or '/'). + /// The normalized name without leading prefix characters, or empty string if null. + public static string NormalizeOptionName(string? name) => (name ?? string.Empty).TrimStart('-', '/'); +} diff --git a/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs b/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs new file mode 100644 index 0000000000..723f3bdd85 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs @@ -0,0 +1,40 @@ +namespace Azure.Mcp.Core.Helpers; + +public static class OptionParsingHelpers +{ + /// + /// Parses key value pair string options to a dictionary, assuming a format of "Key=Value,Key=Value" (default separators '=' and ',') + /// If duplicate keys are found, the last value wins. + /// + /// Value string containing key-value pairs + /// Key Value pairs as dictionary + public static Dictionary ParseKeyValuePairStringToDictionary(string value, char keyValueSeparator = '=', char pairSeparator = ',') + { + return ParseKeyValuePairStringToDictionary(value, StringComparer.OrdinalIgnoreCase, keyValueSeparator, pairSeparator); + } + + /// + /// Parses key value pair string options to a dictionary, assuming a format of "Key=Value,Key=Value" (default separators '=' and ',') + /// If duplicate keys are found, the last value wins. + /// + /// Value string containing key-value pairs + /// Key Value pairs as dictionary + public static Dictionary ParseKeyValuePairStringToDictionary(string value, StringComparer keyComparer, char keyValueSeparator = '=', char pairSeparator = ',') + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + ArgumentNullException.ThrowIfNull(keyComparer); + + var result = new Dictionary(keyComparer); + var valuePairs = value + .Split(pairSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Split(keyValueSeparator, 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .Where(x => x.Length == 2); + + foreach (var pair in valuePairs) + { + result[pair[0]] = pair[1]; + } + + return result; + } +} diff --git a/core/Azure.Mcp.Core/src/Options/AuthMethodOptions.cs b/core/Azure.Mcp.Core/src/Options/AuthMethodOptions.cs new file mode 100644 index 0000000000..34a8058c6a --- /dev/null +++ b/core/Azure.Mcp.Core/src/Options/AuthMethodOptions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Options; + +/// +/// Represents authentication method configuration for Azure SDK clients +/// +public class AuthMethodOptions +{ + [JsonPropertyName(OptionDefinitions.Common.AuthMethodName)] + public AuthMethod AuthMethod { get; set; } + + /// + /// Gets a display-friendly name for the auth method + /// + public static string GetDisplayName(AuthMethod authMethod) => authMethod switch + { + AuthMethod.Credential => "Credential", + AuthMethod.Key => "Key", + AuthMethod.ConnectionString => "Connection String", + _ => authMethod.ToString() + }; + + /// + /// Gets the default auth method + /// + public static AuthMethod GetDefaultAuthMethod() => AuthMethod.Credential; +} diff --git a/core/Azure.Mcp.Core/src/Options/GlobalOptions.cs b/core/Azure.Mcp.Core/src/Options/GlobalOptions.cs new file mode 100644 index 0000000000..284dc1ef76 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Options/GlobalOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Options; + +public class GlobalOptions +{ + [JsonPropertyName(OptionDefinitions.Common.ResourceGroupName)] + public string? ResourceGroup { get; set; } + + [JsonPropertyName(OptionDefinitions.Common.TenantName)] + public string? Tenant { get; set; } + + [JsonPropertyName(OptionDefinitions.Common.AuthMethodName)] + public AuthMethod? AuthMethod { get; set; } + + public RetryPolicyOptions? RetryPolicy { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Options/ParseResultExtensions.cs b/core/Azure.Mcp.Core/src/Options/ParseResultExtensions.cs new file mode 100644 index 0000000000..bc24db98e8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Options/ParseResultExtensions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Helpers; + +namespace Azure.Mcp.Core.Options; + +public static class ParseResultExtensions +{ + public static bool HasAnyRetryOptions(this ParseResult parseResult) + { + foreach (var child in parseResult.CommandResult.Children) + { + if (child is OptionResult optionResult) + { + var option = optionResult.Option; + if (option is null) + { + continue; + } + + var name = NameNormalization.NormalizeOptionName(option.Name); + if (RetryOptionNames.Contains(name)) + return true; + + var aliases = option.Aliases ?? []; + foreach (var alias in aliases) + { + var normalized = NameNormalization.NormalizeOptionName(alias); + if (RetryOptionNames.Contains(normalized)) + { + return true; + } + } + } + } + + return false; + } + + private static readonly HashSet RetryOptionNames = new(StringComparer.OrdinalIgnoreCase) + { + NameNormalization.NormalizeOptionName(Models.Option.OptionDefinitions.RetryPolicy.DelayName), + NameNormalization.NormalizeOptionName(Models.Option.OptionDefinitions.RetryPolicy.MaxDelayName), + NameNormalization.NormalizeOptionName(Models.Option.OptionDefinitions.RetryPolicy.MaxRetriesName), + NameNormalization.NormalizeOptionName(Models.Option.OptionDefinitions.RetryPolicy.ModeName), + NameNormalization.NormalizeOptionName(Models.Option.OptionDefinitions.RetryPolicy.NetworkTimeoutName), + }; +} diff --git a/core/Azure.Mcp.Core/src/Options/RetryPolicyOptions.cs b/core/Azure.Mcp.Core/src/Options/RetryPolicyOptions.cs new file mode 100644 index 0000000000..07109e20b8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Options/RetryPolicyOptions.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Core; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Options; + +/// +/// Represents retry policy configuration for Azure SDK clients +/// +public class RetryPolicyOptions : IComparable, IEquatable +{ + [JsonPropertyName(OptionDefinitions.RetryPolicy.DelayName)] + public double DelaySeconds { get; set; } + + [JsonPropertyName(OptionDefinitions.RetryPolicy.MaxDelayName)] + public double MaxDelaySeconds { get; set; } + + [JsonPropertyName(OptionDefinitions.RetryPolicy.MaxRetriesName)] + public int MaxRetries { get; set; } + + [JsonPropertyName(OptionDefinitions.RetryPolicy.ModeName)] + public RetryMode Mode { get; set; } + + [JsonPropertyName(OptionDefinitions.RetryPolicy.NetworkTimeoutName)] + public double NetworkTimeoutSeconds { get; set; } + + // Flags indicating which options were explicitly provided by the caller. + // This allows us to only override the Azure SDK defaults for the specified settings. + public bool HasDelaySeconds { get; set; } + public bool HasMaxDelaySeconds { get; set; } + public bool HasMaxRetries { get; set; } + public bool HasMode { get; set; } + public bool HasNetworkTimeoutSeconds { get; set; } + + /// + /// Compares this retry policy with another policy to check if all settings match + /// + /// The retry policy to compare with + /// True if both policies have identical settings or are both null, false otherwise + public static bool AreEqual(RetryPolicyOptions? policy1, RetryPolicyOptions? policy2) + { + if (ReferenceEquals(policy1, policy2)) + { + return true; + } + + if (policy1 is null || policy2 is null) + { + return false; + } + + return policy1.HasMaxRetries == policy2.HasMaxRetries && (!policy1.HasMaxRetries || policy1.MaxRetries == policy2.MaxRetries) && + policy1.HasMode == policy2.HasMode && (!policy1.HasMode || policy1.Mode == policy2.Mode) && + policy1.HasDelaySeconds == policy2.HasDelaySeconds && (!policy1.HasDelaySeconds || policy1.DelaySeconds == policy2.DelaySeconds) && + policy1.HasMaxDelaySeconds == policy2.HasMaxDelaySeconds && (!policy1.HasMaxDelaySeconds || policy1.MaxDelaySeconds == policy2.MaxDelaySeconds) && + policy1.HasNetworkTimeoutSeconds == policy2.HasNetworkTimeoutSeconds && (!policy1.HasNetworkTimeoutSeconds || policy1.NetworkTimeoutSeconds == policy2.NetworkTimeoutSeconds); + } + + public int CompareTo(RetryPolicyOptions? other) + { + if (other == null) + return 1; + + // Compare by whether MaxRetries was specified, then value + var retryComparison = HasMaxRetries.CompareTo(other.HasMaxRetries); + if (retryComparison != 0) + return retryComparison; + retryComparison = MaxRetries.CompareTo(other.MaxRetries); + if (retryComparison != 0) + return retryComparison; + + // Then by Mode specification + var modeComparison = HasMode.CompareTo(other.HasMode); + if (modeComparison != 0) + return modeComparison; + modeComparison = Mode.CompareTo(other.Mode); + if (modeComparison != 0) + return modeComparison; + + // Then by delay settings (specified flag then value) + var delayFlagComparison = HasDelaySeconds.CompareTo(other.HasDelaySeconds); + if (delayFlagComparison != 0) + return delayFlagComparison; + var delayComparison = DelaySeconds.CompareTo(other.DelaySeconds); + if (delayComparison != 0) + return delayComparison; + + var maxDelayFlagComparison = HasMaxDelaySeconds.CompareTo(other.HasMaxDelaySeconds); + if (maxDelayFlagComparison != 0) + return maxDelayFlagComparison; + var maxDelayComparison = MaxDelaySeconds.CompareTo(other.MaxDelaySeconds); + if (maxDelayComparison != 0) + return maxDelayComparison; + + // Finally by network timeout flag then value + var networkTimeoutFlagComparison = HasNetworkTimeoutSeconds.CompareTo(other.HasNetworkTimeoutSeconds); + if (networkTimeoutFlagComparison != 0) + return networkTimeoutFlagComparison; + return NetworkTimeoutSeconds.CompareTo(other.NetworkTimeoutSeconds); + } + + public bool Equals(RetryPolicyOptions? other) => AreEqual(this, other); + + public override bool Equals(object? obj) => obj is RetryPolicyOptions other && Equals(other); + + public override int GetHashCode() + { + var h1 = HashCode.Combine(HasMaxRetries, MaxRetries, HasMode, Mode, HasDelaySeconds); + var h2 = HashCode.Combine(DelaySeconds, HasMaxDelaySeconds, MaxDelaySeconds, HasNetworkTimeoutSeconds, NetworkTimeoutSeconds); + return HashCode.Combine(h1, h2); + } + + public static bool operator ==(RetryPolicyOptions? left, RetryPolicyOptions? right) => AreEqual(left, right); + + public static bool operator !=(RetryPolicyOptions? left, RetryPolicyOptions? right) => !(left == right); + + public static bool operator <(RetryPolicyOptions? left, RetryPolicyOptions? right) => + left is null ? right is not null : left.CompareTo(right) < 0; + + public static bool operator <=(RetryPolicyOptions? left, RetryPolicyOptions? right) => + left is null || left.CompareTo(right) <= 0; + + public static bool operator >(RetryPolicyOptions? left, RetryPolicyOptions? right) => !(left <= right); + + public static bool operator >=(RetryPolicyOptions? left, RetryPolicyOptions? right) => !(left < right); +} diff --git a/core/Azure.Mcp.Core/src/Options/SubscriptionOptions.cs b/core/Azure.Mcp.Core/src/Options/SubscriptionOptions.cs new file mode 100644 index 0000000000..f3ddade103 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Options/SubscriptionOptions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Options; + +public class SubscriptionOptions : GlobalOptions +{ + [JsonPropertyName(OptionDefinitions.Common.SubscriptionName)] + public string? Subscription { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..a08bcebd0c --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Identity.Web; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Extension methods for configuring Azure authentication services. +/// +public static class AuthenticationServiceCollectionExtensions +{ + /// + /// Adds as a + /// with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the single identity token credential provider which uses the hosting + /// environment's identity (e.g., a Managed Identity or a user principal using Azure CLI, Visual + /// Studio, etc.). + /// + /// This method will not override any existing + /// registration. It can be overridden as needed for on-behalf-of web APIs using + /// . + /// + public static IServiceCollection AddSingleIdentityTokenCredentialProvider(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + /// + /// Adds as a + /// with lifetime + /// into the service collection, along with all required dependencies. + /// + /// The service collection. + /// + /// The authentication builder from + /// that will be used to enable token acquisition for downstream API calls. + /// + /// The service collection. + /// + /// This method will override any existing registration. + /// + public static IServiceCollection AddHttpOnBehalfOfTokenCredentialProvider( + this IServiceCollection services, + MicrosoftIdentityWebApiAuthenticationBuilderWithConfiguration authBuilder) + { + // Dependencies - directly in constructor. + services.AddHttpContextAccessor(); + + // Dependencies - indirectly required to get MicrosoftIdentityTokenCredential. + authBuilder.EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + services.AddMicrosoftIdentityAzureTokenCredential(); + + // Register the OBO token provider. This uses AddSingleton (not TryAdd) to override + // any default registration, since OBO is an explicit configuration choice. + services.AddSingleton(); + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationUtils.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationUtils.cs new file mode 100644 index 0000000000..b87a4d3cf0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationUtils.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// A utility class for handling Azure authentication. +/// + +public static class AuthenticationUtils +{ + /// + /// Fetches the Azure credentials from the environment variable AZURE_CREDENTIALS. + /// + public static AzureCredentials? GetAzureCredentials(ILogger logger) + { + var credentialsJson = Environment.GetEnvironmentVariable("AZURE_CREDENTIALS"); + if (string.IsNullOrEmpty(credentialsJson)) + { + return null; + } + + try + { + // Use source-generated serialization to avoid trimmer warnings + var credentials = JsonSerializer.Deserialize(credentialsJson, JsonSourceGenerationContext.Default.AzureCredentials); + if (credentials == null) + { + logger.LogWarning("Invalid AZURE_CREDENTIALS format. Ensure it contains clientId, clientSecret, and tenantId."); + return null; + } + return credentials; + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Failed to deserialize AZURE_CREDENTIALS. Ensure it contains clientId, clientSecret, and tenantId."); + return null; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs new file mode 100644 index 0000000000..35889c984d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Azure.Core; +using Azure.Identity; +using Azure.Identity.Broker; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// A custom token credential that chains multiple Azure credentials with a broker-enabled instance of +/// InteractiveBrowserCredential to provide a seamless authentication experience. +/// +/// +/// +/// DO NOT INSTANTIATE THIS CLASS DIRECTLY. Use dependency injection to get an instance of +/// from . +/// +/// +/// The credential chain behavior can be controlled via the AZURE_TOKEN_CREDENTIALS environment variable: +/// +/// +/// +/// Value +/// Behavior +/// +/// +/// "dev" +/// Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI → InteractiveBrowserCredential +/// +/// +/// "prod" +/// Environment → Workload Identity → Managed Identity (no interactive fallback) +/// +/// +/// Specific credential name +/// Only that credential (e.g., "AzureCliCredential" or "ManagedIdentityCredential") with no fallback +/// +/// +/// Not set or empty +/// Development chain (Environment → Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI) + InteractiveBrowserCredential fallback +/// +/// +/// +/// By default, production credentials (Workload Identity and Managed Identity) are excluded unless explicitly requested via AZURE_TOKEN_CREDENTIALS="prod". +/// +/// +/// Special behavior: When running in VS Code context (VSCODE_PID environment variable is set) and AZURE_TOKEN_CREDENTIALS is not explicitly specified, +/// Visual Studio Code credential is automatically prioritized first in the chain. +/// +/// +/// InteractiveBrowserCredential with Identity Broker is added as a final fallback only when: +/// - AZURE_TOKEN_CREDENTIALS is not set (default behavior) +/// - AZURE_TOKEN_CREDENTIALS="dev" (development credentials with interactive fallback) +/// - AZURE_TOKEN_CREDENTIALS="InteractiveBrowserCredential" (explicitly requested) +/// +/// +/// It is NOT added when: +/// - AZURE_TOKEN_CREDENTIALS="prod" (production credentials only, fail fast if unavailable) +/// - AZURE_TOKEN_CREDENTIALS=specific credential name (user wants only that credential, fail fast) +/// +/// +/// For User-Assigned Managed Identity, set the AZURE_CLIENT_ID environment variable to the client ID of the managed identity. +/// If not set, System-Assigned Managed Identity will be used. +/// +/// +internal class CustomChainedCredential(string? tenantId = null, ILogger? logger = null) : TokenCredential +{ + private TokenCredential? _credential; + private readonly ILogger? _logger = logger; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + _credential ??= CreateCredential(tenantId, _logger); + return _credential.GetToken(requestContext, cancellationToken); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + _credential ??= CreateCredential(tenantId, _logger); + return _credential.GetTokenAsync(requestContext, cancellationToken); + } + + private const string AuthenticationRecordEnvVarName = "AZURE_MCP_AUTHENTICATION_RECORD"; + private const string BrowserAuthenticationTimeoutEnvVarName = "AZURE_MCP_BROWSER_AUTH_TIMEOUT_SECONDS"; + private const string OnlyUseBrokerCredentialEnvVarName = "AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL"; + private const string ClientIdEnvVarName = "AZURE_MCP_CLIENT_ID"; + private const string TokenCredentialsEnvVarName = "AZURE_TOKEN_CREDENTIALS"; + + private static bool ShouldUseOnlyBrokerCredential() + { + return EnvironmentHelpers.GetEnvironmentVariableAsBool(OnlyUseBrokerCredentialEnvVarName); + } + + private static TokenCredential CreateCredential(string? tenantId, ILogger? logger = null) + { + + // Check if AZURE_TOKEN_CREDENTIALS is explicitly set + string? tokenCredentials = Environment.GetEnvironmentVariable(TokenCredentialsEnvVarName); + bool hasExplicitCredentialSetting = !string.IsNullOrEmpty(tokenCredentials); + +#if DEBUG + bool isPlaybackMode = string.Equals(tokenCredentials, "PlaybackTokenCredential", StringComparison.OrdinalIgnoreCase); + // Short-circuit for playback to avoid any real auth & interactive prompts. + if (isPlaybackMode) + { + logger?.LogDebug("Playback mode detected: using PlaybackTokenCredential."); + return new PlaybackTokenCredential(); + } +#endif + + string? authRecordJson = Environment.GetEnvironmentVariable(AuthenticationRecordEnvVarName); + AuthenticationRecord? authRecord = null; + if (!string.IsNullOrEmpty(authRecordJson)) + { + byte[] bytes = Encoding.UTF8.GetBytes(authRecordJson); + using MemoryStream authRecordStream = new(bytes); + authRecord = AuthenticationRecord.Deserialize(authRecordStream); + } + + if (ShouldUseOnlyBrokerCredential()) + { + return CreateBrowserCredential(tenantId, authRecord); + } + + var creds = new List(); + + // Check if we are running in a VS Code context. VSCODE_PID is set by VS Code when launching processes, and is a reliable indicator for VS Code-hosted processes. + bool isVsCodeContext = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSCODE_PID")); + + if (isVsCodeContext && !hasExplicitCredentialSetting) + { + logger?.LogDebug("VS Code context detected (VSCODE_PID set). Prioritizing VS Code Credential in chain."); + creds.Add(CreateVsCodePrioritizedCredential(tenantId)); + } + else + { + // Use the default credential chain (respects AZURE_TOKEN_CREDENTIALS if set) + creds.Add(CreateDefaultCredential(tenantId)); + } + + // Only add InteractiveBrowserCredential as fallback when: + // 1. AZURE_TOKEN_CREDENTIALS is not set (default behavior) + // 2. AZURE_TOKEN_CREDENTIALS explicitly requests it + // 3. AZURE_TOKEN_CREDENTIALS="dev" (development credentials with interactive fallback) + // Do NOT add it for "prod" or specific credential names (user wants only those credentials) + bool shouldAddBrowserFallback = !hasExplicitCredentialSetting || + (tokenCredentials?.Equals("dev", StringComparison.OrdinalIgnoreCase) ?? false) || + (tokenCredentials?.Equals("interactivebrowsercredential", StringComparison.OrdinalIgnoreCase) ?? false); + + if (shouldAddBrowserFallback) + { + creds.Add(CreateBrowserCredential(tenantId, authRecord)); + } + + return new ChainedTokenCredential([.. creds]); + } + + private static string TokenCacheName = "azure-mcp-msal.cache"; + + private static TokenCredential CreateBrowserCredential(string? tenantId, AuthenticationRecord? authRecord) + { + string? clientId = Environment.GetEnvironmentVariable(ClientIdEnvVarName); + + IntPtr handle = WindowHandleProvider.GetWindowHandle(); + + InteractiveBrowserCredentialBrokerOptions brokerOptions = new(handle) + { + UseDefaultBrokerAccount = !ShouldUseOnlyBrokerCredential() && authRecord is null, + TenantId = string.IsNullOrEmpty(tenantId) ? null : tenantId, + AuthenticationRecord = authRecord, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions() + { + Name = TokenCacheName, + } + }; + + if (clientId is not null) + { + brokerOptions.ClientId = clientId; + } + + var browserCredential = new InteractiveBrowserCredential(brokerOptions); + + // Check for timeout value in the environment variable + string? timeoutValue = Environment.GetEnvironmentVariable(BrowserAuthenticationTimeoutEnvVarName); + int timeoutSeconds = 300; // Default to 300 seconds (5 minutes) + if (!string.IsNullOrEmpty(timeoutValue) && int.TryParse(timeoutValue, out int parsedTimeout) && parsedTimeout > 0) + { + timeoutSeconds = parsedTimeout; + } + return new TimeoutTokenCredential(browserCredential, TimeSpan.FromSeconds(timeoutSeconds)); + } + + private static ChainedTokenCredential CreateDefaultCredential(string? tenantId) + { + string? tokenCredentials = Environment.GetEnvironmentVariable(TokenCredentialsEnvVarName); + var credentials = new List(); + + // Handle specific credential targeting + if (!string.IsNullOrEmpty(tokenCredentials)) + { + switch (tokenCredentials.ToLowerInvariant()) + { + case "dev": + // Dev chain: VS -> VSCode -> CLI -> PowerShell -> AzD + AddVisualStudioCredential(credentials, tenantId); + AddVisualStudioCodeCredential(credentials, tenantId); + AddAzureCliCredential(credentials, tenantId); + AddAzurePowerShellCredential(credentials, tenantId); + AddAzureDeveloperCliCredential(credentials, tenantId); + break; + + case "prod": + // Prod chain: Environment -> WorkloadIdentity -> ManagedIdentity + AddEnvironmentCredential(credentials); + AddWorkloadIdentityCredential(credentials, tenantId); + AddManagedIdentityCredential(credentials); + break; + + case "environmentcredential": + AddEnvironmentCredential(credentials); + break; + + case "workloadidentitycredential": + AddWorkloadIdentityCredential(credentials, tenantId); + break; + + case "managedidentitycredential": + AddManagedIdentityCredential(credentials); + break; + + case "visualstudiocredential": + AddVisualStudioCredential(credentials, tenantId); + break; + + case "visualstudiocodecredential": + AddVisualStudioCodeCredential(credentials, tenantId); + break; + + case "azureclicredential": + AddAzureCliCredential(credentials, tenantId); + break; + + case "azurepowershellcredential": + AddAzurePowerShellCredential(credentials, tenantId); + break; + + case "azuredeveloperclicredential": + AddAzureDeveloperCliCredential(credentials, tenantId); + break; + + default: + // Unknown value, fall back to default chain + AddDefaultCredentialChain(credentials, tenantId); + break; + } + } + else + { + // No AZURE_TOKEN_CREDENTIALS specified, use default chain + AddDefaultCredentialChain(credentials, tenantId); + } + + return new ChainedTokenCredential([.. credentials]); + } + + private static void AddDefaultCredentialChain(List credentials, string? tenantId) + { + // Default chain: Environment -> VS -> VSCode -> CLI -> PowerShell -> AzD (excludes production credentials by default) + AddEnvironmentCredential(credentials); + AddVisualStudioCredential(credentials, tenantId); + AddVisualStudioCodeCredential(credentials, tenantId); + AddAzureCliCredential(credentials, tenantId); + AddAzurePowerShellCredential(credentials, tenantId); + AddAzureDeveloperCliCredential(credentials, tenantId); + } + + private static void AddEnvironmentCredential(List credentials) + { + credentials.Add(new SafeTokenCredential(new EnvironmentCredential(), "EnvironmentCredential")); + } + + private static void AddWorkloadIdentityCredential(List credentials, string? tenantId) + { + var workloadOptions = new WorkloadIdentityCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + workloadOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new WorkloadIdentityCredential(workloadOptions), "WorkloadIdentityCredential")); + } + + private static void AddManagedIdentityCredential(List credentials) + { + // Check if AZURE_CLIENT_ID is set for User-Assigned Managed Identity + string? clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + + ManagedIdentityCredential managedIdentityCredential = string.IsNullOrEmpty(clientId) + ? new ManagedIdentityCredential() // System-Assigned MI + : new ManagedIdentityCredential(clientId); // User-Assigned MI + + credentials.Add(new SafeTokenCredential(managedIdentityCredential, "ManagedIdentityCredential")); + } + + private static void AddVisualStudioCredential(List credentials, string? tenantId) + { + var vsOptions = new VisualStudioCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + vsOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new VisualStudioCredential(vsOptions), "VisualStudioCredential")); + } + + private static void AddVisualStudioCodeCredential(List credentials, string? tenantId) + { + var vscodeOptions = new VisualStudioCodeCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + vscodeOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new VisualStudioCodeCredential(vscodeOptions), "VisualStudioCodeCredential")); + } + + private static void AddAzureCliCredential(List credentials, string? tenantId) + { + var cliOptions = new AzureCliCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + cliOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new AzureCliCredential(cliOptions), "AzureCliCredential")); + } + + private static void AddAzurePowerShellCredential(List credentials, string? tenantId) + { + var psOptions = new AzurePowerShellCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + psOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new AzurePowerShellCredential(psOptions), "AzurePowerShellCredential")); + } + + private static void AddAzureDeveloperCliCredential(List credentials, string? tenantId) + { + var azdOptions = new AzureDeveloperCliCredentialOptions(); + if (!string.IsNullOrEmpty(tenantId)) + { + azdOptions.TenantId = tenantId; + } + credentials.Add(new SafeTokenCredential(new AzureDeveloperCliCredential(azdOptions), "AzureDeveloperCliCredential")); + } + + private static ChainedTokenCredential CreateVsCodePrioritizedCredential(string? tenantId) + { + var credentials = new List(); + + // VS Code first, then the rest of the default chain (excluding VS Code to avoid duplication) + AddVisualStudioCodeCredential(credentials, tenantId); + AddEnvironmentCredential(credentials); + AddVisualStudioCredential(credentials, tenantId); + // Skip VS Code credential here since it's already first + AddAzureCliCredential(credentials, tenantId); + AddAzurePowerShellCredential(credentials, tenantId); + AddAzureDeveloperCliCredential(credentials, tenantId); + + return new ChainedTokenCredential([.. credentials]); + } + + +} + +/// +/// A wrapper that converts any exception from the underlying credential into a CredentialUnavailableException +/// to ensure proper chaining behavior in ChainedTokenCredential. +/// +internal class SafeTokenCredential(TokenCredential innerCredential, string credentialName) : TokenCredential +{ + private readonly TokenCredential _innerCredential = innerCredential; + private readonly string _credentialName = credentialName; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + try + { + return _innerCredential.GetToken(requestContext, cancellationToken); + } + catch (CredentialUnavailableException) + { + throw; // Re-throw CredentialUnavailableException as-is + } + catch (Exception ex) + { + throw new CredentialUnavailableException($"{_credentialName} is not available: {ex.Message}", ex); + } + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + try + { + return await _innerCredential.GetTokenAsync(requestContext, cancellationToken); + } + catch (CredentialUnavailableException) + { + throw; // Re-throw CredentialUnavailableException as-is + } + catch (Exception ex) + { + throw new CredentialUnavailableException($"{_credentialName} is not available: {ex.Message}", ex); + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs new file mode 100644 index 0000000000..176a379af8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Web; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +public class HttpOnBehalfOfTokenCredentialProvider : IAzureTokenCredentialProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public HttpOnBehalfOfTokenCredentialProvider( + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + /// + public Task GetTokenCredentialAsync(string? tenantId, CancellationToken cancellationToken) + { + if (_httpContextAccessor.HttpContext is not HttpContext httpContext) + { + throw new InvalidOperationException("There is no ongoing HTTP request."); + } + + if (httpContext.User.Identity?.IsAuthenticated != true) + { + throw new InvalidOperationException( + "The current HTTP request must be authenticated to make an on-behalf-of token request."); + } + + if (tenantId is not null) + { + if (httpContext.User.FindFirst("tid")?.Value is string tidClaim + && tidClaim != tenantId) + { + _logger.LogWarning( + "The requested token tenant '{GetTokenTenant}' does not match the tenant of the authenticated user '{TidClaim}'. Going to throw.", + tenantId, + tidClaim); + + throw new InvalidOperationException( + $"The requested token tenant '{tenantId}' does not match the tenant of the authenticated user '{tidClaim}'."); + } + } + + // MicrosoftIdentityTokenCredential is registered as scoped, so we + // can get it from the request services to ensure we get the right instance. + MicrosoftIdentityTokenCredential tokenCredential = httpContext + .RequestServices + .GetRequiredService(); + return Task.FromResult(tokenCredential); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs new file mode 100644 index 0000000000..3447ec48b0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Providers instances of appropriate for the current environment. +/// Implementations are expected to be of , however, in +/// multi-user enviornments using on-behalf-of downstream authentication, the implementation +/// must return credentials within the context of the user in the current execution context. +/// +/// +/// +/// Callers can either directly depend on this interface or indirectly depend on it through +/// . +/// +/// +/// Implementors of this interface are responsible for generating, caching, and retrieving tokens +/// that can be used for authentication or authorization purposes. The specific type of the +/// is opaque to the caller of this interface as it will vary based +/// on the environment and configured authentication. +/// +/// +public interface IAzureTokenCredentialProvider +{ + /// + /// Gets an instance of applicable for the current environment + /// and downstream authentication configuration. + /// + /// An optional tenant ID to use for the token request. Use of this may + /// cause to be thrown. See the exceptions section for + /// details. + /// + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with a value of . + /// + /// Thrown when the operation has been cancelled. + /// + /// + /// Thrown when a credential cannot be provided. This can happen for reasons that vary based on the + /// underlying implementation and authentication configuration of the environment. The + /// of the should include + /// additional details. For example, in multi-user environments using on-behalf-of will + /// throw if a non- is provided that does + /// not match the tenant of the authenticated user in the current execution context. + /// + Task GetTokenCredentialAsync( + string? tenantId, + CancellationToken cancellation); +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs new file mode 100644 index 0000000000..7046d7dd8f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Token credential used during test playback to avoid any interactive or real authentication. +/// Returns a deterministic sanitized token value accepted by the test proxy recordings. +/// +public sealed class PlaybackTokenCredential : TokenCredential +{ + private static readonly string s_tokenValue = "Sanitized"; // Matches proxy sanitizer expectations. + private static readonly DateTimeOffset s_expiration = DateTimeOffset.UtcNow.AddHours(1); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(s_tokenValue, s_expiration); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => ValueTask.FromResult(new AccessToken(s_tokenValue, s_expiration)); +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs new file mode 100644 index 0000000000..d0131efbbb --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Implementation of that uses and caches +/// instances of . +/// +public class SingleIdentityTokenCredentialProvider : IAzureTokenCredentialProvider +{ + private readonly ILoggerFactory _loggerFactory; + private readonly TokenCredential _credential; + private readonly Dictionary _tenantSpecificCredentials + = new(StringComparer.OrdinalIgnoreCase); + + public SingleIdentityTokenCredentialProvider(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + _credential = new CustomChainedCredential( + null, + _loggerFactory.CreateLogger() + ); + } + + /// + public Task GetTokenCredentialAsync( + string? tenantId, + CancellationToken cancellation) + { + if (tenantId is null) + { + return Task.FromResult(_credential); + } + + if (!_tenantSpecificCredentials.TryGetValue(tenantId, out TokenCredential? tenantCredential)) + { + lock (_tenantSpecificCredentials) + { + if (!_tenantSpecificCredentials.TryGetValue(tenantId, out tenantCredential)) + { + tenantCredential = new CustomChainedCredential( + tenantId, + _loggerFactory.CreateLogger() + ); + _tenantSpecificCredentials[tenantId] = tenantCredential; + } + } + } + + return Task.FromResult(tenantCredential); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs new file mode 100644 index 0000000000..249b49abc0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +public class TimeoutTokenCredential(TokenCredential innerCredential, TimeSpan timeout) : TokenCredential +{ + private readonly TokenCredential _innerCredential = innerCredential; + private readonly TimeSpan _timeout = timeout; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_timeout); + + try + { + return _innerCredential.GetToken(requestContext, cts.Token); + } + catch (AuthenticationFailedException ex) when (ex.Message.Contains("Interactive requests with mac broker enabled must be executed on the main thread on macOS", StringComparison.OrdinalIgnoreCase)) + { + throw new AuthenticationFailedException( + "Authentication is not configured correctly." + + "Please authenticate using Azure CLI ('az login'), Azure PowerShell ('Connect-AzAccount'), or Azure Developer CLI ('azd auth login') instead. " + + "Alternatively, set AZURE_TOKEN_CREDENTIALS environment variable to use a specific credential provider.", + ex); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Authentication timed out after {_timeout.TotalSeconds} seconds."); + } + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_timeout); + + try + { + return await _innerCredential.GetTokenAsync(requestContext, cts.Token).ConfigureAwait(false); + } + catch (AuthenticationFailedException ex) when (ex.Message.Contains("Interactive requests with mac broker enabled must be executed on the main thread on macOS", StringComparison.OrdinalIgnoreCase)) + { + throw new AuthenticationFailedException( + "Authentication is not configured correctly." + + "Please authenticate using Azure CLI ('az login'), Azure PowerShell ('Connect-AzAccount'), or Azure Developer CLI ('azd auth login') instead. " + + "Alternatively, set AZURE_TOKEN_CREDENTIALS environment variable to use a specific credential provider.", + ex); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Authentication timed out after {_timeout.TotalSeconds} seconds."); + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/WindowHandleProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/WindowHandleProvider.cs new file mode 100644 index 0000000000..4fe6660c06 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/WindowHandleProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Provides window handle information for native authentication dialogs. +/// +public static partial class WindowHandleProvider +{ + /// + /// Get window handle across platforms + /// + public static IntPtr GetWindowHandle() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return GetForegroundWindow(); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + try + { + IntPtr display = XOpenDisplay(":1"); + Console.WriteLine(display == IntPtr.Zero + ? "No X display available. Running in headless mode." + : "X display is available."); + return display; + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(ex.ToString()); + Console.ResetColor(); + } + } + + return IntPtr.Zero; + } + + [SupportedOSPlatform("windows")] + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.SysInt)] + private static partial IntPtr GetForegroundWindow(); + + [SupportedOSPlatform("linux")] + [LibraryImport("libX11.so.6")] + [return: MarshalAs(UnmanagedType.SysInt)] + private static partial IntPtr XOpenDisplay([MarshalAs(UnmanagedType.LPUTF8Str)] string display); + + [SupportedOSPlatform("linux")] + [LibraryImport("libX11.so.6")] + [return: MarshalAs(UnmanagedType.SysInt)] + private static partial IntPtr XRootWindow(IntPtr display, int screen); + + [SupportedOSPlatform("linux")] + [LibraryImport("libX11.so.6")] + [return: MarshalAs(UnmanagedType.SysInt)] + private static partial IntPtr XDefaultRootWindow(IntPtr display); +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs index d68d8523d9..020cda4102 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs @@ -4,13 +4,13 @@ using System.ClientModel.Primitives; using System.Text.Json.Serialization.Metadata; using Azure.Core; +using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.ResourceManager; using Azure.ResourceManager.ResourceGraph; using Azure.ResourceManager.ResourceGraph.Models; using Azure.ResourceManager.Resources; -using Microsoft.Mcp.Core.Options; namespace Azure.Mcp.Core.Services.Azure; @@ -33,9 +33,9 @@ public abstract class BaseAzureResourceService( /// The tenant resource associated with the subscription private async Task GetTenantResourceAsync(Guid? tenantId, CancellationToken cancellationToken = default) { - if (tenantId == null) + if (tenantId == null || tenantId == Guid.Empty) { - throw new ArgumentException("Tenant ID cannot be null.", nameof(tenantId)); + throw new ArgumentException("Tenant ID cannot be null or empty", nameof(tenantId)); } // Get all tenants and find the matching one (GetTenants already has caching) @@ -74,12 +74,11 @@ private async Task ValidateResourceGroupExistsAsync(SubscriptionResource s /// Optional retry policy configuration /// Function to convert JsonElement to the target type /// Optional table name to query (default: "resources") - /// Optional additional KQL filter condition + /// Optional additional KQL filter conditions /// Maximum number of results to return (default: 50) /// Cancellation token - /// Optional tenant to use for the query /// List of resources converted to the specified type - protected async Task> ExecuteResourceQueryAsync( + protected async Task> ExecuteResourceQueryAsync( string resourceType, string? resourceGroup, string subscription, @@ -88,27 +87,23 @@ protected async Task> ExecuteResourceQueryAsync( string? tableName = "resources", string? additionalFilter = null, int limit = 50, - CancellationToken cancellationToken = default, - string? tenant = null) + CancellationToken cancellationToken = default) { ValidateRequiredParameters((nameof(resourceType), resourceType), (nameof(subscription), subscription)); ArgumentNullException.ThrowIfNull(converter); - if (!string.IsNullOrEmpty(additionalFilter) && additionalFilter.Contains('|')) - { - throw new ArgumentException( - "additionalFilter must not contain the pipe operator '|' to prevent KQL injection.", - nameof(additionalFilter)); - } - var results = new List(); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); - var tenantResource = await GetTenantResourceAsync(subscriptionResource!.Data.TenantId, cancellationToken); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var tenantResource = await GetTenantResourceAsync(subscriptionResource.Data.TenantId, cancellationToken); var queryFilter = $"{tableName} | where type =~ '{EscapeKqlString(resourceType)}'"; if (!string.IsNullOrEmpty(resourceGroup)) { + if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken)) + { + throw new KeyNotFoundException($"Resource group '{resourceGroup}' does not exist in subscription '{subscriptionResource.Data.SubscriptionId}'"); + } queryFilter += $" and resourceGroup =~ '{EscapeKqlString(resourceGroup)}'"; } if (!string.IsNullOrEmpty(additionalFilter)) @@ -135,16 +130,8 @@ protected async Task> ExecuteResourceQueryAsync( } } } - else if (!string.IsNullOrEmpty(resourceGroup)) - { - // If the query returned no results and a resource group filter was applied, validate that the resource group exists to provide better error handling - if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken)) - { - throw new KeyNotFoundException($"Resource group '{resourceGroup}' does not exist in subscription '{subscriptionResource.Data.SubscriptionId}'"); - } - } - return new ResourceQueryResults(results, result?.ResultTruncated == ResultTruncated.True); + return results; } /// @@ -156,7 +143,7 @@ protected async Task> ExecuteResourceQueryAsync( /// The subscription ID or name /// Optional retry policy configuration /// Function to convert JsonElement to the target type - /// Optional additional KQL filter condition + /// Optional additional KQL filter conditions /// Cancellation token /// Single resource converted to the specified type, or null if not found protected async Task ExecuteSingleResourceQueryAsync( @@ -167,12 +154,48 @@ protected async Task> ExecuteResourceQueryAsync( Func converter, string? tableName = "resources", string? additionalFilter = null, - string? tenant = null, CancellationToken cancellationToken = default) where T : class { - var result = await ExecuteResourceQueryAsync(resourceType, resourceGroup, subscription, retryPolicy, converter, - tableName, additionalFilter, 1, cancellationToken, tenant).ConfigureAwait(false); - return result.Results.FirstOrDefault(); + ValidateRequiredParameters((nameof(resourceType), resourceType), (nameof(subscription), subscription)); + ArgumentNullException.ThrowIfNull(converter); + + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); + var tenantResource = await GetTenantResourceAsync(subscriptionResource.Data.TenantId, cancellationToken); + + var queryFilter = $"{tableName} | where type =~ '{EscapeKqlString(resourceType)}'"; + if (!string.IsNullOrEmpty(resourceGroup)) + { + if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken)) + { + throw new KeyNotFoundException($"Resource group '{resourceGroup}' does not exist in subscription '{subscriptionResource.Data.SubscriptionId}'"); + } + queryFilter += $" and resourceGroup =~ '{EscapeKqlString(resourceGroup)}'"; + } + if (!string.IsNullOrEmpty(additionalFilter)) + { + queryFilter += $" and {additionalFilter}"; + } + queryFilter += " | limit 1"; + + var queryContent = new ResourceQueryContent(queryFilter) + { + Subscriptions = { subscriptionResource.Data.SubscriptionId } + }; + + ResourceQueryResult result = await tenantResource.GetResourcesAsync(queryContent, cancellationToken); + if (result != null && result.Count > 0) + { + using var jsonDocument = JsonDocument.Parse(result.Data); + var dataArray = jsonDocument.RootElement; + var item = dataArray.ValueKind == JsonValueKind.Array && dataArray.GetArrayLength() > 0 + ? dataArray[0] + : default; + if (item.ValueKind == JsonValueKind.Object) + { + return converter(item); + } + } + return null; } /// @@ -184,11 +207,11 @@ protected async Task> ExecuteResourceQueryAsync( /// Optional tenant to use when creating the client. /// Optional retry policy used by token acquisition. /// An initialized configured with the requested API version. - protected async Task CreateArmClientWithApiVersionAsync(string resourceTypeForApiVersion, string apiVersion, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + protected async Task CreateArmClientWithApiVersionAsync(string resourceTypeForApiVersion, string apiVersion, string? tenant = null, RetryPolicyOptions? retryPolicy = null) { var options = new ArmClientOptions(); options.SetApiVersion(resourceTypeForApiVersion, apiVersion); - return await CreateArmClientAsync(tenant, retryPolicy, options, cancellationToken).ConfigureAwait(false); + return await CreateArmClientAsync(tenant, retryPolicy, options).ConfigureAwait(false); } /// @@ -200,9 +223,10 @@ protected async Task CreateArmClientWithApiVersionAsync(string resour /// Cancellation token. /// The instance for the requested resource. /// Thrown when a required parameter is null. - protected static async Task GetGenericResourceAsync(ArmClient armClient, ResourceIdentifier resourceIdentifier, CancellationToken cancellationToken = default) + protected async Task GetGenericResourceAsync(ArmClient armClient, ResourceIdentifier resourceIdentifier, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(armClient); + if (armClient == null) + throw new ArgumentNullException(nameof(armClient)); var genericResources = armClient.GetGenericResources(); var response = await genericResources.GetAsync(resourceIdentifier, cancellationToken).ConfigureAwait(false); @@ -227,15 +251,10 @@ protected static async Task GetGenericResourceAsync(ArmClient a /// The instance for the requested resource. /// Thrown when a required parameter is null. /// Thrown when the content is invalid. - protected static async Task CreateOrUpdateGenericResourceAsync( - ArmClient armClient, - ResourceIdentifier resourceIdentifier, - AzureLocation azureLocation, - T content, - JsonTypeInfo jsonTypeInfo, - CancellationToken cancellationToken) + protected async Task CreateOrUpdateGenericResourceAsync(ArmClient armClient, ResourceIdentifier resourceIdentifier, AzureLocation azureLocation, T content, JsonTypeInfo jsonTypeInfo) { - ArgumentNullException.ThrowIfNull(armClient); + if (armClient == null) + throw new ArgumentNullException(nameof(armClient)); // Convert from T to GenericResourceData byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(content, jsonTypeInfo); @@ -244,10 +263,7 @@ protected static async Task CreateOrUpdateGenericResourceAsync< GenericResourceData data = dataModel.Create(ref reader, new ModelReaderWriterOptions("W")) ?? throw new InvalidOperationException("Failed to create deployment data"); // Create the resource - var result = await armClient.GetGenericResources().CreateOrUpdateAsync(WaitUntil.Started, resourceIdentifier, data, cancellationToken); - await WaitForLroCompletionAsync(result, cancellationToken); + var result = await armClient.GetGenericResources().CreateOrUpdateAsync(WaitUntil.Completed, resourceIdentifier, data); return result.Value; } } - -public sealed record ResourceQueryResults(List Results, bool AreResultsTruncated); diff --git a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs index 7687afbf3e..a66d3cd12d 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs @@ -5,21 +5,14 @@ using System.Runtime.Versioning; using Azure.Core; using Azure.Core.Pipeline; +using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.ResourceManager; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Core.Services.Azure; namespace Azure.Mcp.Core.Services.Azure; public abstract class BaseAzureService { - private const int MaxAllowedRetries = 10; - private const double MaxAllowedNetworkTimeoutSeconds = 300; - private const double MaxAllowedDelaySeconds = 60; - private const double MinAllowedDelaySeconds = 0.1; - private static volatile bool s_retryLimitsDisabled = false; private static UserAgentPolicy s_sharedUserAgentPolicy; private static string? s_userAgent; private static volatile bool s_initialized = false; @@ -31,7 +24,6 @@ public abstract class BaseAzureService private static readonly string s_framework; private static readonly string s_platform; private static readonly string s_defaultUserAgent; - private static readonly TimeSpan? s_defaultPollInterval = null; static BaseAzureService() { @@ -43,13 +35,6 @@ static BaseAzureService() // Initialize the default user agent policy without transport type s_defaultUserAgent = $"azmcp/{s_version} ({s_framework}; {s_platform})"; s_sharedUserAgentPolicy = new UserAgentPolicy(s_defaultUserAgent); - -#if DEBUG - if (EnvironmentHelpers.IsPlaybackTesting()) - { - s_defaultPollInterval = TimeSpan.Zero; - } -#endif } /// @@ -81,17 +66,6 @@ public static void InitializeUserAgentPolicy(string transportType) } } - /// - /// Disables upper bounds enforcement on retry policy values (delays, timeouts, max retries). - /// This method should be called once during application startup when the --dangerously-disable-retry-limits flag is set. - /// - public static void DisableRetryLimits() => s_retryLimitsDisabled = true; - - /// - /// Resets the retry limits flag. For testing only. - /// - internal static void ResetRetryLimits() => s_retryLimitsDisabled = false; - /// /// Initializes a new instance of the class. /// @@ -176,7 +150,7 @@ protected async Task GetCredential(string? tenant, Cancellation try { - return await TenantService.GetTokenCredentialAsync(tenantId, cancellationToken); + return await TenantService!.GetTokenCredentialAsync(tenantId, cancellationToken); } catch (Exception ex) { @@ -184,19 +158,6 @@ protected async Task GetCredential(string? tenant, Cancellation } } - /// - /// Gets an ARM access token for the given tenant using the ARM default scope. - /// - /// Optional tenant ID or name to authenticate against. - /// Cancellation token. - protected async Task GetArmAccessTokenAsync(string? tenant, CancellationToken cancellationToken) - { - var credential = await GetCredential(tenant, cancellationToken); - return await credential.GetTokenAsync( - new TokenRequestContext([TenantService.CloudConfiguration.ArmEnvironment.DefaultScope]), - cancellationToken); - } - protected static T AddDefaultPolicies(T clientOptions) where T : ClientOptions { clientOptions.AddPolicy(s_sharedUserAgentPolicy, HttpPipelinePosition.BeforeTransport); @@ -214,33 +175,25 @@ protected static T ConfigureRetryPolicy(T clientOptions, RetryPolicyOptions? { if (retryPolicy != null) { - if (retryPolicy.DelaySeconds is { } delaySeconds) + if (retryPolicy.HasDelaySeconds) { - clientOptions.Retry.Delay = s_retryLimitsDisabled - ? TimeSpan.FromSeconds(delaySeconds) - : TimeSpan.FromSeconds(Math.Clamp(delaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds)); + clientOptions.Retry.Delay = TimeSpan.FromSeconds(retryPolicy.DelaySeconds); } - if (retryPolicy.MaxDelaySeconds is { } maxDelaySeconds) + if (retryPolicy.HasMaxDelaySeconds) { - clientOptions.Retry.MaxDelay = s_retryLimitsDisabled - ? TimeSpan.FromSeconds(maxDelaySeconds) - : TimeSpan.FromSeconds(Math.Clamp(maxDelaySeconds, MinAllowedDelaySeconds, MaxAllowedDelaySeconds)); + clientOptions.Retry.MaxDelay = TimeSpan.FromSeconds(retryPolicy.MaxDelaySeconds); } - if (retryPolicy.MaxRetries is { } maxRetries) + if (retryPolicy.HasMaxRetries) { - clientOptions.Retry.MaxRetries = s_retryLimitsDisabled - ? maxRetries - : Math.Min(MaxAllowedRetries, maxRetries); + clientOptions.Retry.MaxRetries = retryPolicy.MaxRetries; } - if (retryPolicy.Mode is { } mode) + if (retryPolicy.HasMode) { - clientOptions.Retry.Mode = mode; + clientOptions.Retry.Mode = retryPolicy.Mode; } - if (retryPolicy.NetworkTimeoutSeconds is { } networkTimeoutSeconds) + if (retryPolicy.HasNetworkTimeoutSeconds) { - clientOptions.Retry.NetworkTimeout = s_retryLimitsDisabled - ? TimeSpan.FromSeconds(networkTimeoutSeconds) - : TimeSpan.FromSeconds(Math.Min(MaxAllowedNetworkTimeoutSeconds, networkTimeoutSeconds)); + clientOptions.Retry.NetworkTimeout = TimeSpan.FromSeconds(retryPolicy.NetworkTimeoutSeconds); } } @@ -266,7 +219,6 @@ protected async Task CreateArmClientAsync( TokenCredential credential = await GetCredential(tenantId, cancellationToken); ArmClientOptions options = armClientOptions ?? new(); options.Transport = new HttpClientTransport(TenantService.GetClient()); - options.Environment = TenantService.CloudConfiguration.ArmEnvironment; ConfigureRetryPolicy(AddDefaultPolicies(options), retryPolicy); ArmClient armClient = new(credential, defaultSubscriptionId: default, options); @@ -296,59 +248,4 @@ protected static void ValidateRequiredParameters(params (string name, string? va $"Required parameter{(missingParams.Length > 1 ? "s are" : " is")} null or empty: {string.Join(", ", missingParams)}"); } } - - /// - /// Waits for the completion of a long-running operation, periodically polling the operation status until it completes. - /// - /// The return type. - /// The long-running operation. - /// The cancellation token that can cancel the request. - /// The response once the long-running operation completes. - protected static async Task WaitForLroCompletionAsync(Operation operation, CancellationToken cancellationToken = default) where T : notnull - { - ArgumentNullException.ThrowIfNull(operation); - - if (s_defaultPollInterval.HasValue) - { - await WaitForLroCompletionInternalAsync(operation, cancellationToken).ConfigureAwait(false); - } - else - { - await operation.WaitForCompletionAsync(cancellationToken); - } - } - - /// - /// Waits for the completion of a long-running operation, periodically polling the operation status until it completes. - /// - /// The long-running operation. - /// The cancellation token that can cancel the request. - /// The response once the long-running operation completes. - protected static async Task WaitForLroCompletionAsync(Operation operation, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(operation); - - if (s_defaultPollInterval.HasValue) - { - await WaitForLroCompletionInternalAsync(operation, cancellationToken).ConfigureAwait(false); - } - else - { - await operation.WaitForCompletionResponseAsync(cancellationToken); - } - } - - private static async Task WaitForLroCompletionInternalAsync(Operation operation, CancellationToken cancellationToken) - { - while (true) - { - Response response = await operation.UpdateStatusAsync(cancellationToken); - if (operation.HasCompleted) - { - return operation.GetRawResponse(); - } - - await Task.Delay(s_defaultPollInterval!.Value, cancellationToken).ConfigureAwait(false); - } - } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Models/ManagedServiceIdentity.cs b/core/Azure.Mcp.Core/src/Services/Azure/Models/ManagedServiceIdentity.cs new file mode 100644 index 0000000000..56b6e66661 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Models/ManagedServiceIdentity.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Services.Azure.Models; + +/// User assigned identity properties. +public sealed class UserAssignedIdentity +{ + /// The principal ID of the assigned identity. + public Guid? PrincipalId { get; } + /// The client ID of the assigned identity. + public Guid? ClientId { get; } +} + +/// +/// A class representing the Managed Service Identity model. +/// +public sealed class ManagedServiceIdentity +{ + /// The service principal ID of the system assigned identity. This property will only be provided for a system assigned identity. + public Guid? PrincipalId { get; set; } + /// The tenant ID of the system assigned identity. This property will only be provided for a system assigned identity. + public Guid? TenantId { get; set; } + /// Type of managed service identity (where both SystemAssigned and UserAssigned types are allowed). + [JsonPropertyName("type")] + public string? ManagedServiceIdentityType { get; set; } + /// The set of user assigned identities associated with the resource. The userAssignedIdentities dictionary keys will be ARM resource ids in the form: '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}. The dictionary values can be empty objects ({}) in requests. + public IDictionary? UserAssignedIdentities { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs b/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs new file mode 100644 index 0000000000..944ce830d7 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Azure.Models; + +/// +/// A class representing the Resource Sku data model. +/// +public sealed class ResourceSku +{ + /// Name of this SKU. + public string? Name { get; set; } + /// The billing tier of this particular SKU. + public string? Tier { get; set; } + /// The throughput units. + public int? Capacity { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs index 3bfc9984ad..33e87781f3 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Models.ResourceGroup; +using Azure.Mcp.Core.Options; using Azure.ResourceManager.Resources; -using Microsoft.Mcp.Core.Models.Resource; -using Microsoft.Mcp.Core.Models.ResourceGroup; -using Microsoft.Mcp.Core.Options; namespace Azure.Mcp.Core.Services.Azure.ResourceGroup; @@ -13,5 +12,4 @@ public interface IResourceGroupService Task> GetResourceGroups(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetResourceGroup(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetResourceGroupResource(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - IAsyncEnumerable GetGenericResources(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs index 6abe132284..34fb21ff14 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Models.ResourceGroup; +using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; using Azure.ResourceManager.Resources; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Models.Resource; -using Microsoft.Mcp.Core.Models.ResourceGroup; -using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Core.Services.Caching; namespace Azure.Mcp.Core.Services.Azure.ResourceGroup; @@ -22,7 +20,7 @@ public class ResourceGroupService( private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService)); private const string CacheGroup = "resourcegroup"; private const string CacheKey = "resourcegroups"; - private static readonly TimeSpan s_cacheDuration = CacheDurations.ServiceData; + private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(1); public async Task> GetResourceGroups(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { @@ -32,7 +30,7 @@ public async Task> GetResourceGroups(string subscription var subscriptionId = subscriptionResource.Data.SubscriptionId; // Try to get from cache first - var cacheKey = CacheKeyBuilder.Build(CacheKey, subscriptionId, tenant ?? "default"); + var cacheKey = $"{CacheKey}_{subscriptionId}_{tenant ?? "default"}"; var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { @@ -40,18 +38,25 @@ public async Task> GetResourceGroups(string subscription } // If not in cache, fetch from Azure - var resourceGroups = await subscriptionResource.GetResourceGroups() - .GetAllAsync(cancellationToken: cancellationToken) - .Select(rg => new ResourceGroupInfo( - rg.Data.Name, - rg.Data.Id.ToString(), - rg.Data.Location.ToString())) - .ToListAsync(cancellationToken: cancellationToken); - - // Cache the results - await _cacheService.SetAsync(CacheGroup, cacheKey, resourceGroups, s_cacheDuration, cancellationToken); - - return resourceGroups; + try + { + var resourceGroups = await subscriptionResource.GetResourceGroups() + .GetAllAsync(cancellationToken: cancellationToken) + .Select(rg => new ResourceGroupInfo( + rg.Data.Name, + rg.Data.Id.ToString(), + rg.Data.Location.ToString())) + .ToListAsync(cancellationToken: cancellationToken); + + // Cache the results + await _cacheService.SetAsync(CacheGroup, cacheKey, resourceGroups, s_cacheDuration, cancellationToken); + + return resourceGroups; + } + catch (Exception ex) + { + throw new Exception($"Error retrieving resource groups: {ex.Message}", ex); + } } public async Task GetResourceGroup(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) @@ -62,48 +67,48 @@ public async Task> GetResourceGroups(string subscription var subscriptionId = subscriptionResource.Data.SubscriptionId; // Try to get from cache first - var cacheKey = CacheKeyBuilder.Build(CacheKey, subscriptionId, tenant ?? "default"); + var cacheKey = $"{CacheKey}_{subscriptionId}_{tenant ?? "default"}"; var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { - return cachedResults.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparisons.ResourceGroup)); + return cachedResults.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase)); } - var rg = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy, cancellationToken); - if (rg == null) + try { - return null; - } + var rg = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy, cancellationToken); + if (rg == null) + { + return null; + } - return new(rg.Data.Name, rg.Data.Id.ToString(), rg.Data.Location.ToString()); + return new ResourceGroupInfo( + rg.Data.Name, + rg.Data.Id.ToString(), + rg.Data.Location.ToString()); + } + catch (Exception ex) + { + throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex); + } } public async Task GetResourceGroupResource(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName)); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); - var resourceGroupResponse = await subscriptionResource.GetResourceGroups() - .GetAsync(resourceGroupName, cancellationToken) - .ConfigureAwait(false); - - return resourceGroupResponse?.Value; - } - - public async IAsyncEnumerable GetGenericResources(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName)); - - var resourceGroupResource = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy, cancellationToken) - ?? throw new KeyNotFoundException($"Resource group '{resourceGroupName}' not found"); + try + { + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); + var resourceGroupResponse = await subscriptionResource.GetResourceGroups() + .GetAsync(resourceGroupName, cancellationToken) + .ConfigureAwait(false); - await foreach (var r in resourceGroupResource.GetGenericResourcesAsync(cancellationToken: cancellationToken)) + return resourceGroupResponse?.Value; + } + catch (Exception ex) { - yield return new GenericResourceInfo( - r.Data.Name, - r.Data.Id.ToString(), - r.Data.ResourceType.ToString(), - r.Data.Location.ToString()); + throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex); } } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs index 42a8581093..09c67cf977 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Options; using Azure.ResourceManager.Resources; -using Microsoft.Mcp.Core.Options; namespace Azure.Mcp.Core.Services.Azure.Subscription; @@ -13,11 +13,4 @@ public interface ISubscriptionService bool IsSubscriptionId(string subscription, string? tenant = null); Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - - /// - /// Gets the default subscription ID from the Azure CLI profile (~/.azure/azureProfile.json). - /// Falls back to the AZURE_SUBSCRIPTION_ID environment variable if the profile is unavailable. - /// - /// The default subscription ID if found, null otherwise. - string? GetDefaultSubscriptionId(); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs index 5ef5cd3c34..09a1df6f69 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs @@ -1,33 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; using Azure.ResourceManager.Resources; -using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Core.Services.Caching; namespace Azure.Mcp.Core.Services.Azure.Subscription; -public class SubscriptionService( - ICacheService cacheService, - ITenantService tenantService, - ILogger logger) +public class SubscriptionService(ICacheService cacheService, ITenantService tenantService) : BaseAzureService(tenantService), ISubscriptionService { - private const int MaxSubscriptions = 10_000; private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private const string CacheGroup = "subscription"; private const string CacheKey = "subscriptions"; private const string SubscriptionCacheKey = "subscription"; - private static readonly TimeSpan s_cacheDuration = CacheDurations.Subscription; + private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(12); public async Task> GetSubscriptions(string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { // Try to get from cache first - var cacheKey = string.IsNullOrEmpty(tenant) ? CacheKey : CacheKeyBuilder.Build(CacheKey, tenant); + var cacheKey = string.IsNullOrEmpty(tenant) ? CacheKey : $"{CacheKey}_{tenant}"; var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { @@ -42,11 +35,6 @@ public async Task> GetSubscriptions(string? tenant = null await foreach (var subscription in subscriptions.WithCancellation(cancellationToken)) { results.Add(subscription.Data); - if (results.Count >= MaxSubscriptions) - { - _logger.LogWarning("Reached maximum subscription limit of {MaxSubscriptions}. Some subscriptions may not be included in the results.", MaxSubscriptions); - break; - } } // Cache the results @@ -64,8 +52,8 @@ public async Task GetSubscription(string subscription, str // Use subscription ID for cache key var cacheKey = string.IsNullOrEmpty(tenant) - ? CacheKeyBuilder.Build(SubscriptionCacheKey, subscriptionId) - : CacheKeyBuilder.Build(SubscriptionCacheKey, subscriptionId, tenant); + ? $"{SubscriptionCacheKey}_{subscriptionId}" + : $"{SubscriptionCacheKey}_{subscriptionId}_{tenant}"; var cachedSubscription = await _cacheService.GetAsync(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedSubscription != null) { @@ -76,7 +64,7 @@ public async Task GetSubscription(string subscription, str var response = await armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)).GetAsync(cancellationToken); if (response?.Value == null) { - throw new KeyNotFoundException($"Could not retrieve subscription {subscription}"); + throw new Exception($"Could not retrieve subscription {subscription}"); } // Cache the result using subscription ID @@ -85,7 +73,6 @@ public async Task GetSubscription(string subscription, str return response.Value; } - // TODO (alzimmer): Why does this method take tenant? public bool IsSubscriptionId(string subscription, string? tenant = null) { return Guid.TryParse(subscription, out _); @@ -94,8 +81,8 @@ public bool IsSubscriptionId(string subscription, string? tenant = null) public async Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { var subscriptions = await GetSubscriptions(tenant, retryPolicy, cancellationToken); - var subscription = subscriptions.FirstOrDefault(s => s.DisplayName.Equals(subscriptionName, StringComparisons.SubscriptionDisplayName)) ?? - throw new KeyNotFoundException($"Could not find subscription with name {subscriptionName}"); + var subscription = subscriptions.FirstOrDefault(s => s.DisplayName.Equals(subscriptionName, StringComparison.OrdinalIgnoreCase)) ?? + throw new Exception($"Could not find subscription with name {subscriptionName}"); return subscription.SubscriptionId; } @@ -103,18 +90,12 @@ public async Task GetSubscriptionIdByName(string subscriptionName, strin public async Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { var subscriptions = await GetSubscriptions(tenant, retryPolicy, cancellationToken); - var subscription = subscriptions.FirstOrDefault(s => s.SubscriptionId.Equals(subscriptionId, StringComparisons.SubscriptionId)) ?? - throw new KeyNotFoundException($"Could not find subscription with ID {subscriptionId}"); + var subscription = subscriptions.FirstOrDefault(s => s.SubscriptionId.Equals(subscriptionId, StringComparison.OrdinalIgnoreCase)) ?? + throw new Exception($"Could not find subscription with ID {subscriptionId}"); return subscription.DisplayName; } - /// - public string? GetDefaultSubscriptionId() - { - return CommandHelper.GetDefaultSubscription(); - } - private async Task GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken) { if (IsSubscriptionId(subscription)) diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs index 4d88d6a2ee..c1ac23979e 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs @@ -2,8 +2,8 @@ // Licensed under the MIT License. using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.ResourceManager.Resources; -using Microsoft.Mcp.Core.Services.Azure.Authentication; namespace Azure.Mcp.Core.Services.Azure.Tenant; @@ -12,11 +12,6 @@ namespace Azure.Mcp.Core.Services.Azure.Tenant; /// public interface ITenantService { - /// - /// Gets the Azure cloud configuration for the current environment. - /// - IAzureCloudConfiguration CloudConfiguration { get; } - /// /// Gets the list of all available Azure tenants. /// diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs index a50affa3ed..33d8183e31 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs @@ -2,45 +2,33 @@ // Licensed under the MIT License. using Azure.Core; -using Azure.Core.Pipeline; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Caching; using Azure.ResourceManager; using Azure.ResourceManager.Resources; -using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Helpers; -using Microsoft.Mcp.Core.Services.Azure.Authentication; -using Microsoft.Mcp.Core.Services.Caching; namespace Azure.Mcp.Core.Services.Azure.Tenant; public class TenantService : BaseAzureService, ITenantService { - private const int MaxTenants = 10_000; private readonly IAzureTokenCredentialProvider _credentialProvider; private readonly ICacheService _cacheService; private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; private const string CacheGroup = "tenant"; private const string CacheKey = "tenants"; - private static readonly TimeSpan s_cacheDuration = CacheDurations.Tenant; + private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(12); public TenantService( IAzureTokenCredentialProvider credentialProvider, ICacheService cacheService, - IHttpClientFactory clientFactory, - IAzureCloudConfiguration cloudConfiguration, - ILogger logger) + IHttpClientFactory clientFactory) { _credentialProvider = credentialProvider; _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); _httpClientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - CloudConfiguration = cloudConfiguration ?? throw new ArgumentNullException(nameof(cloudConfiguration)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); TenantService = this; } - /// - public IAzureCloudConfiguration CloudConfiguration { get; } - /// public async Task> GetTenants(CancellationToken cancellationToken) { @@ -55,18 +43,11 @@ public async Task> GetTenants(CancellationToken cancellatio var results = new List(); var options = AddDefaultPolicies(new ArmClientOptions()); - options.Transport = new HttpClientTransport(GetClient()); - options.Environment = CloudConfiguration.ArmEnvironment; var client = new ArmClient(await GetCredential(cancellationToken), default, options); - await foreach (var tenant in client.GetTenants().WithCancellation(cancellationToken)) + await foreach (var tenant in client.GetTenants()) { results.Add(tenant); - if (results.Count >= MaxTenants) - { - _logger.LogWarning("Reached maximum tenant limit of {MaxTenants}. Some tenants may not be included in the results.", MaxTenants); - break; - } } // Cache the results @@ -96,9 +77,10 @@ public async Task GetTenantIdByName(string tenantName, CancellationToken { var tenants = await GetTenants(cancellationToken); var tenant = tenants.FirstOrDefault(t => t.Data.DisplayName?.Equals(tenantName, StringComparison.OrdinalIgnoreCase) == true) ?? - throw new KeyNotFoundException($"Could not find tenant with name {tenantName}"); + throw new Exception($"Could not find tenant with name {tenantName}"); - string? tenantId = tenant.Data.TenantId?.ToString() ?? + string? tenantId = tenant.Data.TenantId?.ToString(); + if (tenantId == null) throw new InvalidOperationException($"Tenant {tenantName} has a null TenantId"); return tenantId.ToString(); @@ -108,10 +90,11 @@ public async Task GetTenantIdByName(string tenantName, CancellationToken public async Task GetTenantNameById(string tenantId, CancellationToken cancellationToken) { var tenants = await GetTenants(cancellationToken); - var tenant = tenants.FirstOrDefault(t => t.Data.TenantId?.ToString().Equals(tenantId, StringComparisons.TenantId) == true) ?? - throw new KeyNotFoundException($"Could not find tenant with ID {tenantId}"); + var tenant = tenants.FirstOrDefault(t => t.Data.TenantId?.ToString().Equals(tenantId, StringComparison.OrdinalIgnoreCase) == true) ?? + throw new Exception($"Could not find tenant with ID {tenantId}"); - string? tenantName = tenant.Data.DisplayName ?? + string? tenantName = tenant.Data.DisplayName; + if (tenantName == null) throw new InvalidOperationException($"Tenant with ID {tenantId} has a null DisplayName"); return tenantName; diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs index 97679b84ff..6a14cb9eb4 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Mcp.Core.Services.Azure.Authentication; namespace Azure.Mcp.Core.Services.Azure.Tenant; @@ -36,7 +37,7 @@ public static class TenantServiceCollectionExtensions /// /// /// - public static IServiceCollection AddAzureTenantService(this IServiceCollection services) + public static IServiceCollection AddAzureTenantService(this IServiceCollection services, bool addUserAgentClient = false) { // !!! HACK !!! // Program.cs for the CLI servers have their own DI containers vs ServiceStartCommand. @@ -49,6 +50,12 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s // running as a remote HTTP MCP service. services.AddSingleIdentityTokenCredentialProvider(); + services.AddHttpClient(); + if (addUserAgentClient) + { + services.ConfigureDefaultHttpClient(); + } + services.TryAddSingleton(); return services; } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/UserAgentPolicy.cs b/core/Azure.Mcp.Core/src/Services/Azure/UserAgentPolicy.cs new file mode 100644 index 0000000000..3da50da8a9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/UserAgentPolicy.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Mcp.Core.Services.Azure; + +public class UserAgentPolicy : HttpPipelineSynchronousPolicy +{ + public const string UserAgentHeader = "User-Agent"; + + private readonly string _userAgent; + + public UserAgentPolicy(string userAgent) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userAgent); + + _userAgent = userAgent; + } + + public override void OnSendingRequest(HttpMessage message) + { + message.Request.Headers.SetValue(UserAgentHeader, _userAgent); + + base.OnSendingRequest(message); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs new file mode 100644 index 0000000000..ca838c81e0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Azure.Mcp.Core.Services.Caching; + +/// +/// Extension methods for configuring cache services. +/// +public static class CachingServiceCollectionExtensions +{ + /// + /// Adds as an with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the single-user CLI cache service which is appropriate for + /// single-user command-line scenarios where all cached data belongs to a single user. + /// + /// + /// This method will not override any existing registration. + /// It can be overridden as needed by specific configurations. + /// + /// + public static IServiceCollection AddSingleUserCliCacheService(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + /// + /// Adds as an with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the HTTP service cache service which is appropriate for + /// multi-user web API scenarios where cached data must be partitioned by user. + /// + /// + /// This method will override any existing registration. + /// This is unlike . + /// + /// + public static IServiceCollection AddHttpServiceCacheService(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs new file mode 100644 index 0000000000..4791da28be --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs @@ -0,0 +1,50 @@ +namespace Azure.Mcp.Core.Services.Caching; + +/// +/// An implementation of for multi-user web API scenarios. +/// +/// A memory cache. +/// +/// +/// Do not instantiate directly. Use . +/// +/// +/// For single-user CLI scenarios, use . +/// +/// +// TODO implement this with IHttpContextAccessor + IMemoryCache. This is a no-op stub for now +// until we decide how to handle this. For example, do we only cache within a request rather +// than across requests by the same user? What are the implication of advanced Entra features +// like Conditional Access on caching? +public class HttpServiceCacheService : ICacheService +{ + public ValueTask ClearAsync(CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(default); + } + + public ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken) + { + return ValueTask.FromResult>(Array.Empty()); + } + + public ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs new file mode 100644 index 0000000000..3b325a37b2 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Caching; + +public interface ICacheService +{ + /// + /// Gets a value from the cache using a group and key. + /// + /// The type of the value. + /// The group name. + /// The cache key within the group. + /// Optional expiration time. + /// A token to cancel the operation. + /// The cached value or default if not found. + ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default); + + /// + /// Sets a value in the cache using a group and key. + /// + /// The type of the value. + /// The group name. + /// The cache key within the group. + /// The data to cache. + /// Optional expiration time. + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default); + + /// + /// Deletes a value from the cache using a group and key. + /// + /// The group name. + /// The cache key within the group. + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken); + + /// + /// Gets all keys in a specific group. + /// + /// The group name. + /// A token to cancel the operation. + /// A collection of keys in the specified group. + ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken); + + /// + /// Clears all items from the cache. + /// + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask ClearAsync(CancellationToken cancellationToken); + + /// + /// Clears all items from a specific group in the cache. + /// + /// The group name to clear. + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken); +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs new file mode 100644 index 0000000000..8a9942a44c --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Memory; + +namespace Azure.Mcp.Core.Services.Caching; + +/// +/// An implementation of for single-user CLI scenarios using in-memory caching. +/// +/// A memory cache. +/// +/// +/// Do not instantiate directly. Use . +/// +/// +/// For multi-user web API scenarios, use . +/// +/// +public class SingleUserCliCacheService(IMemoryCache memoryCache) : ICacheService +{ + private readonly IMemoryCache _memoryCache = memoryCache; + private static readonly ConcurrentDictionary> s_groupKeys = new(); + + public ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + string cacheKey = GetGroupKey(group, key); + return _memoryCache.TryGetValue(cacheKey, out T? value) ? new ValueTask(value) : default; + } + + public ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + if (data == null) + return default; + + string cacheKey = GetGroupKey(group, key); + + var options = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = expiration + }; + + _memoryCache.Set(cacheKey, data, options); + + // Track the key in the group + s_groupKeys.AddOrUpdate( + group, + new HashSet { key }, + (_, keys) => + { + keys.Add(key); + return keys; + }); + + return default; + } + + public ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken) + { + string cacheKey = GetGroupKey(group, key); + _memoryCache.Remove(cacheKey); + + // Remove from group tracking + if (s_groupKeys.TryGetValue(group, out var keys)) + { + keys.Remove(key); + } + + return default; + } + + public ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken) + { + if (s_groupKeys.TryGetValue(group, out var keys)) + { + return new ValueTask>(keys.AsEnumerable()); + } + + return new ValueTask>([]); + } + + public ValueTask ClearAsync(CancellationToken cancellationToken) + { + // Clear all items from the memory cache + if (_memoryCache is MemoryCache memoryCache) + { + memoryCache.Compact(1.0); + } + + // Clear all group tracking + s_groupKeys.Clear(); + + return default; + } + + public ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken) + { + // If this group doesn't exist, nothing to do + if (!s_groupKeys.TryGetValue(group, out var keys)) + { + return default; + } + + // Remove each key in the group from the cache + foreach (var key in keys) + { + string cacheKey = GetGroupKey(group, key); + _memoryCache.Remove(cacheKey); + } + + // Remove the group from tracking + s_groupKeys.TryRemove(group, out _); + + return default; + } + + private static string GetGroupKey(string group, string key) => $"{group}:{key}"; +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs new file mode 100644 index 0000000000..29e8f1f044 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Services.Http; + +public static class HttpClientFactoryConfigurator +{ + private static readonly string s_version; + private static readonly string s_framework; + private static readonly string s_platform; + + private static string? s_userAgent = null; + + static HttpClientFactoryConfigurator() + { + var assembly = typeof(HttpClientService).Assembly; + s_version = assembly.GetCustomAttribute()?.Version ?? "unknown"; + s_framework = assembly.GetCustomAttribute()?.FrameworkName ?? "unknown"; + s_platform = RuntimeInformation.OSDescription; + } + + public static IServiceCollection ConfigureDefaultHttpClient(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.ConfigureHttpClientDefaults(ConfigureHttpClientBuilder); + + return services; + } + + private static void ConfigureHttpClientBuilder(IHttpClientBuilder builder) + { + builder.ConfigureHttpClient((serviceProvider, client) => + { + var httpClientOptions = serviceProvider.GetRequiredService>().Value; + client.Timeout = httpClientOptions.DefaultTimeout; + + var transport = serviceProvider.GetRequiredService>().Value.Transport; + client.DefaultRequestHeaders.UserAgent.ParseAdd(BuildUserAgent(transport)); + }); + + builder.ConfigurePrimaryHttpMessageHandler(serviceProvider => + { + var httpClientOptions = serviceProvider.GetRequiredService>().Value; + return CreateHttpMessageHandler(httpClientOptions); + }); + } + + private static HttpMessageHandler CreateHttpMessageHandler(HttpClientOptions options) + { + var handler = new HttpClientHandler(); + + var proxy = CreateProxy(options); + if (proxy != null) + { + handler.Proxy = proxy; + handler.UseProxy = true; + } + +#if DEBUG + var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL"); + if (!string.IsNullOrWhiteSpace(testProxyUrl) && Uri.TryCreate(testProxyUrl, UriKind.Absolute, out var proxyUri)) + { + return new RecordingRedirectHandler(proxyUri) + { + InnerHandler = handler + }; + } +#endif + + return handler; + } + + private static WebProxy? CreateProxy(HttpClientOptions options) + { + string? proxyAddress = options.AllProxy ?? options.HttpsProxy ?? options.HttpProxy; + + if (string.IsNullOrEmpty(proxyAddress)) + { + return null; + } + + if (!Uri.TryCreate(proxyAddress, UriKind.Absolute, out var proxyUri)) + { + return null; + } + + var proxy = new WebProxy(proxyUri); + + if (!string.IsNullOrEmpty(options.NoProxy)) + { + var bypassList = options.NoProxy + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(ConvertGlobToRegex) + .ToArray(); + + if (bypassList.Length > 0) + { + proxy.BypassList = bypassList; + } + } + + return proxy; + } + + private static string ConvertGlobToRegex(string globPattern) + { + if (string.IsNullOrEmpty(globPattern)) + { + return string.Empty; + } + + var escaped = globPattern + .Replace("\\", "\\\\") + .Replace(".", "\\.") + .Replace("+", "\\+") + .Replace("$", "\\$") + .Replace("^", "\\^") + .Replace("{", "\\{") + .Replace("}", "\\}") + .Replace("[", "\\[") + .Replace("]", "\\]") + .Replace("(", "\\(") + .Replace(")", "\\)") + .Replace("|", "\\|"); + + var regex = escaped + .Replace("*", ".*") + .Replace("?", "."); + + return $"^{regex}$"; + } + + private static string BuildUserAgent(string transport) + { + s_userAgent ??= $"azmcp/{s_version} azmcp-{transport}/{s_version} ({s_framework}; {s_platform})"; + return s_userAgent; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs new file mode 100644 index 0000000000..97314600d7 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Http; + +/// +/// Configuration options for HttpClient services. +/// +public sealed class HttpClientOptions +{ + /// + /// Gets or sets the HTTP proxy address. Can be set via HTTP_PROXY environment variable. + /// + public string? HttpProxy { get; set; } + + /// + /// Gets or sets the HTTPS proxy address. Can be set via HTTPS_PROXY environment variable. + /// + public string? HttpsProxy { get; set; } + + /// + /// Gets or sets the proxy address for all protocols. Can be set via ALL_PROXY environment variable. + /// + public string? AllProxy { get; set; } + + /// + /// Gets or sets the comma-separated list of hostnames that should bypass the proxy. Can be set via NO_PROXY environment variable. + /// + public string? NoProxy { get; set; } + + /// + /// Gets or sets the default timeout for HTTP requests. Defaults to 100 seconds. + /// + public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100); + + /// + /// Gets or sets the default User-Agent header value. + /// + /// + /// UNUSED: This overwrites all user agents for all HTTP clients. So, it's not recommended to use this. + /// + public string? DefaultUserAgent { get; set; } +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs new file mode 100644 index 0000000000..0431c1cf91 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Net; +using System.Reflection; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Services.Http; + +/// +/// Implementation of IHttpClientService that provides configured HttpClient instances with proxy support. +/// +public sealed class HttpClientService : IHttpClientService, IDisposable +{ + private readonly HttpClientOptions _options; + private readonly Lazy _defaultClient; + private bool _disposed; + private string UserAgent { get; } + + public HttpClientService(IOptions options, IOptions serviceStartOptions) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _defaultClient = new Lazy(() => CreateClientInternal()); + + var assembly = typeof(HttpClientService).Assembly; + var version = assembly.GetCustomAttribute()?.Version ?? "unknown"; + var framework = assembly.GetCustomAttribute()?.FrameworkName ?? "unknown"; + var platform = System.Runtime.InteropServices.RuntimeInformation.OSDescription; + + var transport = serviceStartOptions?.Value.Transport ?? TransportTypes.StdIo; + UserAgent = $"azmcp/{version} azmcp-{transport}/{version} ({framework}; {platform})"; + } + + /// + public HttpClient DefaultClient => _defaultClient.Value; + + /// + public HttpClient CreateClient(Uri? baseAddress = null) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HttpClientService)); + } + + var client = CreateClientInternal(); + if (baseAddress != null) + { + client.BaseAddress = baseAddress; + } + return client; + } + + /// + public HttpClient CreateClient(Uri? baseAddress, Action configureClient) + { + ArgumentNullException.ThrowIfNull(configureClient); + + var client = CreateClient(baseAddress); + configureClient(client); + return client; + } + + private HttpClient CreateClientInternal() + { + var handler = CreateHttpClientHandler(); + +#if DEBUG + // If a TEST_PROXY_URL is configured, insert RecordingRedirectHandler as the last delegating handler + var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL"); + HttpMessageHandler pipeline = handler; + if (!string.IsNullOrWhiteSpace(testProxyUrl) && Uri.TryCreate(testProxyUrl, UriKind.Absolute, out var proxyUri)) + { + // Write to stderr so stdout stays reserved for command/JSON-RPC output. + Console.Error.WriteLine("Inserting RecordingRedirectHandler for test proxy: " + testProxyUrl); + // RecordingRedirectHandler should be the last delegating handler before the transport + pipeline = new RecordingRedirectHandler(proxyUri) + { + InnerHandler = pipeline + }; + } + var client = new HttpClient(pipeline); +#else + var client = new HttpClient(handler); +#endif + + // Apply default configuration + client.Timeout = _options.DefaultTimeout; + + client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); + + return client; + } + + private HttpClientHandler CreateHttpClientHandler() + { + var handler = new HttpClientHandler(); + + // Configure proxy settings + var proxy = CreateProxy(); + if (proxy != null) + { + handler.Proxy = proxy; + handler.UseProxy = true; + } + + return handler; + } + + private WebProxy? CreateProxy() + { + // Determine proxy address based on priority: ALL_PROXY, HTTPS_PROXY, HTTP_PROXY + string? proxyAddress = _options.AllProxy ?? _options.HttpsProxy ?? _options.HttpProxy; + + if (string.IsNullOrEmpty(proxyAddress)) + { + return null; + } + + if (!Uri.TryCreate(proxyAddress, UriKind.Absolute, out var proxyUri)) + { + return null; + } + + var proxy = new WebProxy(proxyUri); + + // Configure bypass list from NO_PROXY + if (!string.IsNullOrEmpty(_options.NoProxy)) + { + var bypassList = _options.NoProxy + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(ConvertGlobToRegex) + .ToArray(); + + if (bypassList.Length > 0) + { + proxy.BypassList = bypassList; + } + } + + return proxy; + } + + /// + /// Converts a glob-style pattern (e.g., "*.local") to a regex pattern for WebProxy.BypassList + /// + private static string ConvertGlobToRegex(string globPattern) + { + if (string.IsNullOrEmpty(globPattern)) + { + return string.Empty; + } + + // Escape regex special characters except * and ? + var escaped = globPattern + .Replace("\\", "\\\\") + .Replace(".", "\\.") + .Replace("+", "\\+") + .Replace("$", "\\$") + .Replace("^", "\\^") + .Replace("{", "\\{") + .Replace("}", "\\}") + .Replace("[", "\\[") + .Replace("]", "\\]") + .Replace("(", "\\(") + .Replace(")", "\\)") + .Replace("|", "\\|"); + + // Convert glob wildcards to regex + // * means "match any number of characters" + // ? means "match any single character" + var regex = escaped + .Replace("*", ".*") + .Replace("?", "."); + + // Anchor the pattern to match the entire string + return $"^{regex}$"; + } + + public void Dispose() + { + if (!_disposed) + { + if (_defaultClient.IsValueCreated) + { + _defaultClient.Value.Dispose(); + } + _disposed = true; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs new file mode 100644 index 0000000000..5c4d478f36 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Http; + +/// +/// Service for providing configured HttpClient instances with centralized proxy and configuration management. +/// +public interface IHttpClientService +{ + /// + /// Gets a default HttpClient instance configured with global settings. + /// + HttpClient DefaultClient { get; } + + /// + /// Creates a new HttpClient instance with the specified base address and global configuration. + /// + /// The base address for the HttpClient. + /// A new HttpClient instance. + HttpClient CreateClient(Uri? baseAddress = null); + + /// + /// Creates a new HttpClient instance with the specified base address and additional configuration. + /// + /// The base address for the HttpClient. + /// Additional configuration for the HttpClient. + /// A new HttpClient instance. + HttpClient CreateClient(Uri? baseAddress, Action configureClient); +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs b/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs new file mode 100644 index 0000000000..79551d3495 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; + +namespace Azure.Mcp.Core.Services.Http; + +/// +/// DelegatingHandler that rewrites outgoing requests to a recording/replace proxy specified by TEST_PROXY_URL. +/// It also sets the x-recording-upstream-base-uri header once per request to preserve the original target. +/// +/// This handler is intended to be injected as the LAST delegating handler (closest to the transport) so +/// that it rewrites the final outgoing wire request. +/// +internal sealed class RecordingRedirectHandler : DelegatingHandler +{ + private readonly Uri _proxyUri; + + public RecordingRedirectHandler(Uri proxyUri) + { + _proxyUri = proxyUri ?? throw new ArgumentNullException(nameof(proxyUri)); + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + Redirect(request); + return base.Send(request, cancellationToken)!; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Redirect(request); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private void Redirect(HttpRequestMessage message) + { + // Only set upstream header once (HttpRequestMessage can be cloned/reused by some handlers) + if (!message.Headers.Contains("x-recording-upstream-base-uri")) + { + var upstream = new UriBuilder(message.RequestUri!) + { + Query = string.Empty, + Path = string.Empty + }; + message.Headers.Add("x-recording-upstream-base-uri", upstream.Uri.ToString()); + } + + // Rewrite target host/scheme/port + var builder = new UriBuilder(_proxyUri) + { + Path = message.RequestUri!.AbsolutePath, + Query = message.RequestUri!.Query?.TrimStart('?') ?? string.Empty + }; + + message.RequestUri = builder.Uri; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs new file mode 100644 index 0000000000..eb93788bc1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs @@ -0,0 +1,732 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using static Azure.Mcp.Core.Services.ProcessExecution.ProcessExtensions; + +namespace Azure.Mcp.Core.Services.ProcessExecution; + +/// +/// Executes external processes and captures their output in a timeout and cancellation aware way. +/// +public class ExternalProcessService(ILogger logger) : IExternalProcessService +{ + /// + /// Executes an external process and captures its stdout and stderr. + /// + /// The name or path of the executable to run. In the case of a bare executable name, + /// the operating system resolves the executable using its standard search rules (including directories + /// listed in the PATH environment variable). + /// Command-line arguments to pass to the process. + /// + /// Optional environment variables. If null, the process inherits the parent environment. + /// + /// + /// Total timeout for the operation (process execution + exit-wait + stdout/stderr drain). + /// Must be greater than zero. + /// + /// + /// External cancellation token. When triggered, the process is terminated on a best-effort basis + /// and is rethrown. + /// + /// + /// A containing the exit code, stdout, stderr, and the full command. + /// + /// + /// Thrown when is less than or equal to zero. + /// + /// + /// Thrown when is null or empty. + /// + /// + /// Thrown when the process fails to start. + /// + /// + /// Thrown when the operation (process execution and stream draining) exceeds . + /// + /// + /// Thrown when is triggered before the operation completes. + /// + /// + /// Timeout vs. cancellation: + /// + /// Timeout and cancellation are handled independently: + /// + /// + /// + /// + /// is enforced by wrapping the combined + /// (exit-wait + stdout + stderr) task in . + /// A timeout always results in . + /// If the timeout occurs before the process has exited, a best-effort attempt is made to + /// terminate the process (and its tree) before throwing. + /// + /// + /// + /// + /// represents external caller cancellation. When cancellation is + /// signaled, and the process has not yet exited, a best-effort attempt is made to terminate the process + /// (and its tree) before rethrowing the original . + /// + /// + /// + /// + /// This separation avoids the common ambiguity where timeouts are implemented by canceling the caller’s token. + /// Here: always means timeout; + /// always means external cancellation. + /// + /// + /// + /// Diagnostic information on exceptions:
+ /// When a timeout or external cancellation occurs, this method attaches structured diagnostic + /// information to the thrown or + /// via the exception’s Data dictionary. This + /// information does not alter exception semantics or stack traces, and exists solely to aid + /// debugging. The following fields are always included: + ///
+ /// + /// + /// "ProcessName" — the process name if available, or "<unknown>". + /// "ProcessId" — the process ID if available, or -1. + /// "ProcessExitStatus" — one of Exited, NotExited, or Indeterminate, + /// indicating the observed exit state of the process "before" any kill attempt. + /// + /// + /// + /// Additional diagnostic fields are included only when relevant: + /// + /// + /// + /// + /// + /// "ProcessKillException" — present only when the process was observed to be + /// NotExited and a best-effort attempt to terminate it + /// (via ) failed. The value is the caught exception. + /// + /// + /// + /// + /// "ProcessExitCheckException" — present only when the process exit state could not be + /// determined because itself threw during the exit check. This + /// captures the original exception that made the exit status Indeterminate. + /// + /// + /// + /// + /// + /// These fields are useful for diagnosing cases such as processes failing to terminate, OS-level handle + /// failures, corrupted process state, or unexpected platform behavior. All diagnostic data is attached + /// without modifying the original exception type, message, stack trace, or cancellation semantics. + /// + /// + /// + /// The method does not interpret success or failure using . + /// The exit code is included in the , and the caller decides how to interpret it. + /// + ///
+ /// + public async Task ExecuteAsync( + string fileName, + string arguments, + IDictionary? environmentVariables = null, + int operationTimeoutSeconds = 300, + CancellationToken cancellationToken = default) + { + var operationTimeout = ValidateTimeout(operationTimeoutSeconds); + + using Process process = CreateProcess(fileName, arguments, environmentVariables); + using ProcessStreamReader stdoutReader = new(process, isErrorStream: false, logger); + using ProcessStreamReader stderrReader = new(process, isErrorStream: true, logger); + + if (!process.Start()) + { + throw new InvalidOperationException($"Failed to start process: {fileName}"); + } + + Task stdoutTask = stdoutReader.ReadToEndAsync(); + Task stderrTask = stderrReader.ReadToEndAsync(); + Task exitTask = process.WaitForExitAsync(cancellationToken); + + Task operation = Task.WhenAll(exitTask, stdoutTask, stderrTask); + + try + { + await operation.WaitAsync(operationTimeout, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Timeout may be thrown either: + // - Case A: before the process had exited, or + // - Case B: after the process had already exited, but before streams were fully drained. + throw HandleTimeout(process, operationTimeout, fileName, arguments); + } + catch (OperationCanceledException oce) when (cancellationToken.IsCancellationRequested) + { + // Cancellation was explicitly requested by the caller (not a timeout). + // OCE may be thrown either: + // - Case A: by WaitForExitAsync while the process is still running, or + // - Case B: by WaitAsync after the process has already exited. + HandleCancellation(process, fileName, arguments, oce); + // 'throw;' here preserves the original stack trace from where the OCE was first thrown, + // inside WaitAsync or WaitForExitAsync not from this catch block. + throw; + } + + // The earlier await on Task.WhenAll(...) guarantees that stdoutTask and stderrTask have already run + // to completion. The .Result simply retrieves their already-computed output without any blocking. + var stdout = stdoutTask.Result.TrimEnd(); + var stderr = stderrTask.Result.TrimEnd(); + + // Normal completion: the process has exited, and stdout/stderr have fully drained. + return new ProcessResult( + process.ExitCode, + stdout, + stderr, + $"{fileName} {arguments}"); + + // The `using` declarations at the top ensure that both ProcessStreamReader and Process are disposed on + // every exit path—normal completion, timeout, or cancellation — unsubscribing handlers and releasing OS + // resources. + } + + public JsonElement ParseJsonOutput(ProcessResult result) + { + if (result.ExitCode != 0) + { + var error = new ParseError( + result.ExitCode, + result.Error, + result.Command); + + return JsonSerializer.SerializeToElement( + error, + ServicesJsonContext.Default.ParseError); + } + + try + { + using var jsonDocument = JsonDocument.Parse(result.Output); + return jsonDocument.RootElement.Clone(); + } + catch + { + var fallback = new ParseOutput(result.Output); + return JsonSerializer.SerializeToElement( + fallback, + ServicesJsonContext.Default.ParseOutput); + } + } + + internal record ParseError( + int ExitCode, + string Error, + string Command); + + internal record ParseOutput( + [property: JsonPropertyName("output")] + string Output); + + private static TimeSpan ValidateTimeout(int operationTimeoutSeconds) + { + if (operationTimeoutSeconds <= 0) + { + throw new ArgumentOutOfRangeException(nameof(operationTimeoutSeconds), "Timeout must be a positive number of seconds."); + } + return TimeSpan.FromSeconds(operationTimeoutSeconds); + } + + /// + /// Creates and configures a for execution with redirected stdout/stderr/stdin and + /// applied environment variables. + /// + private static Process CreateProcess(string fileName, string arguments, IDictionary? environmentVariables) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + if (environmentVariables is not null) + { + foreach (var pair in environmentVariables) + { + startInfo.EnvironmentVariables[pair.Key] = pair.Value; + } + } + + return new Process + { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + } + + private TimeoutException HandleTimeout(Process process, TimeSpan timeout, string executablePath, string arguments) + { + // Get the pre-kill exit state and any kill error (if termination was attempted). + (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger); + string command = $"{executablePath} {arguments}"; + + TimeoutException exception; + + switch (exitCheck.Status) + { + case ExitStatus.NotExited: + // Timeout occurred before the process had exited: the process itself exceeded the timeout. + exception = new TimeoutException($"Process execution timed out after {timeout.TotalSeconds} seconds: {command}"); + if (killException is not null) + { + exception.Data["ProcessKillException"] = killException; + logger.LogWarning(killException, "Failed to kill process after timeout for command: {Command}", command); + } + break; + + case ExitStatus.Exited: + // Timeout occurred after the process had already exited, but before streams were fully drained. + exception = new TimeoutException($"Process streams draining timed out after {timeout.TotalSeconds} seconds: {command}"); + break; + + case ExitStatus.Indeterminate: + // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted). + exception = new TimeoutException($"Process execution or streams draining timed out after {timeout.TotalSeconds} seconds: {command}"); + exception.Data["ProcessExitCheckException"] = exitCheck.CheckException; + logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state after the timeout for command: {Command}", command); + break; + + default: + throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}"); + } + + exception.Data["ProcessName"] = process.SafeName(); + exception.Data["ProcessId"] = process.SafeId(); + exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString(); + + return exception; + } + + private void HandleCancellation(Process process, string executablePath, string arguments, OperationCanceledException exception) + { + // Get the pre-kill exit state and any kill error (if termination was attempted). + (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger); + string command = $"{executablePath} {arguments}"; + + switch (exitCheck.Status) + { + case ExitStatus.NotExited: + // Cancellation occurred before the process had exited. + if (killException is not null) + { + exception.Data["ProcessKillException"] = killException; + logger.LogWarning(killException, "Failed to kill process after cancellation for command: {Command}", command); + } + break; + + case ExitStatus.Exited: + // Process already exited + break; + + case ExitStatus.Indeterminate: + // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted). + exception.Data["ProcessExitCheckException"] = exitCheck.CheckException; + logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state during cancellation for command: {Command}", command); + break; + + default: + throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}"); + } + + exception.Data["ProcessName"] = process.SafeName(); + exception.Data["ProcessId"] = process.SafeId(); + exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString(); + + // we deliberately preserve and rethrow (via 'throw;') the original OCE at call site. + } + + /// + /// Reads either stdout or stderr from a asynchronously. + /// Handlers are attached in the constructor, and reading begins when + /// is called after the process has started. + /// + /// + /// + /// Intended usage pattern: + /// + /// + /// Create and configure the with redirected streams. + /// Construct (handlers attach immediately). + /// Start the process. + /// Call to begin event-driven reading. + /// Await the returned task to obtain the full stream content. + /// + /// + /// + /// The reader does not handle cancellation directly - the underlying + /// owns the reader threads, which naturally terminate when the process exits or is killed. + /// This type: + /// + /// + /// Accumulates received lines into a buffer. + /// Completes when e.Data == null, the signal that the stream closed. + /// + /// Includes a safety-net completion in so callers cannot hang if + /// a close event is never raised (only relevant if used outside ExecuteAsync). + /// + /// + /// + private sealed class ProcessStreamReader : IDisposable + { + private readonly Process _process; + private readonly bool _isErrorStream; + private readonly ILogger _logger; + private readonly StringBuilder _buffer = new(); + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly DataReceivedEventHandler _handler; + private bool _readingStarted; + private bool _disposed; + + public ProcessStreamReader(Process process, bool isErrorStream, ILogger logger) + { + this._process = process ?? throw new ArgumentNullException(nameof(process)); + this._isErrorStream = isErrorStream; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _handler = (_, e) => + { + if (e.Data is null) + { + // Stream closed – finalize accumulated output and complete. + _tcs.TrySetResult(_buffer.ToString()); + } + else + { + _buffer.AppendLine(e.Data); + } + }; + + if (isErrorStream) + { + process.ErrorDataReceived += _handler; + } + else + { + process.OutputDataReceived += _handler; + } + } + + /// + /// Begins asynchronous reading of the associated stream. + /// Must be called only after has successfully completed. + /// + /// + /// This method does not accept a because the underlying + /// BeginOutputReadLine / BeginErrorReadLine APIs are event-driven and do not + /// support token-based cancellation. + /// + /// Cancellation of the overall external process operation is handled by ExecuteAsync, + /// which terminates the process on timeout or external cancellation. Once the process exits + /// (naturally or via kill), the stream read completes automatically. + /// + /// If ProcessStreamReader is ever used outside the normal ExecuteAsync workflow, + /// provides a safety net by completing the task even if the final + /// e.Data == null callback was never raised. + /// + /// A task that completes when the stream has fully drained. + public Task ReadToEndAsync() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_readingStarted) + { + throw new InvalidOperationException("StartReading has already been called for this reader."); + } + + _readingStarted = true; + + if (_isErrorStream) + { + _process.BeginErrorReadLine(); + } + else + { + _process.BeginOutputReadLine(); + } + + return _tcs.Task; + } + + /// + /// Disposes the stream reader by unsubscribing the data-received handler and ensuring + /// that the task returned by is completed as a safety net. + /// + /// + /// In the normal ExecuteAsync workflow, the read task will already have completed + /// before runs: + /// + /// Success path — the stream has fully drained. + /// Timeout or cancellation — ExecuteAsync has already raised an exception. + /// + /// + /// In these cases, the fallback completion inside is a harmless no-op. + /// Its purpose is to guarantee that callers of never block indefinitely if + /// ProcessStreamReader is used outside the intended ExecuteAsync flow and the + /// final e.Data == null callback is never delivered. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + if (_isErrorStream) + { + _process.ErrorDataReceived -= _handler; + } + else + { + _process.OutputDataReceived -= _handler; + } + } + catch (Exception ex) + { + // Unsubscribing the handlers is a best-effort cleanup step; log and swallow any exceptions to avoid disposal throwing. + _logger.LogDebug( + ex, + "Unsubscribe from {StreamType} stream during disposal was skipped. Process: {ProcessName}, PID: {Pid}.", + StreamType, + _process.SafeName(), + _process.SafeId()); + } + + // Safety net: if the DataReceived handler _handler never observed the final e.Data == null + // and therefore never completed the read task, force completion here. + // + // We intentionally avoid calling ToString() on the shared StringBuilder _buffer to prevent + // any cross-thread races with AppendLine() inside the event handler. In all normal + // ExecuteAsync scenarios, the task will already be completed before Dispose() runs, + // making completion here a no-op. + // + // In timeout or cancellation scenarios, the caller receives an exception and does not + // consume stream output, so try completing with an empty string is sufficient and avoids + // touching potentially-mutating state. + if (_tcs.TrySetResult(string.Empty)) + { + _logger.LogDebug( + "ProcessStreamReader for {StreamType} (Process: {ProcessName}, PID: {Pid}) completed during disposal.", + StreamType, + _process.SafeName(), + _process.SafeId()); + } + } + + private string StreamType => _isErrorStream ? "stderr" : "stdout"; + } +} + +/// +/// Extensions for safe process inspection and kill operations. +/// +internal static class ProcessExtensions +{ + public static int SafeId(this Process process) + { + try + { + return process.Id; + } + catch + { + return -1; + } + } + + public static string SafeName(this Process process) + { + try + { + return process.ProcessName; + } + catch + { + return ""; + } + } + + /// + /// Gets the exit state of a process, defensively handling exceptions that may be thrown by . + /// + /// See official docs: + /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.hasexited + /// + /// + /// The process to check. + /// Logger for diagnostic messages. + /// + /// An indicating: + /// + /// with null exception if the process has exited. + /// with null exception if the process has not exited. + /// with null exception if is thrown. + /// + /// with the exception if or is thrown. + /// + /// + /// + public static ExitCheckResult CheckExitState(this Process process, ILogger logger) + { + try + { + return process.HasExited + ? new ExitCheckResult(ExitStatus.Exited, CheckException: null) + : new ExitCheckResult(ExitStatus.NotExited, CheckException: null); + } + catch (InvalidOperationException checkException) + { + // Official docs: "No process is associated with this object." - treat as "already gone". + logger.LogDebug( + checkException, + "Process.HasExited reported no associated process. Treating as already exited. " + + "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId()); + return new ExitCheckResult(ExitStatus.Exited, CheckException: null); + } + catch (System.ComponentModel.Win32Exception checkException) + { + // Failure to read status (access denied, invalid handle, etc.). + return new ExitCheckResult(ExitStatus.Indeterminate, checkException); + } + catch (NotSupportedException checkException) + { + // Remote process or unsupported scenario – not a valid case for Azure MCP Server. + return new ExitCheckResult(ExitStatus.Indeterminate, checkException); + } + } + + /// + /// Best-effort attempt to terminate the process (and its tree), returning the exit state observed before + /// the kill attempt and any exception from the kill attempt. + /// + /// See official docs: + /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.Kill + /// + /// + /// The process to terminate. + /// Logger for diagnostic messages. + /// + /// + /// The returned reflects the state observed before the kill attempt. + /// If is reported, this method calls . + /// + /// Kill may throw: + /// + /// + /// – no process is associated with this object (for example, it has + /// already exited or the handle is invalid). In this case, the exception is logged as debug information and + /// is not treated as a kill failure, since there is nothing left to terminate. + /// + /// + /// or + /// – treated as genuine kill failures and returned to the caller for + /// diagnostic purposes. + /// + /// + /// + public static (ExitCheckResult ExitCheck, Exception? KillException) TryKill(this Process process, ILogger logger) + { + var exitCheck = process.CheckExitState(logger); + + if (exitCheck.Status == ExitStatus.NotExited) + { + try + { + process.Kill(entireProcessTree: true); + return (exitCheck, null); + } + catch (InvalidOperationException e) + { + // Official docs: "No process is associated with this object." - treat as "already gone". + logger.LogDebug( + e, + "Process.Kill reported no associated process. Treating as already exited. " + + "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId()); + + // Considered success from a termination perspective: nothing left to kill. + return (exitCheck, null); + } + catch (System.ComponentModel.Win32Exception e) + { + // Failure to terminate (access denied, invalid handle, etc.). + return (exitCheck, e); + } + catch (NotSupportedException e) + { + // Remote process or unsupported scenario – not a valid case for Azure MCP Server. + return (exitCheck, e); + } + } + + // Process has already exited or exit state was indeterminate; nothing to kill. + return (exitCheck, null); + } + + /// + /// Represents the exit status of a process. + /// + public enum ExitStatus + { + /// + /// The process has exited. + /// + Exited, + + /// + /// The process has not exited. + /// + NotExited, + + /// + /// The process exit status could not be determined because + /// threw an exception during the check. + /// + Indeterminate + } + + /// + /// Represents the observed exit state of a process as determined by + /// . + /// + /// This captures: + /// + /// + /// + /// The + /// + /// + /// + /// + /// An associated exception only when the status is + /// + /// + /// + /// + public record ExitCheckResult(ExitStatus Status, Exception? CheckException); +} diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs new file mode 100644 index 0000000000..e4c17a1317 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.ProcessExecution; + +public record ProcessResult( + int ExitCode, + string Output, + string Error, + string Command); + +public interface IExternalProcessService +{ + /// + /// Executes an external process and captures its standard output and error streams. + /// + /// Full path to the executable to run. + /// Command-line arguments to pass to the executable. + /// + /// Optional environment variables to set for the process. If null or not provided, no additional + /// environment variables will be set beyond those inherited from the parent process. + /// + /// + /// Total timeout in seconds for the entire operation, including process execution and output capture. + /// Defaults to 300 seconds (5 minutes). Must be greater than zero. + /// + /// + /// Cancellation token to abort the operation. The process will be terminated if cancellation is requested. + /// + /// + /// A containing the exit code, standard output, standard error, and command string. + /// + Task ExecuteAsync( + string executablePath, + string arguments, + IDictionary? environmentVariables = default, + int operationTimeoutSeconds = 300, + CancellationToken cancellationToken = default); + + /// + /// Tries to parse the process output as JSON and return it as JsonElement + /// + /// Process execution result + /// Parsed JSON element or formatted error object if parsing fails + JsonElement ParseJsonOutput(ProcessResult result); +} diff --git a/core/Azure.Mcp.Core/src/Services/ServicesJsonContext.cs b/core/Azure.Mcp.Core/src/Services/ServicesJsonContext.cs new file mode 100644 index 0000000000..a33a107015 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/ServicesJsonContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Services.ProcessExecution; + +namespace Azure.Mcp; + +[JsonSerializable(typeof(ExternalProcessService.ParseError))] +[JsonSerializable(typeof(ExternalProcessService.ParseOutput))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class ServicesJsonContext : JsonSerializerContext +{ + +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/DefaultMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/DefaultMachineInformationProvider.cs new file mode 100644 index 0000000000..408302ba80 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/DefaultMachineInformationProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Telemetry; + +/// +/// Default information provider not tied to any platform specification for DevDeviceId. +/// +internal class DefaultMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + /// + /// Returns null. + /// + /// + public override Task GetOrCreateDeviceId() => Task.FromResult(null); +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/IMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/IMachineInformationProvider.cs new file mode 100644 index 0000000000..3330e584f8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/IMachineInformationProvider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Telemetry; + +internal interface IMachineInformationProvider +{ + /// + /// Gets existing or creates the device id. In case the cached id cannot be retrieved, or the + /// newly generated id cannot be cached, a value of null is returned. + /// + Task GetOrCreateDeviceId(); + + /// + /// Gets a hash of the machine's MAC address. + /// + Task GetMacAddressHash(); +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs new file mode 100644 index 0000000000..f9837fa778 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Services.Telemetry; + +public interface ITelemetryService : IDisposable +{ + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName); + + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// The MCP client information to add to the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName, Implementation? clientInfo); + + /// + /// Performs any initialization operations before telemetry service is ready. + /// + /// A task that completes when initialization is complete. + Task InitializeAsync(); +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/LinuxMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/LinuxMachineInformationProvider.cs new file mode 100644 index 0000000000..82282b4ac0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/LinuxMachineInformationProvider.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Telemetry; + +[SupportedOSPlatform("linux")] + +internal class LinuxMachineInformationProvider(ILogger logger) : UnixMachineInformationProvider(logger) +{ + private const string PrimaryPathEnvVar = "XDG_CACHE_HOME"; + private const string SecondaryPathSubDirectory = ".cache"; + + /// + /// Gets the base folder for the cache to be stored. + /// The final path should be $HOME\.cache\Microsoft\DeveloperTools. + /// + public override string GetStoragePath() + { + var userDir = Environment.GetEnvironmentVariable(PrimaryPathEnvVar); + + // If this comes back as null or empty/whitespace, try environment variable. + if (string.IsNullOrWhiteSpace(userDir)) + { + var rootPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // If the secondary path is still null/empty/whitespace, then throw as it will lead + // to us caching the data in the wrong directory otherwise. + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new InvalidOperationException("linux: Unable to get UserProfile or $HOME folder."); + } + + userDir = Path.Combine(rootPath, SecondaryPathSubDirectory); + } + + return userDir; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/MacOSXMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/MacOSXMachineInformationProvider.cs new file mode 100644 index 0000000000..9f4e0da459 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/MacOSXMachineInformationProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Telemetry; + +[SupportedOSPlatform("osx")] +internal class MacOSXMachineInformationProvider(ILogger logger) + : UnixMachineInformationProvider(logger) +{ + /// + /// Retrieves the storage path for application data on macOS. + /// + /// This method determines the appropriate storage directory by first attempting to retrieve the + /// user's profile directory using . If that is unavailable, it + /// falls back to the "HOME" environment variable. If neither is available, an is thrown. The returned path is typically located under the + /// "Library/Application Support" directory within the user's home folder. + /// A string representing the full path to the "Library/Application Support" directory in the user's home folder. + /// + /// Thrown if the user's profile directory and the "HOME" + /// environment variable are both unavailable or invalid. + public override string GetStoragePath() + { + var userDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // If this comes back as null or empty/whitespace, try environment variable. + if (string.IsNullOrWhiteSpace(userDir)) + { + userDir = Environment.GetEnvironmentVariable("HOME"); + } + + if (string.IsNullOrWhiteSpace(userDir)) + { + throw new InvalidOperationException("macOS: Unable to get UserProfile or $HOME folder."); + } + + var subdirectoryPath = Path.Combine(userDir, "Library", "Application Support"); + return subdirectoryPath; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/MachineInformationProviderBase.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/MachineInformationProviderBase.cs new file mode 100644 index 0000000000..05a3750d79 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/MachineInformationProviderBase.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Telemetry; + +internal abstract class MachineInformationProviderBase(ILogger logger) + : IMachineInformationProvider +{ + protected const string MicrosoftDirectory = "Microsoft"; + protected const string DeveloperToolsDirectory = "DeveloperTools"; + protected const string NotAvailable = "N/A"; + + /// + /// The name of the registry key or file containing device id. + /// + protected const string DeviceId = "deviceid"; + + private static readonly SHA256 s_sHA256 = SHA256.Create(); + + private readonly ILogger _logger = logger; + + /// + /// + /// + public abstract Task GetOrCreateDeviceId(); + + /// + /// + /// + public virtual Task GetMacAddressHash() + { + return Task.Run(() => + { + try + { + var address = GetMacAddress(); + + return address != null + ? HashValue(address) + : NotAvailable; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to calculate MAC address hash."); + return NotAvailable; + } + }); + } + + /// + /// Searches for first network interface card that is up and has a physical address. + /// + /// Hash of the MAC address or if none can be found. + protected virtual string? GetMacAddress() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.OperationalStatus == OperationalStatus.Up && x.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .Select(x => x.GetPhysicalAddress().ToString()) + .FirstOrDefault(x => !string.IsNullOrEmpty(x)); + } + + /// + /// Generates a new device identifier. Conditions that have to be met are: + /// + /// Randomly generated UUID/GUID + /// Follows the 8-4-4-4-12 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + /// Contains only lowercase characters and hyphens + /// + /// + /// The generated device identifier. + protected string GenerateDeviceId() => Guid.NewGuid().ToString("D").ToLowerInvariant(); + + /// + /// Generates a SHA-256 of the given value. + /// + protected string HashValue(string value) + { + var hashInput = s_sHA256.ComputeHash(Encoding.UTF8.GetBytes(value)); + return BitConverter.ToString(hashInput).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs new file mode 100644 index 0000000000..8d8f5b3f78 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Telemetry; + +internal static class TelemetryConstants +{ + /// + /// Name of tags published. + /// + internal class TagName + { + public const string AzureMcpVersion = "Version"; + public const string ClientName = "ClientName"; + public const string ClientVersion = "ClientVersion"; + public const string DevDeviceId = "DevDeviceId"; + public const string ErrorDetails = "ErrorDetails"; + public const string EventId = "EventId"; + public const string MacAddressHash = "MacAddressHash"; + public const string ResourceHash = "AzResourceHash"; + public const string SubscriptionGuid = "AzSubscriptionGuid"; + public const string ToolId = "ToolId"; + public const string ToolName = "ToolName"; + public const string ToolArea = "ToolArea"; + public const string ServerMode = "ServerMode"; + public const string IsServerCommandInvoked = "IsServerCommandInvoked"; + public const string Transport = "Transport"; + public const string IsReadOnly = "IsReadOnly"; + public const string Namespace = "Namespace"; + public const string ToolCount = "ToolCount"; + public const string InsecureDisableElicitation = "InsecureDisableElicitation"; + public const string IsDebug = "IsDebug"; + public const string DangerouslyDisableHttpIncomingAuth = "DangerouslyDisableHttpIncomingAuth"; + public const string Tool = "Tool"; + public const string VSCodeConversationId = "VSCodeConversationId"; + public const string VSCodeRequestId = "VSCodeRequestId"; + } + + internal class ActivityName + { + public const string CommandExecuted = "CommandExecuted"; + public const string ListToolsHandler = "ListToolsHandler"; + public const string ToolExecuted = "ToolExecuted"; + public const string ServerStarted = "ServerStarted"; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryLogRecordEraser.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryLogRecordEraser.cs new file mode 100644 index 0000000000..c08db9919c --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryLogRecordEraser.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OpenTelemetry; +using OpenTelemetry.Logs; + +namespace Azure.Mcp.Core.Services.Telemetry; + +/// +/// Prevents emitting telemetry events by OpenTelemetryExporter. Accomplishes this by clearing the log contents +/// sent when calling any log methods on . +/// +internal class TelemetryLogRecordEraser : BaseProcessor +{ + private static readonly IReadOnlyList> EmptyAttributes = new List>().AsReadOnly(); + + public override void OnEnd(LogRecord data) + { + data.Attributes = EmptyAttributes; + data.Body = string.Empty; + data.FormattedMessage = string.Empty; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs new file mode 100644 index 0000000000..45e84690f2 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Services.Telemetry; + +/// +/// Provides access to services. +/// +internal class TelemetryService : ITelemetryService +{ + private readonly IMachineInformationProvider _informationProvider; + private readonly bool _isEnabled; + private readonly ILogger _logger; + private readonly List> _tagsList; + private readonly SemaphoreSlim _initalizeLock = new(1); + + /// + /// Task created on the first invocation of . + /// This is saved so that repeated invocations will see the same exception + /// as the first invocation. + /// + private Task? _initalizationTask = null; + + private bool _initializationSuccessful; + private bool _isInitialized; + + internal ActivitySource Parent { get; } + + public TelemetryService(IMachineInformationProvider informationProvider, + IOptions options, + IOptions? serverOptions, + ILogger logger) + { + _isEnabled = options.Value.IsTelemetryEnabled; + _tagsList = + [ + new(TagName.AzureMcpVersion, options.Value.Version), + ]; + + if (serverOptions?.Value != null) + { + _tagsList.Add(new(TagName.ServerMode, serverOptions.Value.Mode)); + } + + Parent = new ActivitySource(options.Value.Name, options.Value.Version, _tagsList); + _informationProvider = informationProvider; + _logger = logger; + } + + /// + /// TESTING PURPOSES ONLY: Gets the default tags used for telemetry. + /// + internal IReadOnlyList> GetDefaultTags() + { + if (!_isEnabled) + { + return []; + } + + CheckInitialization(); + return [.. _tagsList]; + } + + /// + /// + /// + public Activity? StartActivity(string activityName) => StartActivity(activityName, null); + + /// + /// + /// + public Activity? StartActivity(string activityName, Implementation? clientInfo) + { + if (!_isEnabled) + { + return null; + } + + CheckInitialization(); + + var activity = Parent.StartActivity(activityName); + + if (activity == null) + { + return activity; + } + + if (clientInfo != null) + { + activity.AddTag(TagName.ClientName, clientInfo.Name) + .AddTag(TagName.ClientVersion, clientInfo.Version); + } + + activity.AddTag(TagName.EventId, Guid.NewGuid().ToString()); + + _tagsList.ForEach(kvp => activity.AddTag(kvp.Key, kvp.Value)); + + return activity; + } + + public void Dispose() + { + } + + /// + /// + /// + public async Task InitializeAsync() + { + if (!_isEnabled) + { + return; + } + + // Quick check if initialization already happened. Avoids + // trying to get the lock. + if (_initalizationTask == null) + { + // Get async lock for starting initialization + await _initalizeLock.WaitAsync(); + + try + { + // Check after acquiring lock to ensure we honor work + // started while we were waiting. + if (_initalizationTask == null) + { + _initalizationTask = InnerInitializeAsync(); + } + } + finally + { + _initalizeLock.Release(); + } + } + + // Await the response of the initialization work regardless of if + // we or another invocation created the Task representing it. All + // awaiting on this will give the same result to ensure idempotency. + await _initalizationTask; + + async Task InnerInitializeAsync() + { + try + { + var macAddressHash = await _informationProvider.GetMacAddressHash(); + var deviceId = await _informationProvider.GetOrCreateDeviceId(); + + _tagsList.Add(new(TagName.MacAddressHash, macAddressHash)); + _tagsList.Add(new(TagName.DevDeviceId, deviceId)); + + _initializationSuccessful = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred initializing telemetry service."); + throw; + } + finally + { + _isInitialized = true; + } + } + } + + private void CheckInitialization() + { + if (!_isInitialized) + { + throw new InvalidOperationException( + $"Telemetry service has not been initialized. Use {nameof(InitializeAsync)}() before any other operations."); + } + + if (!_initializationSuccessful) + { + throw new InvalidOperationException("Telemetry service was not successfully initialized. Check logs for initialization errors."); + } + + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/UnixMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/UnixMachineInformationProvider.cs new file mode 100644 index 0000000000..25e9cc92e1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/UnixMachineInformationProvider.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Telemetry; + +internal abstract class UnixMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + private readonly ILogger _logger = logger; + + /// + /// Gets the root folder to cache information to. + /// + /// If there is no folder to persist data in. + public abstract string GetStoragePath(); + + public override async Task GetOrCreateDeviceId() + { + string cachePath; + try + { + cachePath = Path.Combine(GetStoragePath(), MicrosoftDirectory, DeveloperToolsDirectory); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Unable to find folder to cache device id to."); + return null; + } + + var existingValue = await ReadValueFromDisk(cachePath, DeviceId); + if (existingValue != null) + { + return existingValue; + } + + var deviceId = GenerateDeviceId(); + if (await WriteValueToDisk(cachePath, DeviceId, deviceId)) + { + return deviceId; + } + else + { + _logger.LogWarning("Unable to persist deviceId."); + return null; + } + } + + /// + /// Try and write the value to disk. If is null or empty, this method will return false. + /// + /// Directory path to write the file. + /// The name of the file. + /// The value to write in the file. + /// True, if the value was successfully written. + /// + public async virtual Task WriteValueToDisk(string directoryPath, string fileName, string? value) + { + // If the value is not set, return immediately. + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + Directory.CreateDirectory(directoryPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to create directory. {Directory}", directoryPath); + return false; + } + + var fullPath = Path.Combine(directoryPath, fileName); + + try + { + File.Delete(fullPath); + + await File.WriteAllTextAsync(fullPath, value, Encoding.UTF8); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to write {Value} to disk. {FullPath}", value, fullPath); + return false; + } + } + + /// + /// Try and read the value from disk. If is null or empty, this method will return false. + /// + /// Returns a value if the value could be written on disk. Otherwise, false. + public async virtual Task ReadValueFromDisk(string directoryPath, string fileName) + { + var path = Path.Combine(directoryPath, fileName); + + if (!File.Exists(path)) + { + return null; + } + + try + { + var contents = await File.ReadAllTextAsync(path, Encoding.UTF8); + return contents; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to read value from {FullPath}", path); + return null; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/WindowsMachineInformationProvider.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/WindowsMachineInformationProvider.cs new file mode 100644 index 0000000000..26d5240799 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/WindowsMachineInformationProvider.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace Azure.Mcp.Core.Services.Telemetry; + +[SupportedOSPlatform("windows")] +internal class WindowsMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + // Construct the parts necessary to cache the ids in the registry. + // The final path is HKEY_CURRENT_USER/SOFTWARE/Microsoft/DeveloperTools + private const RegistryHive Hive = RegistryHive.CurrentUser; + private const string RegistryPathRoot = $"SOFTWARE\\{MicrosoftDirectory}\\{DeveloperToolsDirectory}"; + + private readonly ILogger _logger = logger; + + public override Task GetOrCreateDeviceId() + { + return Task.Run(() => + { + try + { + if (TryGetRegistryValue(RegistryPathRoot, DeviceId, out var existingDeviceId)) + { + return existingDeviceId; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to fetch {Key} value from {RegistryRoot}.", DeviceId, RegistryPathRoot); + } + + var newDeviceId = GenerateDeviceId(); + + try + { + if (TrySetRegistryValue(RegistryPathRoot, DeviceId, newDeviceId)) + { + return newDeviceId; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to persist {Key} in {RegistryPath}.", DeviceId, RegistryPathRoot); + } + + return newDeviceId; + }); + } + + private static bool TryGetRegistryValue(string registryRoot, string keyName, out string value) + { + using var registry = RegistryKey.OpenBaseKey(Hive, RegistryView.Registry64); + using var key = registry.OpenSubKey(registryRoot, writable: false); + + if (key == null) + { + value = string.Empty; + return false; + } + + var matchingKeyName = key.GetValueNames().SingleOrDefault(x => string.Equals(x, keyName), null); + if (matchingKeyName != null) + { + var existingValue = key.GetValue(matchingKeyName)?.ToString(); + value = existingValue ?? string.Empty; + + return !string.IsNullOrEmpty(existingValue); + } + + value = string.Empty; + return false; + } + + private static bool TrySetRegistryValue(string keyPath, string keyName, string value) + { + using var keyRoot = RegistryKey.OpenBaseKey(Hive, RegistryView.Registry64); + using var key = keyRoot.OpenSubKey(keyPath, writable: true) + ?? keyRoot.CreateSubKey(keyPath, RegistryKeyPermissionCheck.ReadWriteSubTree); + + if (key == null) + { + return false; + } + + key.SetValue(keyName, value, RegistryValueKind.String); + + return true; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Time/DateTimeProvider.cs b/core/Azure.Mcp.Core/src/Services/Time/DateTimeProvider.cs new file mode 100644 index 0000000000..6664709a85 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Time/DateTimeProvider.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Time; + +public sealed class DateTimeProvider : IDateTimeProvider +{ + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/core/Azure.Mcp.Core/src/Services/Time/IDateTimeProvider.cs b/core/Azure.Mcp.Core/src/Services/Time/IDateTimeProvider.cs new file mode 100644 index 0000000000..9982d27c45 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Time/IDateTimeProvider.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Services.Time; + +public interface IDateTimeProvider +{ + /// + /// Gets the current date and time in Coordinated Universal Time (UTC). + /// + DateTime UtcNow { get; } +} diff --git a/core/Azure.Mcp.Core/src/TestUtilities/ArgBuilder.cs b/core/Azure.Mcp.Core/src/TestUtilities/ArgBuilder.cs new file mode 100644 index 0000000000..85d15dddc1 --- /dev/null +++ b/core/Azure.Mcp.Core/src/TestUtilities/ArgBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.TestUtilities; + +public static class ArgBuilder +{ + // Build an args array, omitting any option whose name matches missingOption + public static string[] BuildArgs(string? missingOption, params (string name, string value)[] pairs) + { + var list = new List(); + foreach (var (name, value) in pairs) + { + if (missingOption != null && name == missingOption) + continue; + list.Add(name); + list.Add(value); + } + return [.. list]; + } +} diff --git a/core/Azure.Mcp.Core/src/TestUtilities/ArgSplitter.cs b/core/Azure.Mcp.Core/src/TestUtilities/ArgSplitter.cs new file mode 100644 index 0000000000..32117ac356 --- /dev/null +++ b/core/Azure.Mcp.Core/src/TestUtilities/ArgSplitter.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; + +namespace Azure.Mcp.TestUtilities; + +public static class ArgSplitter +{ + // Split command-line strings into arguments while respecting double quotes + public static string[] SplitArgs(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + return []; + + var args = new List(); + var current = new StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < commandLine.Length; i++) + { + var c = commandLine[i]; + if (c == '"') + { + // Determine if this quote is escaped by an odd number of backslashes + int backslashCount = 0; + for (int j = i - 1; j >= 0 && commandLine[j] == '\\'; j--) + backslashCount++; + + if (backslashCount % 2 == 1) + { + // Escaped quote: remove one escaping backslash already appended (if any) + if (current.Length > 0 && current[current.Length - 1] == '\\') + current.Length--; + current.Append('"'); + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (char.IsWhiteSpace(c) && !inQuotes) + { + if (current.Length > 0) + { + args.Add(current.ToString()); + current.Clear(); + } + } + else + { + current.Append(c); + } + } + + if (current.Length > 0) + args.Add(current.ToString()); + + return [.. args]; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs new file mode 100644 index 0000000000..5b276f2325 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs @@ -0,0 +1,791 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tests.Client.Helpers; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.Areas.Server; + +/// +/// Live integration tests for Azure MCP Server that validate tool loading behavior across different modes. +/// These tests start actual MCP server instances and verify the correct tools are loaded. +/// +public class ServerCommandTests(ITestOutputHelper output) +{ + protected ITestOutputHelper Output { get; } = output; + + private async Task CreateClientAsync(params string[] arguments) + { + var settingsFixture = new LiveTestSettingsFixture(); + await settingsFixture.InitializeAsync(); + var settings = settingsFixture.Settings; + + string executablePath = McpTestUtilities.GetAzMcpExecutablePath(); + + StdioClientTransportOptions transportOptions = new() + { + Name = "Test Server", + Command = executablePath, + Arguments = arguments, + StandardErrorLines = line => + { + // Safely capture stderr lines without accessing test context from background thread + try + { Output.WriteLine($"[MCP Server] {line}"); } + catch { /* Ignore if test context unavailable */ } + } + }; + + if (!string.IsNullOrEmpty(settings.TestPackage)) + { + Environment.CurrentDirectory = settings.SettingsDirectory; + transportOptions.Command = "npx"; + transportOptions.Arguments = ["-y", settings.TestPackage, .. arguments]; + } + + var clientTransport = new StdioClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport); + } + + #region Default Mode Tests + + [Fact] + public async Task DefaultMode_LoadsNamespaceTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + // Should have tools from multiple areas + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Default mode is now namespace mode, so should have namespace-level tools (not 60+ individual tools) + Assert.True(toolNames.Count > 20, $"Expected more than 20 namespace tools, got {toolNames.Count}"); + + // Should include the documentation tool (displayed by its title) + Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); + + // Log for debugging + Output.WriteLine($"Default mode (namespace) loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task DefaultMode_IncludesUtilityCommands() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should include subscription and group utility commands + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase) && name.Contains("list", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase) && name.Contains("list", StringComparison.OrdinalIgnoreCase)); + + // Log for debugging + Output.WriteLine($"Default mode loaded utility commands:"); + foreach (var name in toolNames.Where(n => n.Contains("subscription", StringComparison.OrdinalIgnoreCase) || n.Contains("group", StringComparison.OrdinalIgnoreCase))) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task DefaultMode_CanCallSubscriptionList() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var result = await client.CallToolAsync("subscription_list", new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain subscription data (even if empty list) + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Subscription list result: {firstContent}"); + } + + [Fact] + public async Task DefaultMode_CanCallGroupList() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var result = await client.CallToolAsync("group_list", new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain resource group data (even if empty list) + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Group list result: {firstContent}"); + } + + #endregion + + #region All Mode Tests + + [Fact] + public async Task AllMode_LoadsAllIndividualTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "all"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + // Should have tools from multiple areas + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should include Azure service tools and extension tools (all individual tools) + Assert.True(toolNames.Count > 60, $"Expected more than 60 individual tools, got {toolNames.Count}"); + + // Should include the microsoft_docs_search tool + Assert.Contains("microsoft_docs_search", toolNames, StringComparer.OrdinalIgnoreCase); + + // Log for debugging + Output.WriteLine($"All mode loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + #endregion + + #region Single Tool Proxy Mode Tests + + [Fact] + public async Task SingleProxyMode_LoadsSingleAzureTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "single"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Single(listResult); + + var tool = listResult.First(); + Assert.Equal("azure", tool.Name); + Assert.Contains("azure", tool.Description, StringComparison.OrdinalIgnoreCase); + + Output.WriteLine($"Single proxy mode loaded 1 tool: {tool.Name}"); + Output.WriteLine($"Description: {tool.Description}"); + } + + [Fact] + public async Task SingleProxyMode_WithNamespaceFilter_StillLoadsSingleAzureTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "single", "--namespace", "storage"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + // Single proxy mode should still expose single azure tool regardless of namespace filter + Assert.Single(listResult); + Assert.Equal("azure", listResult.First().Name); + + Output.WriteLine("Single proxy mode with namespace filter still loaded 1 tool"); + } + + [Fact] + public async Task SingleProxyMode_WithReadOnlyFlag_LoadsSingleAzureTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "single", "--read-only"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Single(listResult); + Assert.Equal("azure", listResult.First().Name); + + // Verify read-only behavior for single proxy mode + var tool = listResult.First(); + var readOnlyHint = tool.ProtocolTool?.Annotations?.ReadOnlyHint; + + // The azure tool should have read-only hint when in read-only mode + Output.WriteLine($"Single proxy read-only tool ReadOnlyHint: {readOnlyHint}"); + Output.WriteLine($"Tool annotations: {tool.ProtocolTool?.Annotations != null}"); + + // For single proxy mode, the ReadOnlyHint may be null because it's a proxy tool + // but we should still have annotations present + Assert.NotNull(tool.ProtocolTool?.Annotations); + + Output.WriteLine("Single proxy read-only mode loaded 1 tool"); + } + + #endregion + + #region Namespace Proxy Mode Tests + + [Fact] + public async Task NamespaceProxyMode_LoadsNamespaceTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // In namespace mode without specific namespaces, should default to extension tools + Assert.True(toolNames.Count > 20, "Should have more than 20 tools in namespace mode"); + + // Should include the documentation tool (displayed by its title) + Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); + + Output.WriteLine($"Namespace proxy mode loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task NamespaceProxyMode_WithSpecificNamespaces_LoadsNamespaceSpecificTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace", "--namespace", "storage", "--namespace", "keyvault"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should include namespace-specific tools + var hasRelevantTools = toolNames.Any(name => + name.Contains("storage", StringComparison.OrdinalIgnoreCase) || + name.Contains("keyvault", StringComparison.OrdinalIgnoreCase)); + + Assert.True(hasRelevantTools, "Should have tools related to specified namespaces"); + + // Should not include documentation tool when explicit namespaces are specified + Assert.DoesNotContain("documentation", toolNames, StringComparer.OrdinalIgnoreCase); + + // Should contain exactly 4 tools: 2 specified namespaces + 2 utility tools (group_list, subscription_list) + Assert.Equal(4, toolNames.Count); + + // Verify tools are from storage, keyvault namespaces, or utility tools + Assert.All(toolNames, toolName => + { + var isStorageOrKeyVault = toolName.Contains("storage", StringComparison.OrdinalIgnoreCase) || + toolName.Contains("keyvault", StringComparison.OrdinalIgnoreCase); + var isUtilityTool = toolName.Contains("group", StringComparison.OrdinalIgnoreCase) || + toolName.Contains("subscription", StringComparison.OrdinalIgnoreCase); + Assert.True(isStorageOrKeyVault || isUtilityTool, $"Tool '{toolName}' should be related to storage, keyvault namespaces, or be a utility tool"); + }); + + Output.WriteLine($"Namespace proxy mode with [storage, keyvault] loaded {toolNames.Count} tools"); + } + + [Fact] + public async Task NamespaceProxyMode_WithDocumentationNamespace_LoadsOnlyDocumentationTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace", "--namespace", "documentation"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should contain the documentation tool (displayed by its title) plus utility tools + Assert.Equal(3, listResult.Count()); + Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase)); + + Output.WriteLine($"Namespace proxy mode with [documentation] loaded {toolNames.Count} tools"); + Output.WriteLine($"Tool: {toolNames.First()}"); + } + + [Fact] + public async Task NamespaceProxyMode_StorageToolLearnMode_ReturnsStorageCommands() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace"); + + // Act - Call storage tool in learn mode + var learnParameters = new Dictionary + { + ["learn"] = true + }; + + var result = await client.CallToolAsync("storage", learnParameters, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Get the text content + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + + var responseText = textContent.Text; + + // Verify the response contains information about storage commands + Assert.Contains("available command", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("storage", responseText, StringComparison.OrdinalIgnoreCase); + + // Verify it contains specific storage commands we expect + Assert.Contains("storage_account_get", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("storage_blob_container_get", responseText, StringComparison.OrdinalIgnoreCase); + + Output.WriteLine("Storage tool learn mode response:"); + Output.WriteLine(responseText); + Output.WriteLine($"✓ Learn mode returned {responseText.Length} characters of storage command information"); + } + + #endregion + + #region Default Mode with Filters Tests + + [Fact] + public async Task DefaultMode_WithNamespaceFilter_LoadsFilteredTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--namespace", "storage", "--namespace", "keyvault"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + Assert.Equal(4, listResult.Count()); // 2 specified namespaces + 2 utility tools + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include tools from specified namespaces + var hasStorageOrKeyVault = toolNames.Any(name => + name.Contains("storage", StringComparison.OrdinalIgnoreCase) || + name.Contains("keyvault", StringComparison.OrdinalIgnoreCase)); + + Assert.True(hasStorageOrKeyVault, "Should have tools related to storage or keyvault namespaces"); + + Output.WriteLine($"Default mode with namespaces [storage, keyvault] loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task AllMode_WithNamespaceFilter_LoadsFilteredIndividualTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "all", "--namespace", "storage", "--namespace", "keyvault"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include individual tools from specified namespaces + var hasStorageOrKeyVault = toolNames.Any(name => + name.Contains("storage", StringComparison.OrdinalIgnoreCase) || + name.Contains("keyvault", StringComparison.OrdinalIgnoreCase)); + + Assert.True(hasStorageOrKeyVault, "Should have tools related to storage or keyvault namespaces"); + + // In all mode with namespace filter, should have more individual tools than namespace mode + Assert.True(toolNames.Count > 2, $"Expected more than 2 individual tools, got {toolNames.Count}"); + + Output.WriteLine($"All mode with namespaces [storage, keyvault] loaded {toolNames.Count} tools"); + } + + [Fact] + public async Task AllMode_WithReadOnlyFlag_LoadsOnlyReadOnlyTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "all", "--read-only"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + // All tools should be read-only or the count should be reduced + var toolCount = listResult.Count(); + Assert.True(toolCount > 0, "Should have at least some read-only tools"); + + // Verify tools have read-only annotations + var toolsWithReadOnlyHint = 0; + var toolsWithAnnotations = 0; + + foreach (var tool in listResult) + { + var hasAnnotations = tool.ProtocolTool?.Annotations != null; + var readOnlyHint = tool.ProtocolTool?.Annotations?.ReadOnlyHint; + + if (hasAnnotations) + { + toolsWithAnnotations++; + } + + if (readOnlyHint.HasValue && readOnlyHint.Value) + { + toolsWithReadOnlyHint++; + } + + Output.WriteLine($"Tool: {tool.Name} - HasAnnotations: {hasAnnotations}, ReadOnlyHint: {readOnlyHint}"); + } + + Output.WriteLine($"Tools with annotations: {toolsWithAnnotations}/{toolCount}"); + Output.WriteLine($"Tools with ReadOnlyHint=true: {toolsWithReadOnlyHint}/{toolCount}"); + + // In read-only mode, ALL tools must have annotations and ReadOnlyHint=true + Assert.Equal(toolCount, toolsWithAnnotations); + Assert.Equal(toolCount, toolsWithReadOnlyHint); + + // Additional verification message + Output.WriteLine("✓ All tools have annotations and ReadOnlyHint=true as expected in read-only mode"); + + Output.WriteLine($"Default read-only mode loaded {toolCount} tools"); + } + + #endregion + + #region Negative Tests - Invalid Modes and Namespaces + + [Fact] + public async Task InvalidMode_FailsToStartServer() + { + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await using var client = await CreateClientAsync("server", "start", "--mode", "invalid-mode"); + }); + } + + [Fact] + public async Task InvalidNamespace_LoadsGracefully() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--namespace", "invalid-namespace", "--namespace", "another-invalid"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + // Should not crash, but may have fewer tools + Assert.NotNull(listResult); + + // Invalid namespaces should result in only utility tools (group_list, subscription_list) + Assert.Equal(2, listResult.Count()); + var toolNames = listResult.Select(t => t.Name).ToList(); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase)); + + Output.WriteLine($"Invalid namespaces loaded {listResult.Count()} tools"); + } + + #endregion + + #region Comparison Tests + + [Fact] + public async Task VerifyUniqueToolNames_InAllMode() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "all"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + var toolNames = listResult.Select(t => t.Name).ToList(); + var uniqueNames = toolNames.Distinct().ToList(); + + Assert.Equal(toolNames.Count, uniqueNames.Count); + + Output.WriteLine($"Verified {toolNames.Count} unique tool names in all mode"); + } + + [Fact] + public async Task VerifyUniqueToolNames_InDefaultMode() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + var toolNames = listResult.Select(t => t.Name).ToList(); + var uniqueNames = toolNames.Distinct().ToList(); + + Assert.Equal(toolNames.Count, uniqueNames.Count); + + Output.WriteLine($"Verified {toolNames.Count} unique tool names in default (namespace) mode"); + } + + #endregion + + #region Consolidated Proxy Mode Tests + + [Fact] + public async Task ConsolidatedProxyMode_LoadsConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // In consolidated mode, should have consolidated tools grouping related operations + Assert.True(toolNames.Count > 20, $"Expected more than 20 consolidated tools, got {toolNames.Count}"); + + // Should include some known consolidated tools + Assert.Contains(toolNames, name => name.Contains("azure_subscriptions_and_resource_groups", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("azure_databases_details", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("azure_storage_details", StringComparison.OrdinalIgnoreCase)); + + Output.WriteLine($"Consolidated proxy mode loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_ToolLearnMode_ReturnsConsolidatedCommands() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act - Call a consolidated tool in learn mode + var learnParameters = new Dictionary + { + ["learn"] = true + }; + + var result = await client.CallToolAsync("get_azure_databases_details", learnParameters, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Get the text content + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + + var responseText = textContent.Text; + + // Verify the response contains information about database commands + Assert.Contains("available command", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("database", responseText, StringComparison.OrdinalIgnoreCase); + + // Verify it contains multiple database-related commands + Assert.Contains("mysql", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("postgres", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sql", responseText, StringComparison.OrdinalIgnoreCase); + + Output.WriteLine("Consolidated database tool learn mode response:"); + Output.WriteLine(responseText); + Output.WriteLine($"✓ Learn mode returned {responseText.Length} characters of consolidated command information"); + } + + [Fact] + public async Task ConsolidatedProxyMode_WithNamespaceFilter_LoadsFilteredConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated", "--namespace", "storage"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include consolidated tools related to specified namespaces + var hasRelevantTools = toolNames.Any(name => + name.Contains("storage", StringComparison.OrdinalIgnoreCase)); + + Assert.True(hasRelevantTools, "Should have consolidated tools related to storage namespaces"); + + // In consolidated mode with namespace filter, should have fewer tools than without filter + Assert.True(toolNames.Count < 10, $"Expected fewer than 10 tools with namespace filter, got {toolNames.Count}"); + + Output.WriteLine($"Consolidated proxy mode with [storage] namespaces loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_WithReadOnlyFlag_LoadsOnlyReadOnlyConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated", "--read-only"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolCount = listResult.Count(); + Assert.True(toolCount > 0, "Should have at least some read-only consolidated tools"); + + // Verify all tools have read-only annotations + var toolsWithReadOnlyHint = 0; + var toolsWithAnnotations = 0; + + foreach (var tool in listResult) + { + var hasAnnotations = tool.ProtocolTool?.Annotations != null; + var readOnlyHint = tool.ProtocolTool?.Annotations?.ReadOnlyHint; + + if (hasAnnotations) + { + toolsWithAnnotations++; + } + + if (readOnlyHint.HasValue && readOnlyHint.Value) + { + toolsWithReadOnlyHint++; + } + + // Verify tool names don't contain destructive operations + Assert.DoesNotContain("create_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("edit_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("delete_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("update_", tool.Name, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_CanCallConsolidatedTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act - Call the consolidated subscriptions and resource groups tool + var result = await client.CallToolAsync("get_azure_subscriptions_and_resource_groups", + new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain subscription and resource group data + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Consolidated tool result: {firstContent}"); + } + + #endregion + + #region Tool Mode Tests + + [Fact] + public async Task ToolMode_AutomaticallyChangesToAllMode() + { + // Arrange - Test that --tool switch automatically changes mode to "all" + await using var client = await CreateClientAsync("server", "start", "--tool", "group_list", "--tool", "subscription_list"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include the specified tools + Assert.Equal(2, toolNames.Count); + Assert.Contains("group_list", toolNames); + Assert.Contains("subscription_list", toolNames); + } + + [Fact] + public async Task ToolMode_OverridesExplicitNamespaceMode() + { + // Arrange - Test that --tool switch overrides --mode namespace + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace", "--tool", "group_list"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include the specified tool, mode should be automatically changed to "all" + Assert.Single(toolNames); + Assert.Contains("group_list", toolNames); + } + + #endregion +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj new file mode 100644 index 0000000000..45eb7dff93 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj @@ -0,0 +1,18 @@ + + + true + Exe + + + + + + + + + + + + + + diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs new file mode 100644 index 0000000000..56c4a5853c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tests; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Helpers; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests; + +public class ClientToolTests(ITestOutputHelper output) : CommandTestsBase(output) +{ + + [Fact] + public async Task Should_List_Tools() + { + var tools = await Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotEmpty(tools); + } + + [Fact] + public async Task Client_Should_Invoke_Tool_Successfully() + { + var result = await Client.CallToolAsync("subscription_list", new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + string? content = McpTestUtilities.GetFirstText(result.Content); + + Assert.False(string.IsNullOrWhiteSpace(content)); + + var root = JsonSerializer.Deserialize(content!); + Assert.Equal(JsonValueKind.Object, root.ValueKind); + + var results = root.AssertProperty("results"); + var subscriptionsArray = results.AssertProperty("subscriptions"); + Assert.Equal(JsonValueKind.Array, subscriptionsArray.ValueKind); + + Assert.NotEmpty(subscriptionsArray.EnumerateArray()); + } + + [Fact] + public async Task Client_Should_Handle_Invalid_Tools() + { + var result = await Client.CallToolAsync("non_existent_tool", new Dictionary(), cancellationToken: TestContext.Current.CancellationToken); + + // When calling a non-existent tool, the server should return an error response + Assert.True(result.IsError, "Expected error response for non-existent tool"); + + string? content = McpTestUtilities.GetFirstText(result.Content); + Assert.False(string.IsNullOrWhiteSpace(content), "Expected error message content"); + Assert.Contains("The tool non_existent_tool was not found", content); + } + + [Fact] + public async Task Client_Should_Ping_Server_Successfully() + { + await Client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); + // If no exception is thrown, the ping was successful. + } + + [Fact] + public async Task Should_Error_When_Resources_List_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Error_When_Resources_Read_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.ReadResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Error_When_Resources_Templates_List_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Error_When_Resources_Subscribe_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.SubscribeToResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Error_When_Resources_Unsubscribe_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.UnsubscribeFromResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Not_Hang_On_Logging_SetLevel_Not_Supported() + { + await Client.SetLoggingLevel(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Should_Error_When_Prompts_List_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Should_Error_When_Prompts_Get_Not_Supported() + { + var ex = await Assert.ThrowsAsync(async () => await Client.GetPromptAsync("unsupported_prompt", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("Request failed", ex.Message); + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs new file mode 100644 index 0000000000..09152b5606 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tests; +using Azure.Mcp.Tests.Client; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests; + +public class CommandTests(ITestOutputHelper output) : CommandTestsBase(output) +{ + [Fact] + public async Task Should_list_groups_by_subscription() + { + var result = await CallToolAsync( + "group_list", + new() + { + { "subscription", Settings.SubscriptionId } + }); + + var groupsArray = result.AssertProperty("groups"); + Assert.Equal(JsonValueKind.Array, groupsArray.ValueKind); + Assert.NotEmpty(groupsArray.EnumerateArray()); + } + + [Fact] + public async Task Should_list_subscriptions() + { + var result = await CallToolAsync( + "subscription_list", + new Dictionary()); + + var subscriptionsArray = result.AssertProperty("subscriptions"); + Assert.Equal(JsonValueKind.Array, subscriptionsArray.ValueKind); + Assert.NotEmpty(subscriptionsArray.EnumerateArray()); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/NpxTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/NpxTests.cs new file mode 100644 index 0000000000..7af7d18f5d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/NpxTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests; + +public class NpxTests : IClassFixture +{ + private readonly LiveTestSettings _settings; + + public NpxTests(LiveTestSettingsFixture fixture) + { + _settings = fixture.Settings; + + if (string.IsNullOrEmpty(_settings.TestPackage)) + { + Assert.Skip("Can only test packages "); + } + } + + [Fact] + public async Task Help_command_should_return_help() + { + var outputLines = await RunCommand("--help"); + var concatenatedOutput = string.Join(Environment.NewLine, outputLines.Output); + Assert.NotEmpty(concatenatedOutput); + Assert.Contains("azmcp [command] [options]", concatenatedOutput); + } + + private async Task<(string[] Output, string[] Error, int ExitCode)> RunCommand(params string[] arguments) + { + var shell = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh"; + var shellArgument = OperatingSystem.IsWindows() ? "/c" : "-c"; + + // Construct the npx command + var npxCommand = $"npx -y {_settings.TestPackage} {string.Join(" ", arguments)}"; + + var processStartInfo = new ProcessStartInfo + { + FileName = shell, + ArgumentList = { shellArgument, npxCommand }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process + { + StartInfo = processStartInfo + }; + + process.Start(); + + var output = new List(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + output.Add(e.Data); + } + }; + process.BeginOutputReadLine(); + + var error = new List(); + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + error.Add(e.Data); + } + }; + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + return (output.ToArray(), error.ToArray(), process.ExitCode); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs new file mode 100644 index 0000000000..c8520efd34 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO; +using System.Text; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.RecordingFramework; + +/// +/// Harness for testing RecordedCommandTestsBase functionality. Intended for proper abstraction of livetest settings etc to allow both record and playback modes in the same test for full roundtrip testing. +/// +/// +/// +internal sealed class RecordedCommandTestHarness(ITestOutputHelper output, TestProxyFixture fixture) : RecordedCommandTestsBase(output, fixture) +{ + public TestMode DesiredMode { get; set; } = TestMode.Record; + + public IReadOnlyDictionary Variables => TestVariables; + + public string GetRecordingAbsolutePath(string displayName) + { + var sanitized = RecordingPathResolver.Sanitize(displayName); + var relativeDirectory = PathResolver.GetSessionDirectory(GetType(), variantSuffix: null) + .Replace('/', Path.DirectorySeparatorChar); + var fileName = RecordingPathResolver.BuildFileName(sanitized, IsAsync, VersionQualifier); + var absoluteDirectory = Path.Combine(PathResolver.RepositoryRoot, relativeDirectory); + Directory.CreateDirectory(absoluteDirectory); + return Path.Combine(absoluteDirectory, fileName); + } + + protected override ValueTask LoadSettingsAsync() + { + Settings = new LiveTestSettings + { + SubscriptionId = "00000000-0000-0000-0000-000000000000", + TenantId = "00000000-0000-0000-0000-000000000000", + ResourceBaseName = "Sanitized", + SubscriptionName = "Sanitized", + TenantName = "Sanitized", + TestMode = TestMode.Playback + }; + + Settings.TestMode = DesiredMode; + TestMode = DesiredMode; + + return ValueTask.CompletedTask; + } + + public void ResetVariables() + { + TestVariables.Clear(); + } + + public string GetRecordingId() + { + return RecordingId; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs new file mode 100644 index 0000000000..8c99fd9027 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Reflection; +using System.Text.Json; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.FileSystemGlobbing; +using NSubstitute; +using Xunit; +using Xunit.v3; + +namespace Azure.Mcp.Core.LiveTests.RecordingFramework; + +public sealed class RecordedCommandTestsBaseTest : IAsyncLifetime +{ + private string RecordingFileLocation = string.Empty; + private string TestDisplayName = string.Empty; + private TestProxyFixture Fixture = new TestProxyFixture(); + private ITestOutputHelper CollectedOutput = Substitute.For(); + private RecordedCommandTestHarness? DefaultHarness; + + [Fact] + public async Task ProxyRecordProducesRecording() + { + await DefaultHarness!.InitializeAsync(); + + Assert.NotNull(Fixture.Proxy); + Assert.False(string.IsNullOrWhiteSpace(Fixture.Proxy!.BaseUri)); + + DefaultHarness!.RegisterVariable("sampleKey", "sampleValue"); + await DefaultHarness!.DisposeAsync(); + + Assert.True(File.Exists(RecordingFileLocation)); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(RecordingFileLocation, TestContext.Current.CancellationToken)); + Assert.True(document.RootElement.TryGetProperty("Variables", out var variablesElement)); + Assert.Equal("sampleValue", variablesElement.GetProperty("sampleKey").GetString()); + } + + [CustomMatcher(IgnoreQueryOrdering = true, CompareBodies = true)] + [Fact] + public async Task PerTestMatcherAttributeAppliesWhenPresent() + { + var activeMatcher = GetActiveMatcher(); + Assert.NotNull(activeMatcher); + Assert.True(activeMatcher!.CompareBodies); + Assert.True(activeMatcher.IgnoreQueryOrdering); + + DefaultHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record, + EnableDefaultSanitizerAdditions = false, + }; + var recordingId = string.Empty; + + await DefaultHarness.InitializeAsync(); + DefaultHarness.RegisterVariable("attrKey", "attrValue"); + await DefaultHarness.DisposeAsync(); + + var playbackHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Playback, + EnableDefaultSanitizerAdditions = false, + }; + + await playbackHarness.InitializeAsync(); + recordingId = playbackHarness.GetRecordingId(); + await playbackHarness.DisposeAsync(); + + CollectedOutput.Received().WriteLine(Arg.Is(s => s.Contains($"Applying custom matcher to recordingId \"{recordingId}\""))); + } + + [Fact] + public void CustomMatcherAttributeClearsAfterExecution() + { + var attribute = new CustomMatcherAttribute(compareBody: true, ignoreQueryordering: true); + var xunitTest = Substitute.For(); + var methodInfo = typeof(RecordedCommandTestsBaseTest).GetMethod(nameof(CustomMatcherAttributeClearsAfterExecution)) + ?? throw new InvalidOperationException("Unable to locate test method for CustomMatcherAttribute verification."); + + attribute.Before(methodInfo, xunitTest); + try + { + var active = GetActiveMatcher(); + Assert.Same(attribute, active); + Assert.True(active!.CompareBodies); + Assert.True(active.IgnoreQueryOrdering); + } + finally + { + attribute.After(methodInfo, xunitTest); + } + + Assert.Null(GetActiveMatcher()); + } + + private static CustomMatcherAttribute? GetActiveMatcher() + { + var method = typeof(CustomMatcherAttribute).GetMethod("GetActive", BindingFlags.NonPublic | BindingFlags.Static); + return (CustomMatcherAttribute?)method?.Invoke(null, null); + } + + [Fact] + public async Task GlobalMatcherAndSanitizerAppliesWhenPresent() + { + DefaultHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record, + TestMatcher = new CustomDefaultMatcher + { + CompareBodies = true, + IgnoreQueryOrdering = true, + } + }; + + DefaultHarness.GeneralRegexSanitizers.Add(new GeneralRegexSanitizer(new GeneralRegexSanitizerBody + { + Regex = "sample", + Value = "sanitized", + })); + DefaultHarness.DisabledDefaultSanitizers.Add("UriSubscriptionIdSanitizer"); + + await DefaultHarness.InitializeAsync(); + await DefaultHarness.DisposeAsync(); + + CollectedOutput.Received().WriteLine(Arg.Is(s => s.Contains("Applying custom matcher to global settings"))); + } + + [Fact] + public async Task VariableSurvivesRecordPlaybackRoundtrip() + { + await DefaultHarness!.InitializeAsync(); + DefaultHarness.RegisterVariable("roundtrip", "value"); + await DefaultHarness.DisposeAsync(); + + var playbackHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Playback, + }; + await playbackHarness.InitializeAsync(); + Assert.True(playbackHarness.Variables.TryGetValue("roundtrip", out var variableValue)); + Assert.Equal("value", variableValue); + await playbackHarness.DisposeAsync(); + } + + public ValueTask InitializeAsync() + { + TestDisplayName = TestContext.Current?.Test?.TestCase?.TestCaseDisplayName ?? throw new InvalidDataException("Test case display name is not available."); + + var harness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record + }; + + RecordingFileLocation = harness.GetRecordingAbsolutePath(TestDisplayName); + + if (File.Exists(RecordingFileLocation)) + { + File.Delete(RecordingFileLocation); + } + + DefaultHarness = harness; + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + // always clean up this recording file on our way out of the test if it exists + if (File.Exists(RecordingFileLocation)) + { + File.Delete(RecordingFileLocation); + } + + // automatically collect the proxy fixture so that writers of tests don't need to remember to do so and the proxy process doesn't run forever + await Fixture.DisposeAsync(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs new file mode 100644 index 0000000000..1e67715024 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using Azure.Identity.Broker; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Tests; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.Services.Azure.Authentication; + +public class AuthenticationIntegrationTests : IAsyncLifetime +{ + private readonly ServiceProvider _serviceProvider; + private readonly ISubscriptionService _subscriptionService; + private readonly ITestOutputHelper _output; + + public AuthenticationIntegrationTests(ITestOutputHelper output) + { + _output = output; + + // Set up real service dependencies for integration test + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _subscriptionService = _serviceProvider.GetRequiredService(); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public async ValueTask DisposeAsync() + { + await _serviceProvider.DisposeAsync(); + } + + [Fact] + public async Task LoginWithIdentityBroker_ThenListSubscriptions_ShouldSucceed() + { + Assert.SkipWhen(TestExtensions.IsRunningFromDotnetTest(), TestExtensions.RunningFromDotnetTestReason); + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.OSX), "Identity broker is not supported on MacOS"); + + _output.WriteLine("Testing InteractiveBrowserCredential with identity broker..."); + + await AuthenticateWithBrokerAsync(); + _output.WriteLine("Successfully authenticated with identity broker"); + + // Step 2: Now test the subscription service which will use our CustomChainedCredential internally + _output.WriteLine("Testing subscription listing with authenticated credential..."); + + var subscriptions = await _subscriptionService.GetSubscriptions(cancellationToken: TestContext.Current.CancellationToken); + ValidateAndLogSubscriptions(subscriptions); + } + + private static async Task AuthenticateWithBrokerAsync() + { + var browserCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialBrokerOptions(WindowHandleProvider.GetWindowHandle()) + ); + + // Verify the credential works by requesting a token + var armScope = "https://management.azure.com/.default"; + var context = new TokenRequestContext([armScope]); + var token = await browserCredential.GetTokenAsync(context, TestContext.Current.CancellationToken); + + Assert.NotNull(token.Token); + Assert.NotEqual(default, token.ExpiresOn); + + return browserCredential; + } + private void ValidateAndLogSubscriptions(List subscriptions) + { + // Validate subscriptions + Assert.NotNull(subscriptions); + Assert.NotEmpty(subscriptions); + + // Verify subscription data structure + foreach (var subscription in subscriptions) + { + Assert.NotNull(subscription.SubscriptionId); + Assert.NotEmpty(subscription.SubscriptionId); + Assert.NotNull(subscription.DisplayName); + Assert.NotEmpty(subscription.DisplayName); + } + + // Output subscriptions for manual verification + var jsonString = JsonSerializer.Serialize(subscriptions, s_writeIndentedOptions); + _output.WriteLine($"Retrieved {subscriptions.Count} subscriptions:"); + _output.WriteLine(jsonString); + } + + private static readonly JsonSerializerOptions s_writeIndentedOptions = new() { WriteIndented = true }; +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/LinuxInformationProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/LinuxInformationProviderTests.cs new file mode 100644 index 0000000000..9b5a740475 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/LinuxInformationProviderTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.Services.Telemetry; + +[SupportedOSPlatform("linux")] +public class LinuxInformationProviderTests +{ + [Fact] + [Trait("Category", "Live")] + public async Task GetOrCreateDeviceId_WorksCorrectly() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Only supported on Linux."); + + // Arrange + var _logger = Substitute.For>(); + var provider = new LinuxMachineInformationProvider(_logger); + + // Act + var deviceId = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.NotNull(deviceId); + Assert.NotEmpty(deviceId); + + // Verify it's persisted by calling again + var deviceId2 = await provider.GetOrCreateDeviceId(); + Assert.Equal(deviceId, deviceId2); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/MacOSXInformationProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/MacOSXInformationProviderTests.cs new file mode 100644 index 0000000000..7239f86fab --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/MacOSXInformationProviderTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.Services.Telemetry; + +[SupportedOSPlatform("osx")] +public class MacOSXInformationProviderTests +{ + [Fact] + [Trait("Category", "Live")] + public async Task GetOrCreateDeviceId_WorksCorrectly() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Only supported on Linux."); + + // Arrange + var _logger = Substitute.For>(); + var provider = new MacOSXMachineInformationProvider(_logger); + + // Act + var deviceId = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.NotNull(deviceId); + Assert.NotEmpty(deviceId); + + // Verify it's persisted by calling again + var deviceId2 = await provider.GetOrCreateDeviceId(); + Assert.Equal(deviceId, deviceId2); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/WindowsInformationProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/WindowsInformationProviderTests.cs new file mode 100644 index 0000000000..388423a5f3 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Telemetry/WindowsInformationProviderTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.Services.Telemetry; + +[SupportedOSPlatform("windows")] +public class WindowsInformationProviderTests +{ + [Fact] + [Trait("Category", "Live")] + public async Task GetOrCreateDeviceId_WorksCorrectly() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Only supported on Windows."); + + // Arrange + var _logger = Substitute.For>(); + var provider = new WindowsMachineInformationProvider(_logger); + + // Act + var deviceId = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.NotNull(deviceId); + Assert.NotEmpty(deviceId); + + // Verify it's persisted by calling again + var deviceId2 = await provider.GetOrCreateDeviceId(); + Assert.Equal(deviceId, deviceId2); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs new file mode 100644 index 0000000000..0963859f3e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Group.Commands; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Models.ResourceGroup; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.ResourceGroup; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Group.UnitTests; + +public class GroupListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly McpServer _mcpServer; + private readonly ILogger _logger; + private readonly IResourceGroupService _resourceGroupService; + private readonly GroupListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public GroupListCommandTests() + { + _mcpServer = Substitute.For(); + _resourceGroupService = Substitute.For(); + _logger = Substitute.For>(); + var collection = new ServiceCollection() + .AddSingleton(_mcpServer) + .AddSingleton(_resourceGroupService); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_WithValidSubscription_ReturnsResourceGroups() + { + // Arrange + var subscriptionId = "test-subs-id"; + var expectedGroups = new List + { + ResourceGroupTestHelpers.CreateResourceGroupInfo("rg1", subscriptionId, "East US"), + ResourceGroupTestHelpers.CreateResourceGroupInfo("rg2", subscriptionId, "West US") + }; + + _resourceGroupService + .GetResourceGroups(Arg.Is(x => x == subscriptionId), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedGroups); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(result.Results)); + var groupsArray = jsonDoc.RootElement.GetProperty("groups"); + + Assert.Equal(2, groupsArray.GetArrayLength()); + + var first = groupsArray[0]; + var second = groupsArray[1]; + + Assert.Equal("rg1", first.GetProperty("name").GetString()); + Assert.Equal("/subscriptions/test-subs-id/resourceGroups/rg1", first.GetProperty("id").GetString()); + Assert.Equal("East US", first.GetProperty("location").GetString()); + + Assert.Equal("rg2", second.GetProperty("name").GetString()); + Assert.Equal("/subscriptions/test-subs-id/resourceGroups/rg2", second.GetProperty("id").GetString()); + Assert.Equal("West US", second.GetProperty("location").GetString()); + + await _resourceGroupService.Received(1).GetResourceGroups( + Arg.Is(x => x == subscriptionId), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithTenant_PassesTenantToService() + { + // Arrange + var subscriptionId = "test-subs-id"; + var tenantId = "test-tenant-id"; + var expectedGroups = new List + { + ResourceGroupTestHelpers.CreateResourceGroupInfo("rg1", subscriptionId, "East US") + }; + + _resourceGroupService + .GetResourceGroups( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == tenantId), + Arg.Any(), + Arg.Any()) + .Returns(expectedGroups); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId} --tenant {tenantId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + await _resourceGroupService.Received(1).GetResourceGroups( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == tenantId), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_EmptyResourceGroupList_ReturnsNullResults() + { + // Arrange + var subscriptionId = "test-subs-id"; + _resourceGroupService + .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.Null(result.Results); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorInResponse() + { + // Arrange + var subscriptionId = "test-subs-id"; + var expectedError = "Test error message"; + _resourceGroupService + .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException>(new Exception(expectedError))); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.InternalServerError, result.Status); + Assert.Contains(expectedError, result.Message); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ArrayOrCollectionElementTypeTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ArrayOrCollectionElementTypeTests.cs new file mode 100644 index 0000000000..adc426ea49 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ArrayOrCollectionElementTypeTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Azure.Mcp.Core.Areas.Server.Commands; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class ArrayOrCollectionElementTypeTests +{ + [Theory] + [InlineData(typeof(string[]), typeof(string))] + [InlineData(typeof(int[]), typeof(int))] + [InlineData(typeof(object[]), typeof(object))] + public void GetArrayElementType_WithArrayTypes_ReturnsElementType(Type arrayType, Type expectedElementType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(arrayType); + + // Assert + Assert.Equal(expectedElementType, result); + } + + [Theory] + [InlineData(typeof(List), typeof(string))] + [InlineData(typeof(List), typeof(int))] + [InlineData(typeof(IList), typeof(object))] + [InlineData(typeof(IEnumerable), typeof(bool))] + [InlineData(typeof(ICollection), typeof(DateTime))] + public void GetArrayElementType_WithGenericCollectionTypes_ReturnsElementType(Type collectionType, Type expectedElementType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(collectionType); + + // Assert + Assert.Equal(expectedElementType, result); + } + + [Theory] + [InlineData(typeof(ArrayList), typeof(object))] + [InlineData(typeof(IEnumerable), typeof(object))] + public void GetArrayElementType_WithNonGenericCollectionTypes_ReturnsObject(Type collectionType, Type expectedElementType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(collectionType); + + // Assert + Assert.Equal(expectedElementType, result); + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(object))] + public void GetArrayElementType_WithNonCollectionTypes_ReturnsNull(Type nonCollectionType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(nonCollectionType); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(typeof(List), typeof(int?))] + [InlineData(typeof(IEnumerable), typeof(bool?))] + [InlineData(typeof(string?[]), typeof(string))] // Note: string?[] is actually string[] since string is reference type + [InlineData(typeof(int?[]), typeof(int?))] + public void GetArrayElementType_WithNullableElementTypes_ReturnsNullableElementType(Type collectionType, Type expectedElementType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(collectionType); + + // Assert + Assert.Equal(expectedElementType, result); + } + + [Theory] + [InlineData(typeof(List>), typeof(List))] + [InlineData(typeof(IEnumerable>), typeof(IEnumerable))] + [InlineData(typeof(int[][]), typeof(int[]))] + [InlineData(typeof(List), typeof(int[]))] + [InlineData(typeof(int[][][]), typeof(int[][]))] + public void GetArrayElementType_WithNestedCollections_ReturnsInnerCollectionType(Type nestedCollectionType, Type expectedInnerType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(nestedCollectionType); + + // Assert + Assert.Equal(expectedInnerType, result); + } + + [Theory] + [InlineData(typeof(Dictionary))] + [InlineData(typeof(IDictionary))] + [InlineData(typeof(SortedDictionary))] + public void GetArrayElementType_WithDictionaryTypes_ReturnsObject(Type dictionaryType) + { + // Dictionary types implement IEnumerable, so they return object for the non-generic case + // This is reasonable behavior even though they're handled as "object" in ToJsonType() + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(dictionaryType); + + // Assert + Assert.Equal(typeof(object), result); + } + + [Theory] + [InlineData(typeof(HashSet), typeof(int))] + [InlineData(typeof(ISet), typeof(string))] + [InlineData(typeof(Queue), typeof(bool))] + [InlineData(typeof(Stack), typeof(double))] + public void GetArrayElementType_WithOtherGenericCollections_ReturnsElementType(Type collectionType, Type expectedElementType) + { + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(collectionType); + + // Assert + Assert.Equal(expectedElementType, result); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs new file mode 100644 index 0000000000..5d76a73aa6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Group; +using Azure.Mcp.Core.Areas.Subscription; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Acr; +using Azure.Mcp.Tools.Aks; +using Azure.Mcp.Tools.AppConfig; +using Azure.Mcp.Tools.AppLens; +using Azure.Mcp.Tools.Authorization; +using Azure.Mcp.Tools.AzureBestPractices; +using Azure.Mcp.Tools.AzureIsv; +using Azure.Mcp.Tools.AzureTerraformBestPractices; +using Azure.Mcp.Tools.BicepSchema; +using Azure.Mcp.Tools.CloudArchitect; +using Azure.Mcp.Tools.Cosmos; +using Azure.Mcp.Tools.Deploy; +using Azure.Mcp.Tools.EventGrid; +using Azure.Mcp.Tools.Extension; +using Azure.Mcp.Tools.Foundry; +using Azure.Mcp.Tools.FunctionApp; +using Azure.Mcp.Tools.Grafana; +using Azure.Mcp.Tools.KeyVault; +using Azure.Mcp.Tools.Kusto; +using Azure.Mcp.Tools.LoadTesting; +using Azure.Mcp.Tools.ManagedLustre; +using Azure.Mcp.Tools.Marketplace; +using Azure.Mcp.Tools.Monitor; +using Azure.Mcp.Tools.MySql; +using Azure.Mcp.Tools.Postgres; +using Azure.Mcp.Tools.Quota; +using Azure.Mcp.Tools.Redis; +using Azure.Mcp.Tools.ResourceHealth; +using Azure.Mcp.Tools.Search; +using Azure.Mcp.Tools.ServiceBus; +using Azure.Mcp.Tools.Sql; +using Azure.Mcp.Tools.Storage; +using Azure.Mcp.Tools.VirtualDesktop; +using Azure.Mcp.Tools.Workbooks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +internal class CommandFactoryHelpers +{ + public static CommandFactory CreateCommandFactory(IServiceProvider? serviceProvider = default) + { + IAreaSetup[] areaSetups = [ + // Core areas + new SubscriptionSetup(), + new GroupSetup(), + + // Tool areas + new AcrSetup(), + new AksSetup(), + new AppConfigSetup(), + new AppLensSetup(), + new AuthorizationSetup(), + new AzureBestPracticesSetup(), + new AzureIsvSetup(), + new ManagedLustreSetup(), + new AzureTerraformBestPracticesSetup(), + new BicepSchemaSetup(), + new CloudArchitectSetup(), + new CosmosSetup(), + new DeploySetup(), + new EventGridSetup(), + new ExtensionSetup(), + new FoundrySetup(), + new FunctionAppSetup(), + new GrafanaSetup(), + new KeyVaultSetup(), + new KustoSetup(), + new LoadTestingSetup(), + new MarketplaceSetup(), + new MonitorSetup(), + new MySqlSetup(), + new PostgresSetup(), + new QuotaSetup(), + new RedisSetup(), + new ResourceHealthSetup(), + new SearchSetup(), + new ServiceBusSetup(), + new SqlSetup(), + new StorageSetup(), + new VirtualDesktopSetup(), + new WorkbooksSetup(), + ]; + + var services = serviceProvider ?? CreateDefaultServiceProvider(); + var logger = services.GetRequiredService>(); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version" + }); + var telemetryService = services.GetService() ?? new NoOpTelemetryService(); + var commandFactory = new CommandFactory(services, areaSetups, telemetryService, configurationOptions, logger); + + return commandFactory; + } + + public static IServiceProvider CreateDefaultServiceProvider() + { + return SetupCommonServices().BuildServiceProvider(); + } + + public static IServiceCollection SetupCommonServices() + { + IAreaSetup[] areaSetups = [ + // Core areas + new SubscriptionSetup(), + new GroupSetup(), + + // Tool areas + new AcrSetup(), + new AksSetup(), + new AppConfigSetup(), + new AppLensSetup(), + new AuthorizationSetup(), + new AzureBestPracticesSetup(), + new AzureIsvSetup(), + new ManagedLustreSetup(), + new AzureTerraformBestPracticesSetup(), + new BicepSchemaSetup(), + new CloudArchitectSetup(), + new CosmosSetup(), + new DeploySetup(), + new EventGridSetup(), + new ExtensionSetup(), + new FoundrySetup(), + new FunctionAppSetup(), + new GrafanaSetup(), + new KeyVaultSetup(), + new KustoSetup(), + new LoadTestingSetup(), + new MarketplaceSetup(), + new MonitorSetup(), + new MySqlSetup(), + new PostgresSetup(), + new QuotaSetup(), + new RedisSetup(), + new ResourceHealthSetup(), + new SearchSetup(), + new ServiceBusSetup(), + new SqlSetup(), + new StorageSetup(), + new VirtualDesktopSetup(), + new WorkbooksSetup(), + ]; + + var builder = new ServiceCollection() + .AddLogging() + .AddSingleton(); + + foreach (var area in areaSetups) + { + area.ConfigureServices(builder); + } + + return builder; + } + + public class NoOpTelemetryService : ITelemetryService + { + public Activity? StartActivity(string activityName) => null; + + public Activity? StartActivity(string activityName, Implementation? clientInfo) => null; + + public void Dispose() + { + } + + public Task InitializeAsync() => Task.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..280142c1a5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +/// +/// Concrete test implementation of BaseDiscoveryStrategy for testing disposal behavior +/// +public class TestDiscoveryStrategy : BaseDiscoveryStrategy +{ + private readonly IEnumerable _providers; + + public TestDiscoveryStrategy(IEnumerable providers, ILogger? logger = null) : base(logger ?? NullLogger.Instance) + { + _providers = providers; + } + + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + return Task.FromResult(_providers); + } +} + +public class BaseDiscoveryStrategyTests +{ + private static IMcpServerProvider CreateMockServerProvider(string name, string id = "", string description = "Test server") + { + var mockProvider = Substitute.For(); + var metadata = new McpServerMetadata + { + Id = id, + Name = name, + Description = description + }; + mockProvider.CreateMetadata().Returns(metadata); + return mockProvider; + } + + private static BaseDiscoveryStrategy CreateMockStrategy(params IMcpServerProvider[] providers) + { + return new TestDiscoveryStrategy(providers); + } + + [Fact] + public async Task FindServerProvider_WithEmptyDiscovery_ThrowsArgumentException() + { + // Arrange + var strategy = CreateMockStrategy(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("notfound", TestContext.Current.CancellationToken)); + Assert.Contains("notfound", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("No MCP server found with the name", exception.Message); + } + + [Fact] + public async Task FindServerProvider_WithNonExistentServer_ThrowsKeyNotFoundException() + { + // Arrange + var provider1 = CreateMockServerProvider("server1"); + var provider2 = CreateMockServerProvider("server2"); + var strategy = CreateMockStrategy(provider1, provider2); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("nonexistent", TestContext.Current.CancellationToken)); + Assert.Contains("nonexistent", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FindServerProvider_WithExistingServer_ReturnsCorrectProvider() + { + // Arrange + var provider1 = CreateMockServerProvider("server1"); + var provider2 = CreateMockServerProvider("server2"); + var strategy = CreateMockStrategy(provider1, provider2); + + // Act + var result = await strategy.FindServerProviderAsync("server1", TestContext.Current.CancellationToken); + + // Assert + Assert.Same(provider1, result); + } + + [Fact] + public async Task FindServerProvider_WithCaseInsensitiveMatch_ReturnsCorrectProvider() + { + // Arrange + var provider = CreateMockServerProvider("TestServer"); + var strategy = CreateMockStrategy(provider); + + // Act + var result3 = await strategy.FindServerProviderAsync("TestServer", TestContext.Current.CancellationToken); + + // Assert + Assert.Same(provider, result3); + } + + [Fact] + public async Task FindServerProvider_WithMultipleServers_ReturnsCorrectOne() + { + // Arrange + var provider1 = CreateMockServerProvider("azure-storage"); + var provider2 = CreateMockServerProvider("azure-keyvault"); + var provider3 = CreateMockServerProvider("azure-cosmos"); + var strategy = CreateMockStrategy(provider1, provider2, provider3); + + // Act + var result = await strategy.FindServerProviderAsync("azure-keyvault", TestContext.Current.CancellationToken); + + // Assert + Assert.Same(provider2, result); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithNewServer_CreatesAndCachesClient() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("TestServer"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + var strategy = CreateMockStrategy(provider); + + // Act + var result = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Same(mockClient, result); + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithCachedServer_ReturnsCachedClient() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("TestServer"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + var strategy = CreateMockStrategy(provider); + + // Act + var result1 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + var result2 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Same(mockClient, result1); + Assert.Same(mockClient, result2); + Assert.Same(result1, result2); + + // Verify client was only created once + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithCustomOptions_PassesOptionsCorrectly() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("TestServer"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + var strategy = CreateMockStrategy(provider); + var customOptions = new McpClientOptions { /* set custom properties if available */ }; + + // Act + var result = await strategy.GetOrCreateClientAsync("TestServer", customOptions, TestContext.Current.CancellationToken); + + // Assert + Assert.Same(mockClient, result); + await provider.Received(1).CreateClientAsync(customOptions, Arg.Any()); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithDefaultOptions_UsesDefaultOptions() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("TestServer"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + var strategy = CreateMockStrategy(provider); + + // Act + var result = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Same(mockClient, result); + await provider.Received(1).CreateClientAsync(Arg.Is(opts => opts != null), Arg.Any()); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithNonExistentServer_ThrowsKeyNotFoundException() + { + // Arrange + var provider = CreateMockServerProvider("ExistingServer"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => strategy.GetOrCreateClientAsync("NonExistentServer", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("NonExistentServer", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithMultipleServers_CachesEachSeparately() + { + // Arrange + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); + var provider1 = CreateMockServerProvider("Server1"); + var provider2 = CreateMockServerProvider("Server2"); + + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); + + var strategy = CreateMockStrategy(provider1, provider2); + + // Act + var result1a = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + var result2a = await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); + var result1b = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + var result2b = await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Same(mockClient1, result1a); + Assert.Same(mockClient2, result2a); + Assert.Same(result1a, result1b); + Assert.Same(result2a, result2b); + + // Verify each client was only created once + await provider1.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + await provider2.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FindServerProvider_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var provider = CreateMockServerProvider("TestServer"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync(null!, TestContext.Current.CancellationToken)); + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindServerProvider_WithEmptyName_ThrowsArgumentNullException() + { + // Arrange + var provider = CreateMockServerProvider("TestServer"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("", TestContext.Current.CancellationToken)); + Assert.Equal("name", exception.ParamName); + Assert.Contains("Server name cannot be null or empty", exception.Message); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithNullName_ThrowsArgumentNullException() + { + // Arrange + var provider = CreateMockServerProvider("TestServer"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => strategy.GetOrCreateClientAsync(null!, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task GetOrCreateClientAsync_WithEmptyName_ThrowsArgumentNullException() + { + // Arrange + var provider = CreateMockServerProvider("TestServer"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => strategy.GetOrCreateClientAsync("", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("name", exception.ParamName); + Assert.Contains("Server name cannot be null or empty", exception.Message); + } + + [Fact] + public async Task GetOrCreateClientAsync_CacheUsesSameKeyForDifferentCasing_ReusesCachedClient() + { + // Arrange + var mockClient1 = Substitute.For(); + var provider = CreateMockServerProvider("TestServer"); + + // Setup provider to return a client for the first call + provider.CreateClientAsync(Arg.Any(), Arg.Any()) + .Returns(mockClient1); + + var strategy = CreateMockStrategy(provider); + + // Act - Different casings use the same cache key because we use StringComparer.OrdinalIgnoreCase + var result1 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + + // Assert - Same client because cache keys are case-insensitive + Assert.Same(mockClient1, result1); + + // Verify provider was called only once (the same cached client is returned for all casing variants) + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + + // Verify subsequent calls with any casing return the same cached client + var result1b = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Same(result1, result1b); + + // Still only 1 call total (all calls use the cached entry regardless of casing) + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeAllCachedClients() + { + // Arrange + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); + var provider1 = CreateMockServerProvider("Server1"); + var provider2 = CreateMockServerProvider("Server2"); + + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); + + var strategy = CreateMockStrategy(provider1, provider2); + + // Create and cache some clients + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); + + // Act + await strategy.DisposeAsync(); + + // Assert - All cached clients should be disposed + await mockClient1.Received(1).DisposeAsync(); + await mockClient2.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_WithNoCachedClients_ShouldNotThrow() + { + // Arrange + var provider = CreateMockServerProvider("Server1"); + var strategy = CreateMockStrategy(provider); + + // Act & Assert - should not throw + await strategy.DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldHandleClientDisposalExceptions() + { + // Arrange + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); + var provider1 = CreateMockServerProvider("Server1"); + var provider2 = CreateMockServerProvider("Server2"); + + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); + + // Setup first client to throw on disposal + mockClient1.DisposeAsync().Returns(ValueTask.FromException(new InvalidOperationException("Client 1 disposal failed"))); + mockClient2.DisposeAsync().Returns(ValueTask.CompletedTask); + + var strategy = CreateMockStrategy(provider1, provider2); + + // Cache both clients + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); + + // Act - Should not throw (BaseDiscoveryStrategy catches and swallows disposal exceptions) + await strategy.DisposeAsync(); + + // Assert - Both clients should have been attempted to dispose + await mockClient1.Received(1).DisposeAsync(); + await mockClient2.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldBeIdempotent() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("Server1"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + + var strategy = CreateMockStrategy(provider); + + // Cache a client + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + + // Act - dispose multiple times + await strategy.DisposeAsync(); + await strategy.DisposeAsync(); + await strategy.DisposeAsync(); + + // Assert - client should only be disposed once (idempotent) + await mockClient.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldClearClientCache() + { + // Arrange + var mockClient = Substitute.For(); + var provider = CreateMockServerProvider("Server1"); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); + + var strategy = CreateMockStrategy(provider); + + // Cache a client + var client1 = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + Assert.Same(mockClient, client1); + + // Act + await strategy.DisposeAsync(); + + // Assert - After disposal, cache should be cleared + // This is verified by the fact that disposal was called and the cache is no longer accessible + await mockClient.Received(1).DisposeAsync(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..7d752d2a86 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs @@ -0,0 +1,593 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class CommandGroupDiscoveryStrategyTests +{ + private static CommandGroupDiscoveryStrategy CreateStrategy( + CommandFactory? commandFactory = null, + ServiceStartOptions? options = null, + string? entryPoint = null) + { + var factory = commandFactory ?? CommandFactoryHelpers.CreateCommandFactory(); + var startOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var logger = NSubstitute.Substitute.For>(); + var strategy = new CommandGroupDiscoveryStrategy(factory, startOptions, logger); + if (entryPoint != null) + { + strategy.EntryPoint = entryPoint; + } + return strategy; + } + + [Fact] + public void Constructor_WithNullCommandFactory_DoesNotThrow() + { + // Arrange + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var logger = NSubstitute.Substitute.For>(); + + // Act & Assert + // Primary constructor syntax doesn't automatically validate null parameters + var strategy = new CommandGroupDiscoveryStrategy(null!, options, logger); + Assert.NotNull(strategy); + } + + [Fact] + public void Constructor_WithNullOptions_DoesNotThrow() + { + // Arrange + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + var logger = NSubstitute.Substitute.For>(); + + // Act & Assert + // Primary constructor syntax doesn't automatically validate null parameters + var strategy = new CommandGroupDiscoveryStrategy(commandFactory, null!, logger); + Assert.NotNull(strategy); + } + + [Fact] + public void Constructor_WithValidParameters_InitializesCorrectly() + { + // Arrange + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var logger = NSubstitute.Substitute.For>(); + + // Act + var strategy = new CommandGroupDiscoveryStrategy(commandFactory, options, logger); + + // Assert + Assert.NotNull(strategy); + Assert.Null(strategy.EntryPoint); // Default should be null + } + + [Fact] + public void EntryPoint_DefaultsToNull() + { + // Arrange + var strategy = CreateStrategy(); + + // Act & Assert + Assert.Null(strategy.EntryPoint); + } + + [Fact] + public void EntryPoint_CanBeSetAndRetrieved() + { + // Arrange + var strategy = CreateStrategy(); + var azmcpPath = McpTestUtilities.GetAzMcpExecutablePath(); + + // Act + strategy.EntryPoint = azmcpPath; + + // Assert + Assert.Equal(azmcpPath, strategy.EntryPoint); + } + + [Fact] + public void EntryPoint_WhenSetToEmpty_RemainsEmpty() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + strategy.EntryPoint = ""; + + // Assert + // The strategy itself just stores the value as-is + // The defaulting behavior happens in CommandGroupServerProvider + Assert.Equal("", strategy.EntryPoint); + } + + [Fact] + public void EntryPoint_WhenSetToWhitespace_RemainsWhitespace() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + strategy.EntryPoint = " "; + + // Assert + // The strategy itself just stores the value as-is + // The defaulting behavior happens in CommandGroupServerProvider + Assert.Equal(" ", strategy.EntryPoint); + } + + [Fact] + public async Task DiscoverServersAsync_WithDefaultOptions_ReturnsNonEmptyCollection() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.All(result, provider => Assert.IsType(provider)); + } + + [Fact] + public async Task DiscoverServersAsync_WithReadOnlyFalse_CreatesNonReadOnlyProviders() + { + // Arrange + var options = new ServiceStartOptions { ReadOnly = false }; + var strategy = CreateStrategy(options: options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => + Assert.False(((CommandGroupServerProvider)provider).ReadOnly)); + } + + [Fact] + public async Task DiscoverServersAsync_WithReadOnlyTrue_CreatesReadOnlyProviders() + { + // Arrange + var options = new ServiceStartOptions { ReadOnly = true }; + var strategy = CreateStrategy(options: options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => + Assert.True(((CommandGroupServerProvider)provider).ReadOnly)); + } + + [Fact] + public async Task DiscoverServersAsync_WithNullReadOnlyOption_DefaultsToFalse() + { + // Arrange + var options = new ServiceStartOptions { ReadOnly = null }; + var strategy = CreateStrategy(options: options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => + Assert.False(((CommandGroupServerProvider)provider).ReadOnly)); + } + + [Fact] + public async Task DiscoverServersAsync_WithCustomEntryPoint_SetsEntryPointOnAllProviders() + { + // Arrange + var customEntryPoint = McpTestUtilities.GetAzMcpExecutablePath(); + var strategy = CreateStrategy(entryPoint: customEntryPoint); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => + Assert.Equal(customEntryPoint, ((CommandGroupServerProvider)provider).EntryPoint)); + } + + [Fact] + public async Task DiscoverServersAsync_WithNullEntryPoint_UsesCurrentProcessExecutable() + { + // Arrange + var strategy = CreateStrategy(entryPoint: null); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + // When EntryPoint is set to null, CommandGroupServerProvider defaults to current process executable + var currentProcessPath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + Assert.All(result, provider => + { + var actualEntryPoint = ((CommandGroupServerProvider)provider).EntryPoint; + Assert.NotNull(actualEntryPoint); + // Should be the current test process executable + Assert.Equal(currentProcessPath, actualEntryPoint); + }); + } + + [Fact] + public async Task DiscoverServersAsync_WithEmptyEntryPoint_ProvidersDefaultToCurrentProcess() + { + // Arrange + var strategy = CreateStrategy(entryPoint: ""); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + var currentProcessPath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + Assert.All(result, provider => + { + var actualEntryPoint = ((CommandGroupServerProvider)provider).EntryPoint; + // CommandGroupServerProvider defaults empty/null to current process + Assert.Equal(currentProcessPath, actualEntryPoint); + }); + } + + [Fact] + public async Task DiscoverServersAsync_WithWhitespaceEntryPoint_ProvidersDefaultToCurrentProcess() + { + // Arrange + var strategy = CreateStrategy(entryPoint: " "); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + var currentProcessPath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + Assert.All(result, provider => + { + var actualEntryPoint = ((CommandGroupServerProvider)provider).EntryPoint; + // CommandGroupServerProvider defaults whitespace to current process + Assert.Equal(currentProcessPath, actualEntryPoint); + }); + } + + [Fact] + public async Task DiscoverServersAsync_ExcludesIgnoredGroups() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var names = result.Select(p => p.CreateMetadata().Name).ToList(); + var ignoredGroups = DiscoveryConstants.IgnoredCommandGroups; + + foreach (var ignored in ignoredGroups) + { + Assert.DoesNotContain(ignored, names, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task DiscoverServersAsync_EachProviderHasCorrectMetadata() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.NotEmpty(providers); + + foreach (var provider in providers) + { + var metadata = provider.CreateMetadata(); + Assert.NotNull(metadata); + Assert.NotEmpty(metadata.Name); + Assert.NotEmpty(metadata.Id); + Assert.Equal(metadata.Name, metadata.Id); // Should be the same + Assert.NotNull(metadata.Description); + } + } + + [Fact] + public async Task DiscoverServersAsync_ProvidersAreCommandGroupServerProviderType() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => Assert.IsType(provider)); + } + + [Fact] + public async Task DiscoverServersAsync_ProvidersHaveUniqueNames() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + var names = providers.Select(p => p.CreateMetadata().Name).ToList(); + Assert.Equal(names.Count, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result1); + Assert.NotNull(result2); + + var providers1 = result1.ToList(); + var providers2 = result2.ToList(); + Assert.Equal(providers1.Count, providers2.Count); + + // Should return equivalent results + var names1 = providers1.Select(p => p.CreateMetadata().Name).OrderBy(n => n).ToList(); + var names2 = providers2.Select(p => p.CreateMetadata().Name).OrderBy(n => n).ToList(); + Assert.Equal(names1, names2); + } + + [Fact] + public async Task DiscoverServersAsync_WithRealCommandFactory_IncludesKnownGroups() + { + // Arrange + var strategy = CreateStrategy(); // Uses real command factory + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + var names = providers.Select(p => p.CreateMetadata().Name).ToList(); + + // Should include at least some known groups (based on actual implementation) + Assert.Contains("storage", names, StringComparer.OrdinalIgnoreCase); + + // Should not include ignored groups + var ignoredGroups = DiscoveryConstants.IgnoredCommandGroups; + foreach (var ignored in ignoredGroups) + { + Assert.DoesNotContain(ignored, names, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task DiscoverServersAsync_RespectsServiceStartOptionsValues() + { + // Arrange + var options = new ServiceStartOptions + { + ReadOnly = true, + }; + var azmcpEntryPoint = McpTestUtilities.GetAzMcpExecutablePath(); + var strategy = CreateStrategy(options: options, entryPoint: azmcpEntryPoint); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + foreach (var provider in result) + { + var serverProvider = (CommandGroupServerProvider)provider; + Assert.True(serverProvider.ReadOnly); + Assert.Equal(azmcpEntryPoint, serverProvider.EntryPoint); + } + } + + [Fact] + public async Task DiscoverServersAsync_IgnoredGroupsAreCaseInsensitive() + { + // Arrange - Test with real factory since we can't easily mock the command groups + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var names = result.Select(p => p.CreateMetadata().Name).ToList(); + + // Verify ignored groups are not present (case insensitive) + foreach (var ignored in DiscoveryConstants.IgnoredCommandGroups) + { + Assert.DoesNotContain(ignored, names, StringComparer.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task DiscoverServersAsync_ResultCountIsConsistent() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var count1 = result1.Count(); + var count2 = result2.Count(); + Assert.Equal(count1, count2); + Assert.True(count1 > 0); // Should have at least some command groups + } + + // Keep the original tests for backward compatibility + [Fact] + public async Task ShouldDiscoverServers() + { + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var logger = NSubstitute.Substitute.For>(); + var strategy = new CommandGroupDiscoveryStrategy(commandFactory, options, logger); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldDiscoverServers_ExcludesIgnoredGroupsAndSetsProperties() + { + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions { ReadOnly = true }); + var azmcpEntryPoint = McpTestUtilities.GetAzMcpExecutablePath(); + var logger = NSubstitute.Substitute.For>(); + var strategy = new CommandGroupDiscoveryStrategy(commandFactory, options, logger) + { + EntryPoint = azmcpEntryPoint + }; + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + Assert.NotEmpty(result); + // Should not include ignored groups + var ignored = DiscoveryConstants.IgnoredCommandGroups; + Assert.DoesNotContain(result, p => ignored.Contains(p.CreateMetadata().Name, StringComparer.OrdinalIgnoreCase)); + // Should include at least one known group (e.g. storage) + Assert.Contains(result, p => p.CreateMetadata().Name == "storage"); + // Should set ReadOnly and EntryPoint as expected + foreach (var provider in result) + { + Assert.True(((CommandGroupServerProvider)provider).ReadOnly); + Assert.Equal(azmcpEntryPoint, ((CommandGroupServerProvider)provider).EntryPoint); + } + } + + [Fact] + public void GetAzmcpExecutablePath_ReturnsCorrectPathForCurrentOS() + { + // Arrange & Act + var azmcpPath = McpTestUtilities.GetAzMcpExecutablePath(); + + // Assert + Assert.NotNull(azmcpPath); + Assert.NotEmpty(azmcpPath); + + // Should end with the correct executable name for the current OS + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( + System.Runtime.InteropServices.OSPlatform.Windows)) + { + Assert.EndsWith("azmcp.exe", azmcpPath); + } + else + { + Assert.EndsWith("azmcp", azmcpPath); + Assert.False(azmcpPath.EndsWith("azmcp.exe")); + } + + // Should be in the same directory as the test assembly + var testAssemblyPath = System.Reflection.Assembly.GetExecutingAssembly().Location; + var testDirectory = Path.GetDirectoryName(testAssemblyPath); + var expectedDirectory = Path.GetDirectoryName(azmcpPath); + Assert.Equal(testDirectory, expectedDirectory); + } + + [Fact] + public async Task DiscoverServersAsync_WithNamespaceFilter_ReturnsOnlySpecifiedNamespaces() + { + // Arrange + var options = new ServiceStartOptions + { + Namespace = ["storage", "keyvault"] + }; + var strategy = CreateStrategy(options: options); + + // Act + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); + + // Assert + Assert.NotNull(servers); + Assert.Equal(2, serverNames.Count); + Assert.Contains("storage", serverNames); + Assert.Contains("keyvault", serverNames); + + // Should not contain other namespaces + Assert.DoesNotContain("cosmos", serverNames); + Assert.DoesNotContain("monitor", serverNames); + } + + [Fact] + public async Task DiscoverServersAsync_WithEmptyNamespaceFilter_ReturnsAllNamespaces() + { + // Arrange + var options = new ServiceStartOptions + { + Namespace = [] + }; + var strategy = CreateStrategy(options: options); + + // Act + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); + + // Assert + Assert.NotNull(servers); + Assert.True(serverNames.Count > 2); // Should have more than just storage and keyvault + + // Should contain expected namespaces (but not ignored ones) + Assert.Contains("storage", serverNames); + Assert.Contains("keyvault", serverNames); + Assert.DoesNotContain("server", serverNames); // Should be ignored + Assert.DoesNotContain("extension", serverNames); // Should be ignored + Assert.DoesNotContain("subscription", serverNames); // Should be ignored + Assert.DoesNotContain("group", serverNames); // Should be ignored + } + + [Fact] + public async Task DiscoverServersAsync_WithNullNamespaceFilter_ReturnsAllNamespaces() + { + // Arrange + var options = new ServiceStartOptions + { + Namespace = null + }; + var strategy = CreateStrategy(options: options); + + // Act + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); + + // Assert + Assert.NotNull(servers); + Assert.True(serverNames.Count > 2); // Should have more than just storage and keyvault + + // Should contain expected namespaces (but not ignored ones) + Assert.Contains("storage", serverNames); + Assert.Contains("keyvault", serverNames); + Assert.DoesNotContain("server", serverNames); // Should be ignored + Assert.DoesNotContain("extension", serverNames); // Should be ignored + Assert.DoesNotContain("subscription", serverNames); // Should be ignored + Assert.DoesNotContain("group", serverNames); // Should be ignored + } + +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs new file mode 100644 index 0000000000..39694d2f13 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tests.Client.Helpers; +using ModelContextProtocol.Client; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class CommandGroupServerProviderTests +{ + private readonly CommandFactory _commandFactory; + public CommandGroupServerProviderTests() + { + _commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + } + + [Fact] + public void CreateMetadata_ReturnsExpectedMetadata() + { + // Arrange + // For testGroup, CommandFactory does not have it by default, so fallback to direct instantiation + var commandGroup = new CommandGroup("testGroup", "Test Description"); + var mcpCommandGroup = new CommandGroupServerProvider(commandGroup); + + // Act + var metadata = mcpCommandGroup.CreateMetadata(); + + // Assert + Assert.Equal("testGroup", metadata.Id); + Assert.Equal("testGroup", metadata.Name); + Assert.Equal("Test Description", metadata.Description); + } + + [Fact] + public async Task CreateClientAsync_ReturnsClientInstance() + { + // Arrange + // Use CommandFactory to get the storage command group + var storageGroup = _commandFactory.RootGroup.SubGroup.FirstOrDefault(g => g.Name == "storage"); + Assert.NotNull(storageGroup); + + // Use the built azmcp.exe as the entry point for testing (should be in the same directory as the test exe) + var entryPoint = McpTestUtilities.GetAzMcpExecutablePath(); + Assert.True(File.Exists(entryPoint), $"azmcp executable not found at {entryPoint}"); + + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + mcpCommandGroup.EntryPoint = entryPoint; + var options = new McpClientOptions(); + + // Act + var client = await mcpCommandGroup.CreateClientAsync(options, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(client); + + await client.DisposeAsync(); + } + + [Fact] + public void ReadOnly_Property_DefaultsToFalse() + { + // Arrange + var storageGroup = _commandFactory.RootGroup.SubGroup.First(g => g.Name == "storage"); + + // Act + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + + // Assert + Assert.False(mcpCommandGroup.ReadOnly); + } + + [Fact] + public void ReadOnly_Property_CanBeSet() + { + // Arrange + var storageGroup = _commandFactory.RootGroup.SubGroup.First(g => g.Name == "storage"); + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + + // Act + mcpCommandGroup.ReadOnly = true; + + // Assert + Assert.True(mcpCommandGroup.ReadOnly); + } + + [Fact] + public void EntryPoint_SetToNull_UsesDefault() + { + // Arrange + var storageGroup = _commandFactory.RootGroup.SubGroup.First(g => g.Name == "storage"); + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + var originalEntryPoint = mcpCommandGroup.EntryPoint; + // Act + mcpCommandGroup.EntryPoint = null!; + + // Assert + Assert.Equal(originalEntryPoint, mcpCommandGroup.EntryPoint); + Assert.False(string.IsNullOrWhiteSpace(mcpCommandGroup.EntryPoint)); + } + + [Fact] + public void EntryPoint_SetToEmpty_UsesDefault() + { + // Arrange + var storageGroup = _commandFactory.RootGroup.SubGroup.First(g => g.Name == "storage"); + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + var originalEntryPoint = mcpCommandGroup.EntryPoint; + + // Act + mcpCommandGroup.EntryPoint = ""; + + // Assert + Assert.Equal(originalEntryPoint, mcpCommandGroup.EntryPoint); + Assert.False(string.IsNullOrWhiteSpace(mcpCommandGroup.EntryPoint)); + } + + [Fact] + public void EntryPoint_SetToValidValue_UsesProvidedValue() + { + // Arrange + var storageGroup = _commandFactory.RootGroup.SubGroup.First(g => g.Name == "storage"); + var mcpCommandGroup = new CommandGroupServerProvider(storageGroup); + var customEntryPoint = "/custom/path/to/executable"; + + // Act + mcpCommandGroup.EntryPoint = customEntryPoint; + + // Assert + Assert.Equal(customEntryPoint, mcpCommandGroup.EntryPoint); + } + + [Fact] + public void BuildArguments_WithoutReadOnly_ReturnsBasicArguments() + { + // Arrange + var commandGroup = new CommandGroup("testGroup", "Test Description"); + var provider = new CommandGroupServerProvider(commandGroup); + provider.ReadOnly = false; + + // Act + var arguments = provider.BuildArguments(); + + // Assert + var expected = new[] { "server", "start", "--mode", "all", "--namespace", "testGroup" }; + Assert.Equal(expected, arguments); + } + + [Fact] + public void BuildArguments_WithReadOnly_IncludesReadOnlyFlag() + { + // Arrange + var commandGroup = new CommandGroup("testGroup", "Test Description"); + var provider = new CommandGroupServerProvider(commandGroup); + provider.ReadOnly = true; + + // Act + var arguments = provider.BuildArguments(); + + // Assert + var expected = new[] { "server", "start", "--mode", "all", "--namespace", "testGroup", "--read-only" }; + Assert.Equal(expected, arguments); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..b3eb1de9e6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class CompositeDiscoveryStrategyTests +{ + private static IMcpDiscoveryStrategy CreateMockStrategy(params IMcpServerProvider[] providers) + { + var strategy = Substitute.For(); + strategy.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(providers)); + return strategy; + } + + private static IMcpServerProvider CreateMockProvider(string id, string? name = null, string? description = null) + { + var provider = Substitute.For(); + provider.CreateMetadata().Returns(new McpServerMetadata + { + Id = id, + Name = name ?? id, + Description = description ?? $"Description for {id}" + }); + return provider; + } + + private static CompositeDiscoveryStrategy CreateCompositeStrategy(IEnumerable strategies) + { + var logger = Substitute.For>(); + return new CompositeDiscoveryStrategy(strategies, logger); + } + + [Fact] + public void Constructor_WithValidStrategies_InitializesCorrectly() + { + // Arrange + var strategy1 = Substitute.For(); + var strategy2 = Substitute.For(); + + // Act + var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); + + // Assert + Assert.NotNull(composite); + Assert.IsType(composite); + Assert.IsAssignableFrom(composite); + } + + [Fact] + public void Constructor_WithNullStrategies_ThrowsArgumentNullException() + { + // Act & Assert + var logger = Substitute.For>(); + var exception = Assert.Throws(() => + new CompositeDiscoveryStrategy(null!, logger)); + Assert.Equal("strategies", exception.ParamName); + } + + [Fact] + public void Constructor_WithEmptyStrategies_ThrowsArgumentException() + { + // Act & Assert + var logger = Substitute.For>(); + var exception = Assert.Throws(() => + new CompositeDiscoveryStrategy([], logger)); + Assert.Equal("strategies", exception.ParamName); + Assert.Contains("At least one discovery strategy must be provided", exception.Message); + } + + [Fact] + public void DiscoverServersAsync_WithEmptyStrategies_ThrowsArgumentException() + { + // Act & Assert + var logger = Substitute.For>(); + var exception = Assert.Throws(() => + new CompositeDiscoveryStrategy([], logger)); + Assert.Equal("strategies", exception.ParamName); + Assert.Contains("At least one discovery strategy must be provided", exception.Message); + } + + [Fact] + public async Task DiscoverServersAsync_WithSingleStrategy_ReturnsProvidersFromThatStrategy() + { + // Arrange + var provider1 = CreateMockProvider("test1"); + var provider2 = CreateMockProvider("test2"); + var strategy = CreateMockStrategy(provider1, provider2); + var composite = CreateCompositeStrategy(new[] { strategy }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(provider1, result); + Assert.Contains(provider2, result); + } + + [Fact] + public async Task DiscoverServersAsync_WithMultipleStrategies_AggregatesAllResults() + { + // Arrange + var provider1 = CreateMockProvider("strategy1-provider1"); + var provider2 = CreateMockProvider("strategy1-provider2"); + var provider3 = CreateMockProvider("strategy2-provider1"); + var provider4 = CreateMockProvider("strategy2-provider2"); + + var strategy1 = CreateMockStrategy(provider1, provider2); + var strategy2 = CreateMockStrategy(provider3, provider4); + var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Equal(4, result.Count); + Assert.Contains(provider1, result); + Assert.Contains(provider2, result); + Assert.Contains(provider3, result); + Assert.Contains(provider4, result); + } + + [Fact] + public async Task DiscoverServersAsync_WithStrategiesReturningEmpty_HandlesGracefully() + { + // Arrange + var provider1 = CreateMockProvider("active-provider"); + var activeStrategy = CreateMockStrategy(provider1); + var emptyStrategy1 = CreateMockStrategy(); // No providers + var emptyStrategy2 = CreateMockStrategy(); // No providers + + var composite = CreateCompositeStrategy(new[] { activeStrategy, emptyStrategy1, emptyStrategy2 }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Single(result); + Assert.Contains(provider1, result); + } + + [Fact] + public async Task DiscoverServersAsync_WithAllEmptyStrategies_ReturnsEmptyCollection() + { + // Arrange + var emptyStrategy1 = CreateMockStrategy(); + var emptyStrategy2 = CreateMockStrategy(); + var composite = CreateCompositeStrategy(new[] { emptyStrategy1, emptyStrategy2 }); + + // Act + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task DiscoverServersAsync_ExecutesAllStrategiesInParallel() + { + // Arrange + var provider1 = CreateMockProvider("provider1"); + var provider2 = CreateMockProvider("provider2"); + var provider3 = CreateMockProvider("provider3"); + + var strategy1 = CreateMockStrategy(provider1); + var strategy2 = CreateMockStrategy(provider2); + var strategy3 = CreateMockStrategy(provider3); + + var composite = CreateCompositeStrategy(new[] { strategy1, strategy2, strategy3 }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Contains(provider1, result); + Assert.Contains(provider2, result); + Assert.Contains(provider3, result); + + // Verify all strategies were called + await strategy1.Received(1).DiscoverServersAsync(Arg.Any()); + await strategy2.Received(1).DiscoverServersAsync(Arg.Any()); + await strategy3.Received(1).DiscoverServersAsync(Arg.Any()); + } + + [Fact] + public async Task DiscoverServersAsync_PreservesOrderFromStrategies() + { + // Arrange + var provider1 = CreateMockProvider("provider1"); + var provider2 = CreateMockProvider("provider2"); + var provider3 = CreateMockProvider("provider3"); + var provider4 = CreateMockProvider("provider4"); + + var strategy1 = CreateMockStrategy(provider1, provider2); + var strategy2 = CreateMockStrategy(provider3, provider4); + + var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Equal(4, result.Count); + // Results should maintain the order: strategy1's providers first, then strategy2's providers + Assert.Equal(provider1, result[0]); + Assert.Equal(provider2, result[1]); + Assert.Equal(provider3, result[2]); + Assert.Equal(provider4, result[3]); + } + + [Fact] + public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() + { + // Arrange + var provider1 = CreateMockProvider("provider1"); + var provider2 = CreateMockProvider("provider2"); + var strategy = CreateMockStrategy(provider1, provider2); + var composite = CreateCompositeStrategy(new[] { strategy }); + + // Act + var result1 = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + var result2 = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.Equal(result1.Count, result2.Count); + Assert.Equal(2, result1.Count); + Assert.Equal(2, result2.Count); + + // Should call the underlying strategy each time + await strategy.Received(2).DiscoverServersAsync(Arg.Any()); + } + + [Fact] + public async Task DiscoverServersAsync_WithDuplicateProviders_IncludesAllProviders() + { + // Arrange + var provider1 = CreateMockProvider("same-provider"); + var provider2 = CreateMockProvider("same-provider"); // Same ID but different instance + + var strategy1 = CreateMockStrategy(provider1); + var strategy2 = CreateMockStrategy(provider2); + + var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); + + // Act + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + // CompositeDiscoveryStrategy doesn't deduplicate - it includes all providers + Assert.Equal(2, result.Count); + Assert.Contains(provider1, result); + Assert.Contains(provider2, result); + } + + [Fact] + public async Task DiscoverServersAsync_InheritsFromBaseDiscoveryStrategy() + { + // Arrange + var strategy = CreateMockStrategy(); + var composite = CreateCompositeStrategy(new[] { strategy }); + + // Act & Assert + Assert.IsAssignableFrom(composite); + + // Should implement the base contract + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + } + + // Keep original tests for backward compatibility + [Fact] + public async Task ShouldAggregateResults() + { + var mockStrategy1 = Substitute.For(); + var mockStrategy2 = Substitute.For(); + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + mockStrategy1.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider1 })); + mockStrategy2.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider2 })); + var composite = CreateCompositeStrategy(new[] { mockStrategy1, mockStrategy2 }); + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); + Assert.Contains(provider1, result); + Assert.Contains(provider2, result); + } + + [Fact] + public async Task ShouldAggregateResults_ReturnsAllProviders() + { + var mockStrategy1 = Substitute.For(); + var mockStrategy2 = Substitute.For(); + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + provider1.CreateMetadata().Returns(new McpServerMetadata { Id = "one", Name = "one", Description = "desc1" }); + provider2.CreateMetadata().Returns(new McpServerMetadata { Id = "two", Name = "two", Description = "desc2" }); + mockStrategy1.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider1 })); + mockStrategy2.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider2 })); + var composite = CreateCompositeStrategy(new[] { mockStrategy1, mockStrategy2 }); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.CreateMetadata().Id == "one"); + Assert.Contains(result, p => p.CreateMetadata().Id == "two"); + } + + [Fact] + public async Task DiscoverServersAsync_WithSingleEmptyStrategy_ReturnsEmptyCollection() + { + // Arrange + var emptyStrategy = CreateMockStrategy(); // No providers + var composite = CreateCompositeStrategy(new[] { emptyStrategy }); + + // Act + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..543fdc8da9 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class ConsolidatedToolDiscoveryStrategyTests +{ + private static ConsolidatedToolDiscoveryStrategy CreateStrategy( + CommandFactory? commandFactory = null, + ServiceStartOptions? options = null, + string? entryPoint = null) + { + var factory = commandFactory ?? CommandFactoryHelpers.CreateCommandFactory(); + var serviceProvider = CommandFactoryHelpers.SetupCommonServices().BuildServiceProvider(); + var startOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version" + }); + var logger = NSubstitute.Substitute.For>(); + var strategy = new ConsolidatedToolDiscoveryStrategy(factory, serviceProvider, startOptions, configurationOptions, logger); + if (entryPoint != null) + { + strategy.EntryPoint = entryPoint; + } + return strategy; + } + + [Fact] + public async Task DiscoverServersAsync_ReturnsEmptyList() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + var providers = result.ToList(); + Assert.Empty(providers); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithDefaultOptions_ReturnsCommandFactory() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + Assert.True(factory.AllCommands.Count > 10); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithNamespaceFilter_FiltersCommands() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["storage"] }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + // Should only have storage-related consolidated commands + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + Assert.True(allCommands.Count < 10); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithReadOnlyFilter_FiltersCommands() + { + // Arrange + var options = new ServiceStartOptions { ReadOnly = true }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + // All commands should be read-only + Assert.All(allCommands.Values, cmd => Assert.True(cmd.Metadata.ReadOnly)); + } + + [Fact] + public void CreateConsolidatedCommandFactory_HandlesEmptyNamespaceFilter() + { + // Arrange + var options = new ServiceStartOptions { Namespace = [] }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..ab622f9de8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Options; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class RegistryDiscoveryStrategyTests +{ + private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions? options = null) + { + var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var logger = NSubstitute.Substitute.For>(); + return new RegistryDiscoveryStrategy(serviceOptions, logger); + } + + [Fact] + public void Constructor_InitializesCorrectly() + { + // Act + var strategy = CreateStrategy(); + + // Assert + Assert.NotNull(strategy); + Assert.IsType(strategy); + } + + [Fact] + public async Task DiscoverServersAsync_ReturnsNonNullResult() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task DiscoverServersAsync_ReturnsExpectedProviders() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + + // Assert + Assert.NotEmpty(result); + + // Should contain the 'learn' server from registry.json + var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); + Assert.NotNull(documentationProvider); + + var metadata = documentationProvider.CreateMetadata(); + Assert.Equal("documentation", metadata.Id); + Assert.Equal("documentation", metadata.Name); + Assert.Contains("documentation", metadata.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DiscoverServersAsync_AllProvidersAreRegistryServerProviderType() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + Assert.All(result, provider => Assert.IsType(provider)); + } + + [Fact] + public async Task DiscoverServersAsync_EachProviderHasValidMetadata() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.NotEmpty(providers); + + foreach (var provider in providers) + { + var metadata = provider.CreateMetadata(); + Assert.NotNull(metadata); + Assert.NotEmpty(metadata.Name); + Assert.NotEmpty(metadata.Id); + Assert.Equal(metadata.Name, metadata.Id); // Should be the same for registry providers + Assert.NotNull(metadata.Description); + Assert.NotEmpty(metadata.Description); + } + } + + [Fact] + public async Task DiscoverServersAsync_ProvidersHaveUniqueIds() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + var ids = providers.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result1); + Assert.NotNull(result2); + + var providers1 = result1.ToList(); + var providers2 = result2.ToList(); + Assert.Equal(providers1.Count, providers2.Count); + + // Should return equivalent results + var ids1 = providers1.Select(p => p.CreateMetadata().Id).OrderBy(i => i).ToList(); + var ids2 = providers2.Select(p => p.CreateMetadata().Id).OrderBy(i => i).ToList(); + Assert.Equal(ids1, ids2); + } + + [Fact] + public async Task DiscoverServersAsync_ResultCountIsConsistent() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var count1 = result1.Count(); + var count2 = result2.Count(); + Assert.Equal(count1, count2); + Assert.True(count1 > 0); // Should have at least one registry server + } + + [Fact] + public async Task DiscoverServersAsync_LoadsFromEmbeddedRegistryResource() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + // Should successfully load from the embedded registry.json resource + Assert.NotEmpty(result); + + // Verify we get expected server(s) from the registry + var serverIds = result.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); // Known server from registry.json + } + + [Fact] + public async Task DiscoverServersAsync_DocumentationServerHasExpectedProperties() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); + + // Assert + Assert.NotNull(documentationProvider); + + var metadata = documentationProvider.CreateMetadata(); + Assert.Equal("documentation", metadata.Id); + Assert.Equal("documentation", metadata.Name); + Assert.NotEmpty(metadata.Description); + + // Description should contain key terms related to Microsoft documentation + var description = metadata.Description.ToLowerInvariant(); + Assert.Contains("microsoft", description); + Assert.Contains("documentation", description); + } + + [Fact] + public async Task DiscoverServersAsync_ServerNamesMatchIds() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + + // For registry servers, Name should match Id (both are the key from registry.json) + Assert.All(result, provider => + { + var metadata = provider.CreateMetadata(); + Assert.Equal(metadata.Id, metadata.Name); + }); + } + + [Fact] + public async Task DiscoverServersAsync_AllProvidersCanCreateMetadata() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + + // Every provider should be able to create valid metadata without throwing + Assert.All(result, provider => + { + var metadata = provider.CreateMetadata(); + Assert.NotNull(metadata); + Assert.NotNull(metadata.Id); + Assert.NotNull(metadata.Name); + Assert.NotNull(metadata.Description); + }); + } + + [Fact] + public async Task DiscoverServersAsync_RegistryServerProviderSupportsSSE() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); + + // Assert + Assert.NotNull(documentationProvider); + + // Documentation server should be SSE-based (has URL) + var registryProvider = (RegistryServerProvider)documentationProvider; + Assert.NotNull(registryProvider); + + // Should not throw when creating metadata + var metadata = registryProvider.CreateMetadata(); + Assert.NotNull(metadata); + Assert.Equal("documentation", metadata.Name); + } + + [Fact] + public async Task DiscoverServersAsync_RegistryServersHaveValidDescriptions() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + + // All registry servers should have meaningful descriptions + Assert.All(result, provider => + { + var metadata = provider.CreateMetadata(); + Assert.NotEmpty(metadata.Description); + Assert.True(metadata.Description.Length > 10); // Should be substantial + }); + } + + [Fact] + public async Task DiscoverServersAsync_InheritsFromBaseDiscoveryStrategy() + { + // Arrange + var strategy = CreateStrategy(); + + // Act & Assert + Assert.IsAssignableFrom(strategy); + + // Should implement the base contract + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + } + + // Keep the original tests for backward compatibility + [Fact] + public async Task ShouldDiscoverServers() + { + var strategy = CreateStrategy(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + } + + [Fact] + public async Task ShouldDiscoverServers_ReturnsExpectedProviders() + { + var strategy = CreateStrategy(); + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + Assert.NotEmpty(result); + // Should contain the 'documentation' server from registry.json + var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); + Assert.NotNull(documentationProvider); + var metadata = documentationProvider.CreateMetadata(); + Assert.Equal("documentation", metadata.Id); + Assert.Equal("documentation", metadata.Name); + Assert.Contains("documentation", metadata.Description, StringComparison.OrdinalIgnoreCase); + } + + // Namespace filtering tests + [Fact] + public async Task DiscoverServersAsync_WithNullNamespace_ReturnsAllServers() + { + // Arrange + var options = new ServiceStartOptions { Namespace = null }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + // Should contain all servers when namespace is null + var serverIds = result.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); + } + + [Fact] + public async Task DiscoverServersAsync_WithEmptyNamespace_ReturnsAllServers() + { + // Arrange + var options = new ServiceStartOptions { Namespace = [] }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(result); + // Should contain all servers when namespace is empty + var serverIds = result.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); + } + + [Fact] + public async Task DiscoverServersAsync_WithMatchingNamespace_ReturnsFilteredServers() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["documentation"] }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.NotEmpty(providers); + + // Should only contain servers that match the namespace filter + var serverIds = providers.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); + + // All returned servers should match the namespace filter + Assert.All(serverIds, id => Assert.Contains(id, options.Namespace, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DiscoverServersAsync_WithNonMatchingNamespace_ReturnsEmptyResult() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["nonexistent"] }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.Empty(providers); + } + + [Fact] + public async Task DiscoverServersAsync_WithMultipleNamespaces_ReturnsMatchingServers() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["documentation", "another"] }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.NotEmpty(providers); + + // Should contain servers that match any of the namespaces + var serverIds = providers.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); + + // All returned servers should match at least one namespace in the filter + Assert.All(serverIds, id => + Assert.Contains(id, options.Namespace!, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DiscoverServersAsync_NamespaceFilteringIsCaseInsensitive() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["DOCUMENTATION"] }; + var strategy = CreateStrategy(options); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + var providers = result.ToList(); + Assert.NotEmpty(providers); + + // Should find "documentation" server even with uppercase namespace filter + var serverIds = providers.Select(p => p.CreateMetadata().Id).ToList(); + Assert.Contains("documentation", serverIds); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs new file mode 100644 index 0000000000..ec3129e792 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Sockets; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Models; +using ModelContextProtocol.Client; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class RegistryServerProviderTests +{ + [Fact] + public void Constructor_InitializesCorrectly() + { + // Arrange + string testId = "testProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Test Description" + }; + + // Act + var provider = new RegistryServerProvider(testId, serverInfo); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void CreateMetadata_ReturnsExpectedMetadata() + { + // Arrange + string testId = "testProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Test Description" + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act + var metadata = provider.CreateMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal(testId, metadata.Id); + Assert.Equal(testId, metadata.Name); + Assert.Null(metadata.Title); + Assert.Equal(serverInfo.Description, metadata.Description); + } + + [Fact] + public void CreateMetadata_EmptyDescription_ReturnsEmptyString() + { + // Arrange + string testId = "testProvider"; + var serverInfo = new RegistryServerInfo + { + Description = null + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act + var metadata = provider.CreateMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal(testId, metadata.Id); + Assert.Equal(testId, metadata.Name); + Assert.Null(metadata.Title); // No title specified + Assert.Equal(string.Empty, metadata.Description); + } + + [Fact] + public void CreateMetadata_WithTitle_ReturnsTitleInMetadata() + { + // Arrange + string testId = "testProvider"; + string testTitle = "Test Provider Display Name"; + var serverInfo = new RegistryServerInfo + { + Title = testTitle, + Description = "Test Description" + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act + var metadata = provider.CreateMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal(testId, metadata.Id); + Assert.Equal(testId, metadata.Name); + Assert.Equal(testTitle, metadata.Title); + Assert.Equal(serverInfo.Description, metadata.Description); + } + + // [Fact] + // public async Task CreateClientAsync_WithUrlReturning404_ThrowsHttpRequestException() + // { + // // Arrange + // string testId = "sseProvider"; + // using var server = new MockHttpTestServer(); + // var serverInfo = new RegistryServerInfo + // { + // Description = "Test SSE Provider", + // Url = $"{server.Endpoint}/mcp" + // }; + // var provider = new RegistryServerProvider(testId, serverInfo); + + // // Act & Assert + // var exception = await Assert.ThrowsAsync( + // () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + // Assert.Contains(((int)HttpStatusCode.NotFound).ToString(), exception.Message); + // } + + [Fact] + public async Task CreateClientAsync_WithStdioType_CreatesStdioClient() + { + // Arrange + string testId = "stdioProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Test Stdio Provider", + Type = "stdio", + Command = "echo", + Args = ["hello world"] + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert - Should throw InvalidOperationException for subprocess startup failure + // since configuration is valid but external process fails to start properly + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + Assert.Contains($"Failed to create MCP client for registry server '{testId}'", exception.Message); + } + + [Fact] + public async Task CreateClientAsync_WithEnvVariables_MergesWithSystemEnvironment() + { + // Arrange + string testId = "envProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Test Env Provider", + Type = "stdio", + Command = "echo", + Args = ["hello world"], + Env = new Dictionary + { + { "TEST_VAR", "test value" } + } + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert - Should throw InvalidOperationException for subprocess startup failure + // since configuration is valid but external process fails to start properly + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + Assert.Contains($"Failed to create MCP client for registry server '{testId}'", exception.Message); + } + + [Fact] + public async Task CreateClientAsync_NoUrlOrType_ThrowsArgumentException() + { + // Arrange + string testId = "invalidProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Invalid Provider - No Transport" + // No Url or Type specified + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + Assert.Contains($"Registry server '{testId}' does not have a valid transport type.", + exception.Message); + } + + [Fact] + public async Task CreateClientAsync_StdioWithoutCommand_ThrowsInvalidOperationException() + { + // Arrange + string testId = "invalidStdioProvider"; + var serverInfo = new RegistryServerInfo + { + Description = "Invalid Stdio Provider - No Command", + Type = "stdio" + // No Command specified + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + Assert.Contains($"Registry server '{testId}' does not have a valid command for stdio transport.", + exception.Message); + } + + [Fact] + public async Task CreateClientAsync_WithInstallInstructions_IncludesInstructionsInException() + { + // Arrange + string testId = "toolWithInstructions"; + string installInstructions = "To install this tool, run: npm install -g my-mcp-tool"; + var serverInfo = new RegistryServerInfo + { + Description = "Tool that requires installation", + Type = "stdio", + Command = "my-mcp-tool", // This will fail since the command doesn't exist + Args = ["--serve"], + InstallInstructions = installInstructions + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert - Should throw InvalidOperationException with install instructions + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + // Verify the exception message contains the install instructions + Assert.Contains($"Failed to initialize the '{testId}' MCP tool.", exception.Message); + Assert.Contains("This tool may require dependencies that are not installed.", exception.Message); + Assert.Contains("Installation Instructions:", exception.Message); + Assert.Contains(installInstructions, exception.Message); + } +} + +internal sealed class MockHttpTestServer : IDisposable +{ + private readonly HttpListener _listener; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly TaskCompletionSource _ready; + public string Endpoint { get; } + + public MockHttpTestServer() + { + var port = GetAvailablePort(); + Endpoint = $"http://127.0.0.1:{port}"; + + _listener = new HttpListener(); + _listener.Prefixes.Add($"{Endpoint}/"); + _listener.Start(); + + _cancellationTokenSource = new CancellationTokenSource(); + _ready = new TaskCompletionSource(); + + _ = Task.Run(async () => + { + try + { + _ready.SetResult(); + + while (_listener.IsListening && !_cancellationTokenSource.Token.IsCancellationRequested) + { + var context = await _listener.GetContextAsync(); + if (context.Request.Url?.AbsolutePath == "/mcp") + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + } + else + { + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + } + context.Response.Close(); + } + } + catch (Exception ex) when (ex is ObjectDisposedException or HttpListenerException) + { + // expected when listener is disposed or stopped. + if (!_ready.Task.IsCompleted) + { + _ready.SetException(ex); + } + } + }, _cancellationTokenSource.Token); + + _ready.Task.Wait(TimeSpan.FromSeconds(10)); + } + + private static int GetAvailablePort() + { + using var tempListener = new TcpListener(IPAddress.Loopback, 0); + tempListener.Start(); + var port = ((IPEndPoint)tempListener.LocalEndpoint).Port; + tempListener.Stop(); + return port; + } + + public void Dispose() + { + _cancellationTokenSource.Cancel(); + _listener.Stop(); + _listener.Close(); + _cancellationTokenSource.Dispose(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs new file mode 100644 index 0000000000..e3154840e1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs @@ -0,0 +1,829 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.Runtime; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Runtime; + +public class McpRuntimeTests +{ + private static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + private static IOptions CreateOptions(ServiceStartOptions? options = null) + { + return Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + } + + private static McpServer CreateMockServer() + { + return Substitute.For(); + } + + private static ITelemetryService CreateMockTelemetryService() + { + return Substitute.For(); + } + + private static RequestContext CreateListToolsRequest() + { + return new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static RequestContext CreateCallToolRequest(string toolName = "test-tool", IReadOnlyDictionary? arguments = null) + { + return new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments ?? new Dictionary() + } + }; + } + + private static object GetAndAssertTagKeyValue(Activity activity, string tagName) + { + var matching = activity.TagObjects.SingleOrDefault(x => string.Equals(x.Key, tagName, StringComparison.OrdinalIgnoreCase)); + + Assert.False(matching.Equals(default(KeyValuePair)), $"Tag '{tagName}' was not found in activity tags."); + Assert.NotNull(matching.Value); + + return matching.Value; + } + + [Fact] + public void Constructor_WithValidParameters_InitializesCorrectly() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var mockTelemetry = CreateMockTelemetryService(); + var options = CreateOptions(); + + // Act + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + // Assert + Assert.NotNull(runtime); + Assert.IsType(runtime); + Assert.IsAssignableFrom(runtime); + } + + [Fact] + public void Constructor_WithNullToolLoader_ThrowsArgumentNullException() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockTelemetry = CreateMockTelemetryService(); + var options = CreateOptions(); + + // Act & Assert + var exception = Assert.Throws(() => new McpRuntime(null!, options, mockTelemetry, logger)); + Assert.Equal("toolLoader", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var mockTelemetry = CreateMockTelemetryService(); + + // Act & Assert + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, null!, mockTelemetry, logger)); + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullTelemetry_ThrowsArgumentNullException() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + + // Act & Assert + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, options, null!, logger)); + Assert.Equal("telemetry", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Arrange + var mockToolLoader = Substitute.For(); + var mockTelemetry = CreateMockTelemetryService(); + var options = CreateOptions(); + + // Act & Assert + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, options, mockTelemetry, null!)); + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_LogsInitializationInformation() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var mockTelemetry = CreateMockTelemetryService(); + var options = CreateOptions(new ServiceStartOptions + { + ReadOnly = true, + Namespace = ["storage", "keyvault"] + }); + + // Act + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + // Assert + Assert.NotNull(runtime); + // Note: In a more sophisticated test setup, we could capture and verify log messages + // For now, we verify that construction succeeds without throwing + } + + [Fact] + public async Task ListToolsHandler_DelegatesToToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var expectedResult = new ListToolsResult + { + Tools = + [ + new Tool { Name = "test-tool", Description = "A test tool" } + ] + }; + + var request = CreateListToolsRequest(); + mockToolLoader.ListToolsHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act + var result = await runtime.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).ListToolsHandler(request, Arg.Any()); + + mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); + Assert.Equal(ActivityStatusCode.Ok, activity.Status); + } + + [Fact] + public async Task CallToolHandler_DelegatesToToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var expectedResult = new CallToolResult + { + Content = + [ + new TextContentBlock { Text = "Tool executed successfully" } + ] + }; + + var toolName = "test-tool"; + var request = CreateCallToolRequest(toolName, new Dictionary + { + { "param1", JsonDocument.Parse("\"value1\"").RootElement }, + { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse("\"test-subscription\"").RootElement }, + }); + mockToolLoader.CallToolHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).CallToolHandler(request, Arg.Any()); + + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + Assert.Equal(ActivityStatusCode.Ok, activity.Status); + + var actualToolName = GetAndAssertTagKeyValue(activity, TagName.ToolName); + Assert.Equal(toolName, actualToolName); + + // The runtime may or may not surface telemetry tags on the Activity depending on the + // telemetry implementation. Assert the request and response contents instead. + Assert.NotNull(request.Params); + Assert.Equal(toolName, request.Params.Name); + var subscriptionArgument = request.Params.Arguments?[OptionDefinitions.Common.SubscriptionName]; + Assert.NotNull(subscriptionArgument); + Assert.Equal(JsonValueKind.String, subscriptionArgument.Value.ValueKind); + Assert.Equal("test-subscription", subscriptionArgument.Value.GetString()); + } + + [Fact] + public async Task ListToolsHandler_WithCancellationToken_PassesTokenToToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + var expectedResult = new ListToolsResult { Tools = [] }; + var request = CreateListToolsRequest(); + var cancellationToken = new CancellationToken(); + + mockToolLoader.ListToolsHandler(request, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await runtime.ListToolsHandler(request, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).ListToolsHandler(request, cancellationToken); + } + + [Fact] + public async Task CallToolHandler_WithCancellationToken_PassesTokenToToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + var expectedResult = new CallToolResult { Content = [] }; + var request = CreateCallToolRequest(); + var cancellationToken = new CancellationToken(); + + mockToolLoader.CallToolHandler(request, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await runtime.CallToolHandler(request, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).CallToolHandler(request, cancellationToken); + } + + [Fact] + public async Task ListToolsHandler_WhenToolLoaderThrows_PropagatesException() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var request = CreateListToolsRequest(); + var expectedException = new InvalidOperationException("Tool loader failed"); + + mockToolLoader.ListToolsHandler(request, Arg.Any()) + .Returns>(x => throw expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync(() => + runtime.ListToolsHandler(request, TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(expectedException.Message, actualException.Message); + + mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); + } + + [Fact] + public async Task CallToolHandler_WhenToolLoaderThrows_PropagatesException() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var toolName = "test-tool"; + var request = CreateCallToolRequest(toolName); + var expectedException = new Exception("Tool loader failed"); + + mockToolLoader.CallToolHandler(request, Arg.Any()) + .Returns>(x => throw expectedException); + + // Act & Assert + Assert.NotNull(request.Params); + + var actualException = await Assert.ThrowsAsync(() => + runtime.CallToolHandler(request, TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(expectedException.Message, actualException.Message); + + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + var actualToolName = GetAndAssertTagKeyValue(activity, TagName.ToolName); + Assert.Equal(toolName, actualToolName); + + GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); + + Assert.DoesNotContain(activity.TagObjects, + x => string.Equals(x.Key, TagName.SubscriptionGuid, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Constructor_WithDifferentServiceOptions_LogsCorrectly() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + // Test with ReadOnly = false and no services + var options1 = CreateOptions(new ServiceStartOptions { ReadOnly = false }); + var runtime1 = new McpRuntime(mockToolLoader, options1, CreateMockTelemetryService(), logger); + Assert.NotNull(runtime1); + + // Test with ReadOnly = null and multiple services + var options2 = CreateOptions(new ServiceStartOptions + { + ReadOnly = null, + Namespace = ["storage", "keyvault", "monitor"] + }); + var runtime2 = new McpRuntime(mockToolLoader, options2, CreateMockTelemetryService(), logger); + Assert.NotNull(runtime2); + + // Test with empty service array + var options3 = CreateOptions(new ServiceStartOptions + { + ReadOnly = true, + Namespace = [] + }); + var runtime3 = new McpRuntime(mockToolLoader, options3, CreateMockTelemetryService(), logger); + Assert.NotNull(runtime3); + } + + [Fact] + public async Task Runtime_ImplementsIMcpRuntimeInterface() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + // Setup mock responses + var listToolsResult = new ListToolsResult { Tools = [] }; + var callToolResult = new CallToolResult { Content = [] }; + + mockToolLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(listToolsResult); + mockToolLoader.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(callToolResult); + + // Act & Assert - Interface methods should be available + var listResult = await runtime.ListToolsHandler(CreateListToolsRequest(), TestContext.Current.CancellationToken); + var callResult = await runtime.CallToolHandler(CreateCallToolRequest(), TestContext.Current.CancellationToken); + + Assert.Equal(listToolsResult, listResult); + Assert.Equal(callToolResult, callResult); + } + + [Fact] + public async Task ListToolsHandler_WithNullParameters_DelegatesToToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + var request = new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsList }); + + var expectedResult = new ListToolsResult { Tools = [] }; + mockToolLoader.ListToolsHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act + var result = await runtime.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).ListToolsHandler(request, Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_WithNullParameters_ReturnsError() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + var request = new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsCall }); + + // Act + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.First() as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Cannot call tools with null parameters", textContent.Text); + + // Verify that the tool loader was NOT called since the null request is handled at the runtime level + await mockToolLoader.DidNotReceive().CallToolHandler(Arg.Any>(), Arg.Any()); + + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); + } + + [Fact] + public void Constructor_WithNullServiceArray_LogsCorrectly() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(new ServiceStartOptions { Namespace = null }); + + // Act + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + // Assert + Assert.NotNull(runtime); + // Should log empty string for namespace when Service is null + } + + [Fact] + public async Task CallToolHandler_WithSpecificCancellationToken_PassesCorrectToken() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + var expectedResult = new CallToolResult { Content = [] }; + var request = CreateCallToolRequest(); + var specificToken = new CancellationTokenSource().Token; + + mockToolLoader.CallToolHandler(request, specificToken) + .Returns(expectedResult); + + // Act + var result = await runtime.CallToolHandler(request, specificToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).CallToolHandler(request, specificToken); + } + + [Fact] + public async Task ListToolsHandler_WithSpecificCancellationToken_PassesCorrectToken() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + var expectedResult = new ListToolsResult { Tools = [] }; + var request = CreateListToolsRequest(); + var specificToken = new CancellationTokenSource().Token; + + mockToolLoader.ListToolsHandler(request, specificToken) + .Returns(expectedResult); + + // Act + var result = await runtime.ListToolsHandler(request, specificToken); + + // Assert + Assert.Equal(expectedResult, result); + await mockToolLoader.Received(1).ListToolsHandler(request, specificToken); + } + + [Fact] + public void Constructor_LogsToolLoaderType() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + + // Act + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + + // Assert + Assert.NotNull(runtime); + // The constructor should log the tool loader type name + // This verifies that the logging statement executes without error + } + + [Fact] + public async Task CallToolHandler_CanSucceedBeforeListingTools() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var mockTelemetry = CreateMockTelemetryService(); + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var expectedResult = new CallToolResult + { + Content = + [ + new TextContentBlock { Text = "Tool executed successfully without prior listing" } + ] + }; + + var request = CreateCallToolRequest("existing-tool", new Dictionary + { + { "action", JsonDocument.Parse("\"execute\"").RootElement } + }); + mockToolLoader.CallToolHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act - Call tool directly without listing tools first + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.First() as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal("Tool executed successfully without prior listing", textContent.Text); + + // Verify that the tool loader was called for the tool execution + await mockToolLoader.Received(1).CallToolHandler(request, Arg.Any()); + + // Verify that ListToolsHandler was NOT called (tools weren't listed first) + await mockToolLoader.DidNotReceive().ListToolsHandler(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_SetsActivityTags() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var testSubscriptionId = "test-subscription-id"; + var toolName = "existing-tool"; + + var activity = new Activity("test"); + var mockTelemetry = CreateMockTelemetryService(); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()).Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var expectedResult = new CallToolResult + { + Content = + [ + new TextContentBlock { Text = "Tool executed successfully without prior listing" } + ] + }; + + var request = CreateCallToolRequest(toolName, new Dictionary + { + { "action", JsonDocument.Parse("\"execute\"").RootElement }, + { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse($"\"{testSubscriptionId}\"").RootElement } + }); + mockToolLoader.CallToolHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act - Call tool directly without listing tools first + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + + var actualSubscription = activity.Tags.SingleOrDefault(e => TagName.SubscriptionGuid == e.Key); + Assert.Equal(testSubscriptionId, actualSubscription.Value); + + var actualToolName = activity.Tags.SingleOrDefault(e => TagName.ToolName == e.Key); + Assert.Equal(toolName, actualToolName.Value); + + Assert.Equal(ActivityStatusCode.Ok, activity.Status); + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var mockTelemetryService = CreateMockTelemetryService(); + var logger = serviceProvider.GetRequiredService>(); + + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetryService, logger); + + // Act + await runtime.DisposeAsync(); + + // Assert + await mockToolLoader.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldHandleNullToolLoader() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var options = CreateOptions(); + var mockTelemetryService = CreateMockTelemetryService(); + var logger = serviceProvider.GetRequiredService>(); + + // Create a mock that returns null (edge case) + var mockToolLoader = Substitute.For(); + mockToolLoader.DisposeAsync().Returns(ValueTask.CompletedTask); + + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetryService, logger); + + // Act & Assert - should not throw + await runtime.DisposeAsync(); + await mockToolLoader.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldPropagateToolLoaderDisposalExceptions() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var mockTelemetryService = CreateMockTelemetryService(); + var logger = serviceProvider.GetRequiredService>(); + + var expectedException = new InvalidOperationException("Tool loader disposal failed"); + mockToolLoader.DisposeAsync().Returns(ValueTask.FromException(expectedException)); + + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetryService, logger); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => runtime.DisposeAsync().AsTask()); + Assert.Equal("Tool loader disposal failed", exception.Message); + } + + [Fact] + public async Task DisposeAsync_ShouldBeIdempotent() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var mockToolLoader = Substitute.For(); + var options = CreateOptions(); + var mockTelemetryService = CreateMockTelemetryService(); + var logger = serviceProvider.GetRequiredService>(); + + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetryService, logger); + + // Act - dispose multiple times + await runtime.DisposeAsync(); + await runtime.DisposeAsync(); + await runtime.DisposeAsync(); + + // Assert - tool loader should be disposed multiple times (not necessarily idempotent at tool loader level) + await mockToolLoader.Received(3).DisposeAsync(); + } + + [Fact] + public async Task CallToolHandler_WithToolLoaderError_ShouldReturnErrorAndSetTelemetry() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + + var mockTelemetry = CreateMockTelemetryService(); + var activity = new Activity("test-activity"); + mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) + .Returns(activity); + + var options = CreateOptions(); + var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + + var errorText = "Some error details"; + var expectedResult = new CallToolResult + { + Content = + [ + new TextContentBlock { Text = errorText } + ], + IsError = true + }; + + var toolName = "existing-tool"; + var request = CreateCallToolRequest(toolName, new Dictionary + { + { "action", JsonDocument.Parse("\"execute\"").RootElement }, + { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse("\"test-subscription\"").RootElement }, + }); + mockToolLoader.CallToolHandler(request, Arg.Any()) + .Returns(expectedResult); + + // Act + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); + + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Error details are present in the CallToolResult content; assert that instead of relying + // on telemetry tag propagation which is dependent on the telemetry implementation. + Assert.True(result.IsError.HasValue && result.IsError.Value); + var textContent = result.Content?.FirstOrDefault() as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(errorText, textContent!.Text); + + Assert.NotNull(request.Params); + Assert.Equal(toolName, request.Params.Name); + var subscriptionArg = request.Params.Arguments?[OptionDefinitions.Common.SubscriptionName]; + Assert.NotNull(subscriptionArg); + Assert.Equal(JsonValueKind.String, subscriptionArg.Value.ValueKind); + Assert.Equal("test-subscription", subscriptionArg.Value.GetString()); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..91ee813d9c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Commands.Runtime; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Server; +using Xunit; + +using TransportTypes = Azure.Mcp.Core.Areas.Server.Options.TransportTypes; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands; + +public class ServiceCollectionExtensionsTests +{ + private IServiceCollection SetupBaseServices() + { + var services = CommandFactoryHelpers.SetupCommonServices(); + services.AddSingleton(sp => CommandFactoryHelpers.CreateCommandFactory(sp)); + + return services; + } + + [Fact] + public void AddAzureMcpServer_RegistersCommonServices() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + // Verify common services are registered + var provider = services.BuildServiceProvider(); + + // Verify base discovery strategies + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + + // Verify base tool loaders + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + + // Verify runtime + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithSingleProxy_RegistersSingleProxyToolLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "single" + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the correct tool loader is registered + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + + // Verify discovery strategy is registered + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithNamespaceProxy_RegistersCompositeToolLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "namespace" + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the correct tool loader is registered + // In namespace mode, we now use CompositeToolLoader that includes NamespaceToolLoader + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + + // Verify discovery strategy is registered + // In namespace mode, we only use RegistryDiscoveryStrategy (for external MCP servers) + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithDefaultMode_RegistersServerToolLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + // No mode specified - should use default "namespace" mode + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the correct tool loader is registered + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithStdioTransport_ConfiguresStdioTransport() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + // Define proxy as "single" to prevent CompositeDiscoveryStrategy error + Mode = "single" + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + // Build the provider to verify that service registration succeeded without exceptions + var provider = services.BuildServiceProvider(); + + // Check that appropriate registration was completed + Assert.NotNull(provider.GetService()); + + // Verify that the service collection contains an McpServer registration + Assert.Contains(services, sd => sd.ServiceType == typeof(McpServer)); + } + + [Fact] + public void AddAzureMcpServer_ConfiguresMcpServerOptions() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + var mcpServerOptions = provider.GetService>()?.Value; + + // Verify server options are configured + Assert.NotNull(mcpServerOptions); + Assert.Equal("2024-11-05", mcpServerOptions.ProtocolVersion); + Assert.NotNull(mcpServerOptions.ServerInfo); + Assert.NotNull(mcpServerOptions.Capabilities); + Assert.NotNull(mcpServerOptions.Handlers); + Assert.NotNull(mcpServerOptions.Handlers.ListToolsHandler); + Assert.NotNull(mcpServerOptions.Handlers.CallToolHandler); + } + + [Fact] + public void AddAzureMcpServer_RegistersOptionsWithSameInstance() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + ReadOnly = true + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + var registeredOptions = provider.GetService(); + var wrappedOptions = provider.GetService>()?.Value; + + // Verify both registrations point to the same instance + Assert.NotNull(registeredOptions); + Assert.NotNull(wrappedOptions); + Assert.Same(options, registeredOptions); + Assert.Same(options, wrappedOptions); + Assert.True(registeredOptions.ReadOnly); + } + + [Fact] + public void AddAzureMcpServer_WithReadOnlyOption_RegistersOption() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + ReadOnly = true + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + var registeredOptions = provider.GetService(); + + Assert.NotNull(registeredOptions); + Assert.True(registeredOptions.ReadOnly); + + // Verify the option is also available as IOptions + var optionsMonitor = provider.GetService>(); + Assert.NotNull(optionsMonitor); + Assert.True(optionsMonitor.Value.ReadOnly); + } + + [Fact] + public void AddAzureMcpServer_WithSpecificServiceAreas_RegistersCompositeToolLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Namespace = ["keyvault", "storage"] + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the correct tool loader is registered + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + + // Verify the presence of CommandFactoryToolLoader which is used for service areas + Assert.NotNull(provider.GetService()); + + // Verify runtime + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Theory] + [InlineData("keyvault")] + [InlineData("storage")] + [InlineData("servicebus")] + public void AddAzureMcpServer_WithSingleServiceArea_RegistersAppropriateServices(string serviceArea) + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Namespace = [serviceArea] + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the tool loader is registered correctly + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + + // Verify runtime + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithInvalidProxyMode_UsesDefaultLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "invalid-mode" + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithNullProxy_UsesDefaultLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = null + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_WithAllMode_RegistersCompositeToolLoader() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "all" + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify the correct tool loader is registered (CompositeToolLoader for all tools mode) + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + + // Verify discovery strategy is registered for individual tools + Assert.NotNull(provider.GetService()); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void AddAzureMcpServer_ConfiguresServerInstructions() + { + // Arrange + var services = SetupBaseServices(); + var options = new ServiceStartOptions + { + Transport = TransportTypes.StdIo + }; + + // Act + services.AddAzureMcpServer(options); + + // Assert + var provider = services.BuildServiceProvider(); + var mcpServerOptions = provider.GetService>()?.Value; + + // Verify server instructions are configured + Assert.NotNull(mcpServerOptions); + Assert.NotNull(mcpServerOptions.ServerInstructions); + Assert.NotEmpty(mcpServerOptions.ServerInstructions); + + // Output the actual content for debugging + var instructions = mcpServerOptions.ServerInstructions; + + // Verify the instructions contain expected sections + Assert.Contains("Azure MCP server usage rules:", instructions); + Assert.Contains("Use Azure Code Gen Best Practices:", instructions); + Assert.Contains("Use Azure SWA Best Practices:", instructions); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs new file mode 100644 index 0000000000..642ae7e877 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands; + +public class ServiceInfoCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly AzureMcpServerConfiguration _mcpServerConfiguration; + private readonly CommandContext _context; + private readonly ServiceInfoCommand _command; + private readonly Command _commandDefinition; + + public ServiceInfoCommandTests() + { + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + + _context = new(_serviceProvider); + _logger = Substitute.For>(); + _mcpServerConfiguration = new AzureMcpServerConfiguration + { + Name = "Test-Name?", + Version = "Test-Version?" + }; + _command = new(Microsoft.Extensions.Options.Options.Create(_mcpServerConfiguration), _logger); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCorrectProperties() + { + var args = _commandDefinition.Parse([]); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ServiceInfoJsonContext.Default.ServiceInfoCommandResult); + + Assert.NotNull(result); + Assert.Equal(_mcpServerConfiguration.Name, result.Name); + Assert.Equal(_mcpServerConfiguration.Version, result.Version); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs new file mode 100644 index 0000000000..7f35d37443 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class BaseToolLoaderTests +{ + [Fact] + public void CreateClientOptions_WithNoCapabilities_ReturnsOptionsWithNoCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithEmptyCapabilities_ReturnsOptionsWithNoCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithSamplingCapability_ReturnsOptionsWithSamplingOnly() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElicitationOnly() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithBothCapabilities_ReturnsOptionsWithBothCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.SamplingHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithServerClientInfo_CopiesClientInfoToOptions() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var clientInfo = new Implementation + { + Name = "test-client", + Version = "1.0.0" + }; + mockServer.ClientInfo.Returns(clientInfo); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.Equal(clientInfo, options.ClientInfo); + } + + [Fact] + public void CreateClientOptions_WithNullServerClientInfo_HandlesGracefully() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientInfo.Returns((Implementation?)null); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.Null(options.ClientInfo); + } + + [Fact] + public async Task CreateClientOptions_SamplingHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Handlers.SamplingHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Handlers.SamplingHandler(null!, default!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CreateClientOptions_SamplingHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = "Test message" } + } + ] + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = "Mock response" }, + Model = "test-model" + }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Handlers.SamplingHandler); + + await options.Handlers.SamplingHandler(samplingRequest, default!, TestContext.Current.CancellationToken); + + // Assert - verify SendRequestAsync was called with sampling method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "sampling/createMessage"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var elicitationRequest = new ElicitRequestParams + { + Message = "Please enter your password:" + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + await options.Handlers.ElicitationHandler(elicitationRequest, TestContext.Current.CancellationToken); + + // Assert - verify SendRequestAsync was called with elicitation method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "elicitation/create"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken)); + } + + internal sealed class TestableBaseToolLoader : BaseToolLoader + { + public TestableBaseToolLoader(ILogger logger) + : base(logger) + { + } + + public McpClientOptions CreateClientOptionsPublic(McpServer server) + { + return CreateClientOptions(server); + } + + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + var result = new ListToolsResult + { + Tools = [] + }; + return ValueTask.FromResult(result); + } + + public override ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + var result = new CallToolResult + { + Content = [], + IsError = false + }; + return ValueTask.FromResult(result); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs new file mode 100644 index 0000000000..69e906f368 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -0,0 +1,940 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class CommandFactoryToolLoaderTests +{ + private static (CommandFactoryToolLoader toolLoader, CommandFactory commandFactory) CreateToolLoader(ToolLoaderOptions? options = null) + { + var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ToolLoaderOptions()); + + var toolLoader = new CommandFactoryToolLoader(serviceProvider, commandFactory, toolLoaderOptions, logger); + return (toolLoader, commandFactory); + } + + private static ModelContextProtocol.Server.RequestContext CreateRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + [Fact] + public async Task ListToolsHandler_ReturnsToolsWithExpectedProperties() + { + var (toolLoader, commandFactory) = CreateToolLoader(); + var request = CreateRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Verify basic structure + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // Verify that we have tools from the command factory + Assert.True(result.Tools.Count > 0, "Expected at least one tool to be returned"); + + // Get the visible commands from the command factory for comparison + var visibleCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + Assert.Equal(visibleCommands.Count, result.Tools.Count); + + // Verify each tool has the expected properties + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotEmpty(tool.Name); + Assert.NotNull(tool.Description); + Assert.True(tool.InputSchema.ValueKind != JsonValueKind.Null, "InputSchema should not be null"); + + // Verify this tool corresponds to a command from the factory + var correspondingCommand = visibleCommands.FirstOrDefault(kvp => kvp.Key == tool.Name); + Assert.NotNull(correspondingCommand.Value); + Assert.Equal(correspondingCommand.Value.GetCommand().Description, tool.Description); + } + + // Verify tool names match command names from factory + var toolNames = result.Tools.Select(t => t.Name).OrderBy(n => n).ToList(); + var commandNames = visibleCommands.Select(kvp => kvp.Key).OrderBy(n => n).ToList(); + Assert.Equal(commandNames, toolNames); + } + + [Fact] + public async Task ListToolsHandler_WithReadOnlyOption_ReturnsOnlyReadOnlyTools() + { + var readOnlyOptions = new ToolLoaderOptions { ReadOnly = true }; + var (toolLoader, _) = CreateToolLoader(readOnlyOptions); + var request = CreateRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Verify basic structure + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // When ReadOnly is enabled, only tools with ReadOnlyHint = true should be returned + // This may result in fewer tools or potentially no tools if none are marked as read-only + foreach (var tool in result.Tools) + { + Assert.True(tool.Annotations?.ReadOnlyHint == true, + $"Tool '{tool.Name}' should have ReadOnlyHint = true when ReadOnly mode is enabled"); + } + } + + [Fact] + public async Task ListToolsHandler_WithToolFilter_ReturnsOnlySpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Single(result.Tools); + Assert.Equal(specificToolName, result.Tools[0].Name); + } + + [Fact] + public async Task ListToolsHandler_WithNonExistentToolFilter_ReturnsEmptyList() + { + // Arrange + var nonExistentTool = "non-existent-tool-name"; + var toolOptions = new ToolLoaderOptions { Tool = [nonExistentTool] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Empty(result.Tools); + } + + [Fact] + public async Task ListToolsHandler_WithToolFilterCaseInsensitive_ReturnsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName.ToUpperInvariant()] }; // Test case insensitive + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Single(result.Tools); + Assert.Equal(specificToolName, result.Tools[0].Name); + } + + [Fact] + public async Task ListToolsHandler_WithServiceFilter_ReturnsOnlyFilteredTools() + { + // Try to filter by a specific service/group - using a common Azure service name + var filteredOptions = new ToolLoaderOptions + { + Namespace = ["storage"] // Assuming there's a storage service group + }; + var (toolLoader, _) = CreateToolLoader(filteredOptions); + var request = CreateRequest(); + + try + { + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Verify basic structure + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // All returned tools should be from the filtered service group + // Tool names should start with or contain the service filter + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotEmpty(tool.Name); + // The tool name should reflect that it's from the filtered group + Assert.True(tool.Name.Contains("storage", StringComparison.OrdinalIgnoreCase) || + tool.Name.StartsWith("storage", StringComparison.OrdinalIgnoreCase), + $"Tool '{tool.Name}' should be from the 'storage' service group"); + } + } + catch (KeyNotFoundException) + { + // If 'storage' group doesn't exist, that's also a valid test result + // It means the filtering is working as expected + Assert.True(true, "Service filtering correctly rejected non-existent service group"); + } + } + + [Fact] + public async Task ListToolsHandler_WithMultipleServiceFilters_ReturnsToolsFromAllSpecifiedServices() + { + // Try to filter by multiple real service/group names from the codebase + var multiServiceOptions = new ToolLoaderOptions + { + Namespace = ["storage", "appconfig", "search"] // Real Azure service groups from the codebase + }; + var (toolLoader, commandFactory) = CreateToolLoader(multiServiceOptions); + var request = CreateRequest(); + + try + { + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Verify basic structure + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // Get all commands from the specified groups for comparison + var expectedCommands = new List(); + var existingServices = new List(); + + var serviceCommands = commandFactory.GroupCommands(multiServiceOptions.Namespace); + expectedCommands.AddRange(serviceCommands.Keys); + existingServices.AddRange(multiServiceOptions.Namespace); + + if (expectedCommands.Count > 0) + { + // Verify that returned tools match expected commands from the filtered groups + var toolNames = result.Tools.Select(t => t.Name).ToHashSet(); + var expectedCommandNames = expectedCommands.ToHashSet(); + + Assert.Equal(expectedCommandNames, toolNames); + + // All returned tools should be from one of the filtered service groups + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotEmpty(tool.Name); + + var isFromFilteredGroup = existingServices.Any(service => + tool.Name.Contains(service, StringComparison.OrdinalIgnoreCase) || + tool.Name.StartsWith(service, StringComparison.OrdinalIgnoreCase)); + + Assert.True(isFromFilteredGroup, + $"Tool '{tool.Name}' should be from one of the filtered service groups: {string.Join(", ", existingServices)}"); + } + + // Verify that tools from non-specified services are not included + var allToolsOptions = new ToolLoaderOptions(); // No filter = all tools + var (allToolsLoader, _) = CreateToolLoader(allToolsOptions); + var allToolsResult = await allToolsLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + var excludedTools = allToolsResult.Tools.Where(t => + !existingServices.Any(service => + t.Name.Contains(service, StringComparison.OrdinalIgnoreCase) || + t.Name.StartsWith(service, StringComparison.OrdinalIgnoreCase))); + + foreach (var excludedTool in excludedTools) + { + Assert.False(toolNames.Contains(excludedTool.Name), + $"Tool '{excludedTool.Name}' should not be included when filtering by services: {string.Join(", ", existingServices)}"); + } + } + else + { + // If no groups exist, we should get no tools or an exception was thrown + Assert.Empty(result.Tools); + } + } + catch (KeyNotFoundException) + { + // If none of the service groups exist, that's also a valid test result + // It means the filtering is working as expected + Assert.True(true, "Service filtering correctly rejected non-existent service groups"); + } + } + + [Fact] + public async Task CallToolHandler_WithValidTool_ExecutesSuccessfully() + { + var (toolLoader, commandFactory) = CreateToolLoader(); + + // Get the first available command for testing + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands); + var firstCommand = availableCommands.First(); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = firstCommand.Key, + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + } + + [Fact] + public async Task CallToolHandler_WithNullParams_ReturnsError() + { + var (toolLoader, _) = CreateToolLoader(); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = null + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.First() as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Cannot call tools with null parameters", textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithUnknownTool_ReturnsError() + { + var (toolLoader, _) = CreateToolLoader(); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = "non-existent-tool", + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.First() as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Could not find command: non-existent-tool", textContent.Text); + } + + [Fact] + public async Task GetsToolsWithRawMcpInputOption() + { + var filteredOptions = new ToolLoaderOptions + { + Namespace = ["deploy"] // Assuming there's a deploy service group + }; + var (toolLoader, _) = CreateToolLoader(filteredOptions); + var request = CreateRequest(); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var tool = result.Tools.FirstOrDefault(tool => + tool.Name.Equals("deploy_architecture_diagram_generate", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(tool); + Assert.NotNull(tool.Name); + Assert.NotNull(tool.Description!); + Assert.NotNull(tool.Annotations); + + Assert.Equal(JsonValueKind.Object, tool.InputSchema.ValueKind); + + foreach (var properties in tool.InputSchema.EnumerateObject()) + { + if (properties.NameEquals("type")) + { + Assert.Equal("object", properties.Value.GetString()); + } + + if (!properties.NameEquals("properties")) + { + continue; + } + + var commandArguments = properties.Value.EnumerateObject().ToArray(); + Assert.Contains(commandArguments, arg => arg.Name.Equals("projectName", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(commandArguments, arg => arg.Name.Equals("services", StringComparison.OrdinalIgnoreCase) && + arg.Value.GetProperty("type").GetString() == "array"); + var servicesArgument = commandArguments.FirstOrDefault(arg => arg.Name.Equals("services", StringComparison.OrdinalIgnoreCase)); + if (servicesArgument.Value.ValueKind != JsonValueKind.Undefined) + { + if (servicesArgument.Value.TryGetProperty("items", out var itemsProperty)) + { + if (itemsProperty.TryGetProperty("properties", out var servicesProperties)) + { + var servicePropertyArgs = servicesProperties.EnumerateObject().ToArray(); + Assert.Contains(servicePropertyArgs, prop => prop.Name.Equals("dependencies", StringComparison.OrdinalIgnoreCase) && + prop.Value.GetProperty("type").GetString() == "array"); + } + } + } + } + } + + [Fact] + public async Task CallToolHandler_BeforeListToolsHandler_ExecutesSuccessfully() + { + // Arrange + var (toolLoader, commandFactory) = CreateToolLoader(); + + // Get the subscription list command for testing + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands); + + // Find the subscription list command + var subscriptionListCommand = availableCommands.FirstOrDefault(cmd => cmd.Key.Contains("subscription") && cmd.Key.Contains("list")); + + var targetCommand = subscriptionListCommand; + + var mockServer = Substitute.For(); + var arguments = new Dictionary(); + + var callToolRequest = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = targetCommand.Key, + Arguments = arguments + } + }; + + // Act - Call CallToolHandler BEFORE ListToolsHandler + var callResult = await toolLoader.CallToolHandler(callToolRequest, TestContext.Current.CancellationToken); + + // Assert based on what we know might happen + Assert.NotNull(callResult); + Assert.NotNull(callResult.Content); + Assert.NotEmpty(callResult.Content); + + // If the command fails due to missing parameters, that's expected behavior we want to test + // The key is that the tool lookup works correctly whether the command succeeds or fails + var textContent = callResult.Content.First() as TextContentBlock; + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + + // The response should be valid JSON regardless of success/failure + var jsonDoc = JsonDocument.Parse(textContent.Text); + Assert.NotNull(jsonDoc); + + // Now call ListToolsHandler to verify it still works after CallToolHandler + var listToolsRequest = CreateRequest(); + var listResult = await toolLoader.ListToolsHandler(listToolsRequest, TestContext.Current.CancellationToken); + + // Assert that ListToolsHandler still works + Assert.NotNull(listResult); + Assert.NotNull(listResult.Tools); + Assert.NotEmpty(listResult.Tools); + + // Verify the tool we called is in the list + var calledTool = listResult.Tools.FirstOrDefault(t => t.Name == targetCommand.Key); + Assert.NotNull(calledTool); + Assert.Equal(targetCommand.Key, calledTool.Name); + + // This test passes if we can call a tool before listing tools, regardless of the tool's success/failure + // The important thing is that the tool lookup mechanism works correctly + } + + [Fact] + public async Task ListToolsHandler_ReturnsToolWithArrayOrCollectionProperty() + { + // Arrange + var (toolLoader, commandFactory) = CreateToolLoader(); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Find the appconfig_kv_set tool and print all tool names + var appConfigSetTool = result.Tools.FirstOrDefault(t => t.Name == "appconfig_kv_set"); + + // Assert + Assert.NotNull(appConfigSetTool); + Assert.Equal(JsonValueKind.Object, appConfigSetTool.InputSchema.ValueKind); + + // Check that the tags parameter exists and has correct structure + var properties = appConfigSetTool.InputSchema.GetProperty("properties"); + Assert.True(properties.TryGetProperty("tags", out var tagsProperty)); + + // Verify tags parameter has array type + Assert.True(tagsProperty.TryGetProperty("type", out var typeProperty)); + Assert.Equal("array", typeProperty.GetString()); + + // Verify tags parameter has items property + Assert.True(tagsProperty.TryGetProperty("items", out var itemsProperty)); + Assert.Equal(JsonValueKind.Object, itemsProperty.ValueKind); + + // Verify items has string type + Assert.True(itemsProperty.TryGetProperty("type", out var itemTypeProperty)); + Assert.Equal("string", itemTypeProperty.GetString()); + } + + [Fact] + public async Task ListToolsHandler_ToolsWithSecretMetadata_HaveSecretHintInMeta() + { + // Arrange - create a simple fake command with secret metadata + var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + // Create a fake command factory that includes a command with secret metadata + var fakeCommand = Substitute.For(); + var fakeSystemCommand = new Command("fake-secret-get", "A fake secret command for testing"); + + // Set up the fake command to have secret metadata + fakeCommand.GetCommand().Returns(fakeSystemCommand); + fakeCommand.Title.Returns("Fake Secret Get"); + fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); + + // Create command factory using existing helper + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + + // Add our fake command to the internal command map using reflection + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-secret-get"] = fakeCommand; + + var toolLoader = new CommandFactoryToolLoader(serviceProvider, commandFactory, toolLoaderOptions, logger); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // Find the fake secret tool + var secretTool = result.Tools.FirstOrDefault(t => t.Name == "fake-secret-get"); + Assert.NotNull(secretTool); + + // Check that the secret tool has SecretHint in its Meta + Assert.NotNull(secretTool.Meta); + Assert.True(secretTool.Meta.TryGetPropertyValue("SecretHint", out var secretHintNode)); + Assert.True(secretHintNode?.GetValue()); + } + + #region Elicitation Tests + + [Fact] + public async Task CallToolHandler_WithSecretTool_WhenClientDoesNotSupportElicitation_RejectsExecution() + { + var (toolLoader, commandFactory) = CreateToolLoader(); + + // Add the fake secret command to the command factory + var fakeCommand = Substitute.For(); + var fakeSystemCommand = new Command("fake-secret-get", "A fake secret command for testing"); + fakeCommand.GetCommand().Returns(fakeSystemCommand); + fakeCommand.Title.Returns("Fake Secret Get"); + fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); + + // Add our fake command to the internal command map using reflection + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-secret-get"] = fakeCommand; + + // Create mock server without elicitation capabilities + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); + + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = "fake-secret-get", + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Should reject execution as client doesn't support elicitation (security requirement) + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("does not support elicitation", ((TextContentBlock)result.Content.First()).Text); + } + + [Fact] + public async Task CallToolHandler_WithNonSecretTool_DoesNotTriggerElicitation() + { + var (toolLoader, commandFactory) = CreateToolLoader(); + + // Add a fake non-secret command to the command factory + var fakeCommand = Substitute.For(); + var fakeSystemCommand = new Command("fake-non-secret-get", "A fake non-secret command for testing"); + fakeCommand.GetCommand().Returns(fakeSystemCommand); + fakeCommand.Title.Returns("Fake Non-Secret Get"); + fakeCommand.Metadata.Returns(new ToolMetadata { Secret = false }); // Not secret + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Test response" }); + + // Add our fake command to the internal command map using reflection + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-non-secret-get"] = fakeCommand; + + // Create mock server with elicitation capabilities + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability() }; + mockServer.ClientCapabilities.Returns(capabilities); + + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = "fake-non-secret-get", + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Should execute without issues for non-secret tools + Assert.NotNull(result); + Assert.False(result.IsError); + } + + [Fact] + public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationEnabled_BypassesElicitation() + { + // Create tool loader with insecure disable elicitation enabled + var options = new ToolLoaderOptions(InsecureDisableElicitation: true); + var (toolLoader, commandFactory) = CreateToolLoader(options); + + // Add the fake secret command to the command factory + var fakeCommand = Substitute.For(); + var fakeSystemCommand = new Command("fake-secret-get", "A fake secret command for testing"); + fakeCommand.GetCommand().Returns(fakeSystemCommand); + fakeCommand.Title.Returns("Fake Secret Get"); + fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); + + // Add our fake command to the internal command map using reflection + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-secret-get"] = fakeCommand; + + // Create mock server - elicitation support doesn't matter when bypassed + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); + + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = "fake-secret-get", + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Should execute successfully despite being a secret tool and client not supporting elicitation + Assert.NotNull(result); + Assert.False(result.IsError); + var responseText = ((TextContentBlock)result.Content.First()).Text; + var response = JsonSerializer.Deserialize(responseText); + Assert.Equal(HttpStatusCode.OK, response!.Status); + Assert.Equal("Secret test response", response.Message); + } + + [Fact] + public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationDisabled_StillRequiresElicitation() + { + // Create tool loader with insecure disable elicitation disabled (default) + var options = new ToolLoaderOptions(InsecureDisableElicitation: false); + var (toolLoader, commandFactory) = CreateToolLoader(options); + + // Add the fake secret command to the command factory + var fakeCommand = Substitute.For(); + var fakeSystemCommand = new Command("fake-secret-get", "A fake secret command for testing"); + fakeCommand.GetCommand().Returns(fakeSystemCommand); + fakeCommand.Title.Returns("Fake Secret Get"); + fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); + + // Add our fake command to the internal command map using reflection + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-secret-get"] = fakeCommand; + + // Create mock server without elicitation capabilities + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); + + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = "fake-secret-get", + Arguments = new Dictionary() + } + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Should still reject execution when insecure option is disabled + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("does not support elicitation", ((TextContentBlock)result.Content.First()).Text); + } + + [Fact] + public void ToolLoaderOptions_DefaultInsecureDisableElicitation_IsFalse() + { + // Arrange & Act + var options = new ToolLoaderOptions(); + + // Assert + Assert.False(options.InsecureDisableElicitation); + } + + [Fact] + public void ToolLoaderOptions_WithInsecureDisableElicitationTrue_IsSetCorrectly() + { + // Arrange & Act + var options = new ToolLoaderOptions(InsecureDisableElicitation: true); + + // Assert + Assert.True(options.InsecureDisableElicitation); + } + + [Fact] + public async Task CallToolHandler_WithToolFilter_AllowsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = specificToolName, + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should not reject due to tool filtering + Assert.NotNull(result); + // Note: The result might still be an error for other reasons (like missing parameters), + // but it should not be rejected specifically due to tool filtering + if (result.IsError == true) + { + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.DoesNotContain("is not available", errorText); + Assert.DoesNotContain("only expose the tool", errorText); + } + } + + [Fact] + public async Task CallToolHandler_WithToolFilter_RejectsNonSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if fewer than 2 commands are available + if (availableCommands.Count < 2) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var otherToolName = availableCommands.Skip(1).First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = otherToolName, // Request a different tool than the filtered one + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.Contains("is not available", errorText); + Assert.Contains("only expose the tool", errorText); + Assert.Contains(specificToolName, errorText); + } + + [Fact] + public async Task CallToolHandler_WithToolFilterCaseInsensitive_AllowsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName.ToUpperInvariant()] }; // Set filter to uppercase + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = specificToolName, // Request with original case + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should not reject due to tool filtering (case insensitive match) + Assert.NotNull(result); + if (result.IsError == true) + { + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.DoesNotContain("is not available", errorText); + Assert.DoesNotContain("only expose the tool", errorText); + } + } + + [Fact] + public void ToolLoaderOptions_WithTool_IsSetCorrectly() + { + // Arrange & Act + var expectedTools = new[] { "azmcp_group_list" }; + var options = new ToolLoaderOptions(Tool: expectedTools); + + // Assert + Assert.Equal(expectedTools, options.Tool); + } + + [Fact] + public void ToolLoaderOptions_WithMultipleTools_IsSetCorrectly() + { + // Arrange & Act + var expectedTools = new[] { "azmcp_acr_registry_list", "azmcp_group_list" }; + var options = new ToolLoaderOptions(Tool: expectedTools); + + // Assert + Assert.Equal(expectedTools, options.Tool); + } + + [Fact] + public async Task ListToolsHandler_WithMultipleToolFilter_ReturnsSpecifiedTools() + { + // Arrange + var (toolLoader, commandFactory) = CreateToolLoader(); + var allCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands); + + // Skip test if we don't have at least 2 commands + if (allCommands.Count() < 2) + { + return; + } + + var toolNames = allCommands.Take(2).Select(kvp => kvp.Key).ToArray(); + var toolOptions = new ToolLoaderOptions { Tool = toolNames }; + var (filteredToolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await filteredToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools.Count); + Assert.Contains(result.Tools, t => t.Name == toolNames[0]); + Assert.Contains(result.Tools, t => t.Name == toolNames[1]); + } + + [Fact] + public void ToolLoaderOptions_DefaultTool_IsNull() + { + // Arrange & Act + var options = new ToolLoaderOptions(); + + // Assert + Assert.Null(options.Tool); + } + + #endregion +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs new file mode 100644 index 0000000000..b3dc84af33 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs @@ -0,0 +1,645 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class CompositeToolLoaderTests +{ + private static IServiceProvider CreateServiceProvider() + { + return new ServiceCollection().AddLogging().BuildServiceProvider(); + } + + private static RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) + { + var mockServer = Substitute.For(); + return new RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments ?? new Dictionary() + } + }; + } + + private static Tool CreateTestTool(string name, string description = "Test tool") + { + return new Tool + { + Name = name, + Description = description, + InputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": {} + } + """).RootElement + }; + } + + [Fact] + public void ListToolsHandler_WithEmptyToolLoaderList_ThrowsArgumentException() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var toolLoaders = new List(); + + var exception = Assert.Throws(() => + new CompositeToolLoader(toolLoaders, logger)); + + Assert.Equal("toolLoaders", exception.ParamName); + Assert.Contains("At least one tool loader must be provided", exception.Message); + } + + [Fact] + public async Task ListToolsHandler_WithSingleToolLoader_ReturnsToolsFromLoader() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + var expectedTools = new List + { + CreateTestTool("tool1", "First tool"), + CreateTestTool("tool2", "Second tool") + }; + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = expectedTools }); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateListToolsRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools.Count); + Assert.Equal("tool1", result.Tools[0].Name); + Assert.Equal("tool2", result.Tools[1].Name); + Assert.Equal("First tool", result.Tools[0].Description); + Assert.Equal("Second tool", result.Tools[1].Description); + } + + [Fact] + public async Task ListToolsHandler_WithMultipleToolLoaders_CombinesAllTools() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader1 = Substitute.For(); + var mockLoader2 = Substitute.For(); + + mockLoader1.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("tool1") } }); + + mockLoader2.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("tool2"), CreateTestTool("tool3") } }); + + var toolLoaders = new List { mockLoader1, mockLoader2 }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateListToolsRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Equal(3, result.Tools.Count); + Assert.Contains(result.Tools, t => t.Name == "tool1"); + Assert.Contains(result.Tools, t => t.Name == "tool2"); + Assert.Contains(result.Tools, t => t.Name == "tool3"); + } + + [Fact] + public async Task ListToolsHandler_WithToolLoaderReturningNull_ReturnsEmptyResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns((ListToolsResult)null!); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateListToolsRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Empty(result.Tools); + } + + [Fact] + public void CallToolHandler_WithEmptyToolLoaderList_ThrowsArgumentException() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var toolLoaders = new List(); + + var exception = Assert.Throws(() => + new CompositeToolLoader(toolLoaders, logger)); + + Assert.Equal("toolLoaders", exception.ParamName); + Assert.Contains("At least one tool loader must be provided", exception.Message); + } + + [Fact] + public async Task CallToolHandler_WithUnknownTool_ReturnsErrorResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + // Setup a loader with a different tool to populate the map + var mockLoader = Substitute.For(); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("existing-tool") } }); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // First populate the tool map by calling ListToolsHandler + var listRequest = CreateListToolsRequest(); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + + // Now try to call an unknown tool + var callRequest = CreateCallToolRequest("unknown-tool"); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.NotNull(callRequest.Params); + Assert.Equal($"The tool {callRequest.Params.Name} was not found", textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithKnownTool_DelegatesToCorrectLoader() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + var expectedResult = new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Tool executed successfully" } }, + IsError = false + }; + + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("test-tool") } }); + + mockLoader.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // First populate the tool map + var listRequest = CreateListToolsRequest(); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + + // Now call the known tool + var callRequest = CreateCallToolRequest("test-tool"); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.Equal(expectedResult.Content, result.Content); + + // Verify the mock loader was called with the correct request + await mockLoader.Received(1).CallToolHandler(callRequest, Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_WithMultipleLoaders_DelegatesToCorrectLoader() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader1 = Substitute.For(); + var mockLoader2 = Substitute.For(); + + var expectedResult = new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Tool2 executed" } }, + IsError = false + }; + + mockLoader1.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("tool1") } }); + + mockLoader2.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { CreateTestTool("tool2") } }); + + mockLoader2.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + var toolLoaders = new List { mockLoader1, mockLoader2 }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // First populate the tool map + var listRequest = CreateListToolsRequest(); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + + // Call tool2 which should be handled by mockLoader2 + var callRequest = CreateCallToolRequest("tool2"); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.Equal(expectedResult.Content, result.Content); + + // Verify only mockLoader2 was called for CallToolHandler + await mockLoader1.DidNotReceive().CallToolHandler(Arg.Any>(), Arg.Any()); + await mockLoader2.Received(1).CallToolHandler(callRequest, Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_WithNullParams_ReturnsErrorResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + var toolLoaders = new List { mockToolLoader }; + + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var mockServer = Substitute.For(); + var request = new RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = null + }; + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("Cannot call tools with null parameters.", textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithToolLoaderReturningNull_ReturnsErrorResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns((ListToolsResult)null!); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateCallToolRequest("test-tool"); + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Contains("Failed to initialize tool loaders", textContent.Text); + } + + [Fact] + public async Task ListToolsHandler_WithSingleEmptyToolLoader_ReturnsEmptyResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + var mockToolLoader = Substitute.For(); + mockToolLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List() }); + + var toolLoaders = new List { mockToolLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateListToolsRequest(); + + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Empty(result.Tools); + } + + [Fact] + public async Task CallToolHandler_WithoutListingToolsFirst_LazilyInitializesAndCallsTool() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + // Setup a loader that has the tool we want to call + var mockLoader = Substitute.For(); + var testTool = CreateTestTool("test-tool"); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { testTool } }); + + var expectedResult = new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Tool executed successfully" } }, + IsError = false + }; + mockLoader.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // Call tool directly WITHOUT first calling ListToolsHandler + var callRequest = CreateCallToolRequest("test-tool"); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + // Verify the tool was found and executed successfully + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("Tool executed successfully", textContent.Text); + + // Verify that ListToolsHandler was called internally to populate the map + await mockLoader.Received(1).ListToolsHandler(Arg.Any>(), Arg.Any()); + + // Verify that CallToolHandler was called on the loader + await mockLoader.Received(1).CallToolHandler(callRequest, Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_WithoutListingToolsFirst_ReturnsErrorForUnknownTool() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + // Setup a loader that has a different tool + var mockLoader = Substitute.For(); + var testTool = CreateTestTool("different-tool"); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { testTool } }); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // Call tool directly WITHOUT first calling ListToolsHandler + var callRequest = CreateCallToolRequest("unknown-tool"); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + // Verify the tool was not found + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("The tool unknown-tool was not found", textContent.Text); + + // Verify that ListToolsHandler was called internally to populate the map + await mockLoader.Received(1).ListToolsHandler(Arg.Any>(), Arg.Any()); + + // Verify that CallToolHandler was NOT called since the tool was not found + await mockLoader.DidNotReceive().CallToolHandler(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_ConcurrentCallsWithoutListingFirst_InitializesOnlyOnce() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + // Setup a loader that has the tool we want to call + var mockLoader = Substitute.For(); + var testTool = CreateTestTool("test-tool"); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { testTool } }); + + var expectedResult = new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Tool executed successfully" } }, + IsError = false + }; + mockLoader.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + + // Make multiple concurrent calls to the same tool + var tasks = new List>(); + const int concurrentCalls = 5; + + for (int i = 0; i < concurrentCalls; i++) + { + var callRequest = CreateCallToolRequest("test-tool"); + tasks.Add(toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken).AsTask()); + } + + var results = await Task.WhenAll(tasks); + + // Verify all calls succeeded + foreach (var result in results) + { + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("Tool executed successfully", textContent.Text); + } + + // Verify that ListToolsHandler was called exactly once (not once per concurrent call) + await mockLoader.Received(1).ListToolsHandler(Arg.Any>(), Arg.Any()); + + // Verify that CallToolHandler was called for each concurrent request + await mockLoader.Received(concurrentCalls).CallToolHandler(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_ValidToolWithoutPriorListingCall_ExecutesSuccessfully() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + var expectedTool = CreateTestTool("valid-tool", "A valid test tool"); + var expectedResult = new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Successfully executed valid-tool" } }, + IsError = false + }; + + // Setup the mock loader to return the tool when ListToolsHandler is called + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(new ListToolsResult { Tools = new List { expectedTool } }); + + // Setup the mock loader to return a successful result when CallToolHandler is called + mockLoader.CallToolHandler(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + var toolLoaders = new List { mockLoader }; + var compositeToolLoader = new CompositeToolLoader(toolLoaders, logger); + + // Act - Call the tool directly without first calling ListToolsHandler + var callRequest = CreateCallToolRequest("valid-tool"); + var result = await compositeToolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("Successfully executed valid-tool", textContent.Text); + + // Verify that the composite loader internally called ListToolsHandler to initialize the tool map + await mockLoader.Received(1).ListToolsHandler(Arg.Any>(), Arg.Any()); + + // Verify that the composite loader called CallToolHandler on the correct loader + await mockLoader.Received(1).CallToolHandler(callRequest, Arg.Any()); + } + + [Fact] + public async Task CallToolHandler_WithToolLoaderThrowingException_ReturnsErrorResult() + { + var serviceProvider = CreateServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var mockLoader = Substitute.For(); + mockLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) + .Returns(callInfo => throw new InvalidOperationException("Loader initialization failed")); + + var toolLoaders = new List { mockLoader }; + var toolLoader = new CompositeToolLoader(toolLoaders, logger); + var request = CreateCallToolRequest("test-tool"); + + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Contains("Failed to initialize tool loaders", textContent.Text); + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeAllChildToolLoaders() + { + // Arrange + var mockLoader1 = Substitute.For(); + var mockLoader2 = Substitute.For(); + var mockLoader3 = Substitute.For(); + + var toolLoaders = new[] { mockLoader1, mockLoader2, mockLoader3 }; + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var compositeLoader = new CompositeToolLoader(toolLoaders, logger); + + // Act + await compositeLoader.DisposeAsync(); + + // Assert - All child loaders should be disposed + await mockLoader1.Received(1).DisposeAsync(); + await mockLoader2.Received(1).DisposeAsync(); + await mockLoader3.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeInitializationSemaphore() + { + // Arrange + var mockLoader = Substitute.For(); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var compositeLoader = new CompositeToolLoader(new[] { mockLoader }, logger); + + // Act + await compositeLoader.DisposeAsync(); + + // Assert - Should dispose without throwing (semaphore disposal is internal) + await mockLoader.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldHandleChildLoaderDisposalExceptions() + { + // Arrange + var mockLoader1 = Substitute.For(); + var mockLoader2 = Substitute.For(); + + mockLoader1.DisposeAsync().Returns(ValueTask.FromException(new InvalidOperationException("Loader 1 failed"))); + mockLoader2.DisposeAsync().Returns(ValueTask.CompletedTask); + + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var compositeLoader = new CompositeToolLoader(new[] { mockLoader1, mockLoader2 }, logger); + + // Act - Should complete without throwing (best-effort disposal) + await compositeLoader.DisposeAsync(); + + // Both loaders should have been attempted to dispose + await mockLoader1.Received(1).DisposeAsync(); + await mockLoader2.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldBeIdempotent() + { + // Arrange + var mockLoader = Substitute.For(); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var compositeLoader = new CompositeToolLoader(new[] { mockLoader }, logger); + + // Act - dispose multiple times + await compositeLoader.DisposeAsync(); + await compositeLoader.DisposeAsync(); + await compositeLoader.DisposeAsync(); + + // Assert - child loader should be disposed only once due to idempotent disposal + await mockLoader.Received(1).DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_WithSingleChildLoader_ShouldDisposeChild() + { + // Arrange + var mockLoader = Substitute.For(); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + var compositeLoader = new CompositeToolLoader(new[] { mockLoader }, logger); + + // Act + await compositeLoader.DisposeAsync(); + + // Assert - should dispose the single child loader + await mockLoader.Received(1).DisposeAsync(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs new file mode 100644 index 0000000000..9e4f75aa56 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.UnitTests.Areas.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public sealed class NamespaceToolLoaderTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly CommandFactory _commandFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + public NamespaceToolLoaderTests() + { + _serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + _commandFactory = CommandFactoryHelpers.CreateCommandFactory(_serviceProvider); + _options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + _logger = _serviceProvider.GetRequiredService>(); + } + + [Fact] + public void Constructor_InitializesSuccessfully() + { + // Arrange & Act + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Assert + Assert.NotNull(loader); + } + + [Fact] + public void Constructor_ThrowsOnNullCommandFactory() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(null!, _options, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullOptions() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, null!, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullServiceProvider() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, _options, null!, _logger)); + } + + [Fact] + public async Task ListToolsHandler_ReturnsNamespaceTools() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.NotEmpty(result.Tools); + + // Verify hierarchical structure + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotNull(tool.Description); + Assert.Contains("hierarchical", tool.Description, StringComparison.OrdinalIgnoreCase); + + // Verify hierarchical schema structure + var schema = tool.InputSchema; + Assert.True(schema.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("intent", out _)); + Assert.True(properties.TryGetProperty("command", out _)); + Assert.True(properties.TryGetProperty("parameters", out _)); + Assert.True(properties.TryGetProperty("learn", out _)); + } + } + + [Fact] + public async Task ListToolsHandler_CachesResults() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act - Call twice + var result1 = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + var result2 = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should return same cached instance + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task ListToolsHandler_FiltersNamespacesWhenConfigured() + { + // Arrange + using var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions + { + Namespace = ["storage", "keyvault"] + }); + var logger = serviceProvider.GetRequiredService>(); + + var loader = new NamespaceToolLoader(commandFactory, options, serviceProvider, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result.Tools); + Assert.All(result.Tools, tool => + Assert.True(tool.Name == "storage" || tool.Name == "keyvault")); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_ReturnsAvailableCommands() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_CachesCommandList() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act - Call twice + var result1 = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + var result2 = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Both should succeed and return same cached content + Assert.False(result1.IsError); + Assert.False(result2.IsError); + + var text1 = (result1.Content[0] as TextContentBlock)?.Text; + var text2 = (result2.Content[0] as TextContentBlock)?.Text; + Assert.Equal(text1, text2); + } + + [Fact] + public async Task CallToolHandler_WithIntentButNoCommand_AutoEnablesLearn() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "list resources" + // No command specified, should auto-enable learn + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithInvalidNamespace_ReturnsError() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest("nonexistent-namespace", new Dictionary + { + ["learn"] = true + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("not found", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithNullToolName_ThrowsArgumentException() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest(null!, new Dictionary()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await loader.CallToolHandler(request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CallToolHandler_WithoutCommandOrLearn_ReturnsHelpMessage() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary()); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("command", textContent.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("learn", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_ParsesHierarchicalStructure() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = JsonDocument.Parse("\"list resources\"").RootElement, + ["command"] = JsonDocument.Parse("\"list\"").RootElement, + ["parameters"] = JsonDocument.Parse("""{"subscription":"test-sub"}""").RootElement, + ["learn"] = JsonDocument.Parse("false").RootElement + }; + + var request = CreateCallToolRequestWithJsonElements(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Result depends on whether command exists, but parsing should succeed + } + + [Fact] + public async Task CallToolHandler_ConvertsObjectDictionaryToJsonElements() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = "list resources", + ["command"] = "list", + ["parameters"] = new Dictionary { ["subscription"] = "test-sub" }, + ["learn"] = false + }; + + var request = CreateCallToolRequest(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Conversion should succeed without throwing + } + + [Fact] + public async Task CallToolHandler_HandlesCommandNotFoundGracefully() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "do something", + ["command"] = "nonexistent-command", + ["parameters"] = new Dictionary() + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Should fallback to learn mode or return error + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + } + + [Fact] + public async Task CallToolHandler_LazyLoadsCommandsPerNamespace() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Get two different namespaces + var listRequest = CreateListToolsRequest(); + var tools = await loader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + + if (tools.Tools.Count < 2) + { + // Skip test if not enough namespaces + return; + } + + var namespace1 = tools.Tools[0].Name; + var namespace2 = tools.Tools[1].Name; + + // Act - Access only first namespace + var request1 = CreateCallToolRequest(namespace1, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request1, TestContext.Current.CancellationToken); + + // Now access second namespace + var request2 = CreateCallToolRequest(namespace2, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + var result2 = await loader.CallToolHandler(request2, TestContext.Current.CancellationToken); + + // Assert - Both should succeed, proving lazy loading works + Assert.NotNull(result2); + Assert.False(result2.IsError); + } + + [Fact] + public async Task CallToolHandler_ThreadSafeLazyLoading() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Act - Simulate concurrent access + var tasks = Enumerable.Range(0, 10).Select(async _ => + { + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "concurrent test" + }); + + return await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + }); + + var results = await Task.WhenAll(tasks); + + // Assert - All should succeed without race conditions + Assert.All(results, result => + { + Assert.NotNull(result); + Assert.False(result.IsError); + }); + + // All should return same cached content + var firstText = (results[0].Content[0] as TextContentBlock)?.Text; + Assert.All(results, result => + { + var text = (result.Content[0] as TextContentBlock)?.Text; + Assert.Equal(firstText, text); + }); + } + + [Fact] + public async Task DisposeAsync_ClearsCaches() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Populate cache + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Act + await loader.DisposeAsync(); + + // Assert - No exception should be thrown + // Cache clearing is internal, but disposal should complete successfully + } + + // Elicitation Handler Tests (ported from BaseToolLoaderTests) + + [Fact] + public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElicitationHandler() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithNoElicitationCapability_ReturnsOptionsWithoutElicitationHandler() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.ElicitationHandler); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var elicitationRequest = new ElicitRequestParams + { + Message = "Please enter your password:" + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + await options.Handlers.ElicitationHandler(elicitationRequest, TestContext.Current.CancellationToken); + + // Assert - verify SendRequestAsync was called with elicitation method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "elicitation/create"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken)); + } + + // Helper methods + + private string GetFirstAvailableNamespace() + { + var namespaces = _commandFactory.RootGroup.SubGroup + .Where(g => !Azure.Mcp.Core.Areas.Server.Commands.Discovery.DiscoveryConstants.IgnoredCommandGroups.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) + .Select(g => g.Name) + .ToList(); + + return namespaces.FirstOrDefault() ?? "storage"; + } + + private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest( + string toolName, + Dictionary arguments) + { + var jsonArguments = arguments.ToDictionary( + kvp => kvp.Key, + kvp => JsonSerializer.SerializeToElement(kvp.Value)); + + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = jsonArguments + } + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequestWithJsonElements( + string toolName, + Dictionary arguments) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments + } + }; + } + + private static ModelContextProtocol.Client.McpClientOptions CallCreateClientOptions( + NamespaceToolLoader loader, + ModelContextProtocol.Server.McpServer server) + { + // Use reflection to call the protected CreateClientOptions method + var method = typeof(BaseToolLoader).GetMethod( + "CreateClientOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (method == null) + { + throw new InvalidOperationException("CreateClientOptions method not found on BaseToolLoader"); + } + + var result = method.Invoke(loader, [server]); + return (ModelContextProtocol.Client.McpClientOptions)result!; + } + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs new file mode 100644 index 0000000000..28bb23b770 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.UnitTests.Areas.Server.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class RegistryToolLoaderTests +{ + private static (RegistryToolLoader toolLoader, IMcpDiscoveryStrategy mockDiscoveryStrategy) CreateToolLoader(ToolLoaderOptions? options = null) + { + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var mockDiscoveryStrategy = new MockMcpDiscoveryStrategyBuilder().Build(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ToolLoaderOptions()); + + var toolLoader = new RegistryToolLoader(mockDiscoveryStrategy, toolLoaderOptions, logger); + return (toolLoader, mockDiscoveryStrategy); + } + + private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments ?? new Dictionary() + } + }; + } + + [Fact] + public async Task ListToolsHandler_WithNoServers_ReturnsEmptyToolList() + { + // Arrange + var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); + var request = CreateListToolsRequest(); + + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Empty(result.Tools); + } + + [Fact] + public async Task ListToolsHandler_WithMockServerProvider_ReturnsExpectedStructure() + { + // Arrange + var clientBuilder = new MockMcpClientBuilder() + .AddTool("test-tool-1", "Test Tool 1 Description", "Test response 1") + .AddTool("test-tool-2", "Test Tool 2 Description", "Test response 2"); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools.Count); + Assert.Contains(result.Tools, t => t.Name == "test-tool-1"); + Assert.Contains(result.Tools, t => t.Name == "test-tool-2"); + } + + [Fact] + public async Task ListToolsHandler_WithReadOnlyOption_FiltersProperly() + { + // Arrange + var readOnlyTool = new Tool + { + Name = "readonly-tool", + Description = "Read-only tool", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = new ToolAnnotations { ReadOnlyHint = true } + }; + + var writeTool = new Tool + { + Name = "write-tool", + Description = "Write tool", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = new ToolAnnotations { ReadOnlyHint = false } + }; + + var clientBuilder = new MockMcpClientBuilder() + .AddTool(readOnlyTool, _ => new CallToolResult { Content = [new TextContentBlock { Text = "Read-only result" }], IsError = false }) + .AddTool(writeTool, _ => new CallToolResult { Content = [new TextContentBlock { Text = "Write result" }], IsError = false }); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var readOnlyOptions = new ToolLoaderOptions(ReadOnly: true); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(readOnlyOptions); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // When ReadOnly is enabled, only tools with ReadOnlyHint = true should be returned + Assert.Single(result.Tools); + var returnedTool = result.Tools.First(); + Assert.Equal("readonly-tool", returnedTool.Name); + Assert.True(returnedTool.Annotations?.ReadOnlyHint == true, "Returned tool should have ReadOnlyHint = true"); + + // Verify that the write tool was filtered out + Assert.DoesNotContain(result.Tools, t => t.Name == "write-tool"); + } + + [Fact] + public async Task ListToolsHandler_WithReadOnlyDisabled_ReturnsAllTools() + { + // Arrange + var readOnlyTool = new Tool + { + Name = "readonly-tool", + Description = "Read-only tool", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = new ToolAnnotations { ReadOnlyHint = true } + }; + + var writeTool = new Tool + { + Name = "write-tool", + Description = "Write tool", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = new ToolAnnotations { ReadOnlyHint = false } + }; + + var clientBuilder = new MockMcpClientBuilder() + .AddTool(readOnlyTool, _ => new CallToolResult { Content = [new TextContentBlock { Text = "Read-only result" }], IsError = false }) + .AddTool(writeTool, _ => new CallToolResult { Content = [new TextContentBlock { Text = "Write result" }], IsError = false }); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var defaultOptions = new ToolLoaderOptions(ReadOnly: false); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(defaultOptions); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // When ReadOnly is disabled, all tools should be returned regardless of ReadOnlyHint + Assert.Equal(2, result.Tools.Count); + Assert.Contains(result.Tools, t => t.Name == "readonly-tool"); + Assert.Contains(result.Tools, t => t.Name == "write-tool"); + + // Verify annotations are preserved + var readOnlyToolResult = result.Tools.First(t => t.Name == "readonly-tool"); + var writeToolResult = result.Tools.First(t => t.Name == "write-tool"); + Assert.True(readOnlyToolResult.Annotations?.ReadOnlyHint == true); + Assert.True(writeToolResult.Annotations?.ReadOnlyHint == false); + } + + [Fact] + public async Task CallToolHandler_WithUnknownTool_ReturnsErrorResult() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(); + var request = CreateCallToolRequest("unknown-tool"); + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Verify the error message + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("unknown-tool", textContent.Text); + Assert.Contains("was not found", textContent.Text); + } + + [Fact] + public async Task RegistryToolLoader_WithDifferentOptions_BehavesConsistently() + { + // Arrange - Test with different service options + var defaultOptions = new ToolLoaderOptions(); + var readOnlyOptions = new ToolLoaderOptions(ReadOnly: true); + + // Create empty discovery strategies for both tests + var defaultDiscoveryStrategy = new MockMcpDiscoveryStrategyBuilder().Build(); + var readOnlyDiscoveryStrategy = new MockMcpDiscoveryStrategyBuilder().Build(); + + // Create tool loaders with different options + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger1 = loggerFactory.CreateLogger(); + var logger2 = loggerFactory.CreateLogger(); + + var defaultToolLoader = new RegistryToolLoader(defaultDiscoveryStrategy, + Microsoft.Extensions.Options.Options.Create(defaultOptions), logger1); + var readOnlyToolLoader = new RegistryToolLoader(readOnlyDiscoveryStrategy, + Microsoft.Extensions.Options.Options.Create(readOnlyOptions), logger2); + + var request = CreateListToolsRequest(); + + // Act + var defaultResult = await defaultToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + var readOnlyResult = await readOnlyToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert - Both should return empty but valid results + Assert.NotNull(defaultResult); + Assert.NotNull(defaultResult.Tools); + Assert.Empty(defaultResult.Tools); + + Assert.NotNull(readOnlyResult); + Assert.NotNull(readOnlyResult.Tools); + Assert.Empty(readOnlyResult.Tools); + } + + [Fact] + public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() + { + // Arrange + var clientBuilder = new MockMcpClientBuilder() + .AddTool("microsoft_docs_search", "Search Microsoft documentation", args => + { + var question = args?.GetValueOrDefault("question")?.ToString() ?? ""; + return new CallToolResult + { + Content = new List { new TextContentBlock { Text = "Tool executed successfully" } }, + IsError = false + }; + }); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + var request = CreateCallToolRequest("microsoft_docs_search", + new Dictionary + { + { "question", JsonDocument.Parse("\"how to implement mcp server in azure\"").RootElement } + }); + + // Act - Call CallToolHandler, which should initialize tools first + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - The tool call should succeed + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Verify the content + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Equal("Tool executed successfully", textContent.Text); + } + + [Fact] + public async Task MockMcpClient_WithExtensionMethods_WorksCorrectly() + { + // Arrange + var clientBuilder = new MockMcpClientBuilder() + .AddTool("docs-search", "Azure documentation", args => + { + var query = args?.GetValueOrDefault("query")?.ToString() ?? ""; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Azure documentation: here is some content about {query}" }], + IsError = false + }; + }) + .AddTool("echo", "Echo tool", args => + { + var message = args?.GetValueOrDefault("message")?.ToString() ?? "No message"; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Echo: {message}" }], + IsError = false + }; + }); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + + // Act & Assert - List tools + var listRequest = CreateListToolsRequest(); + var listResult = await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + Assert.NotNull(listResult); + Assert.Equal(2, listResult.Tools.Count); + Assert.Contains(listResult.Tools, t => t.Name == "docs-search"); + Assert.Contains(listResult.Tools, t => t.Name == "echo"); + + // Act & Assert - Call search tool + var searchRequest = CreateCallToolRequest("docs-search", new Dictionary + { + { "query", JsonDocument.Parse("\"MCP implementation\"").RootElement } + }); + + var searchResult = await toolLoader.CallToolHandler(searchRequest, TestContext.Current.CancellationToken); + Assert.NotNull(searchResult); + Assert.False(searchResult.IsError); + + var searchContent = searchResult.Content.OfType().FirstOrDefault(); + Assert.NotNull(searchContent); + Assert.Contains("MCP implementation", searchContent.Text); + Assert.Contains("Azure documentation", searchContent.Text); + + // Act & Assert - Call echo tool + var echoRequest = CreateCallToolRequest("echo", new Dictionary + { + { "message", JsonDocument.Parse("\"Hello MCP!\"").RootElement } + }); + var echoResult = await toolLoader.CallToolHandler(echoRequest, TestContext.Current.CancellationToken); + Assert.NotNull(echoResult); + Assert.False(echoResult.IsError); + var echoContent = echoResult.Content.OfType().FirstOrDefault(); + Assert.NotNull(echoContent); + Assert.Equal("Echo: Hello MCP!", echoContent.Text); + } + + [Fact] + public async Task ListToolsHandler_WithReadOnlyOption_FilterToolsWithNullAnnotations() + { + // Arrange + var readOnlyTool = new Tool + { + Name = "readonly-tool", + Description = "Read-only tool", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = new ToolAnnotations { ReadOnlyHint = true } + }; + + var toolWithoutAnnotations = new Tool + { + Name = "tool-no-annotations", + Description = "Tool without annotations", + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement, + Annotations = null // No annotations + }; + + var clientBuilder = new MockMcpClientBuilder() + .AddTool(readOnlyTool, _ => new CallToolResult { Content = [new TextContentBlock { Text = "Read-only result" }], IsError = false }) + .AddTool(toolWithoutAnnotations, _ => new CallToolResult { Content = [new TextContentBlock { Text = "No annotations result" }], IsError = false }); + + var discoveryStrategy = new MockMcpDiscoveryStrategyBuilder() + .AddServer("test-server", "test-server", "Test Server Description", clientBuilder) + .Build(); + + var readOnlyOptions = new ToolLoaderOptions(ReadOnly: true); + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serviceOptions = Microsoft.Extensions.Options.Options.Create(readOnlyOptions); + + var toolLoader = new RegistryToolLoader(discoveryStrategy, serviceOptions, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + + // When ReadOnly is enabled, only tools with ReadOnlyHint = true should be returned + // Tools with null annotations should be filtered out + Assert.Single(result.Tools); + var returnedTool = result.Tools.First(); + Assert.Equal("readonly-tool", returnedTool.Name); + Assert.True(returnedTool.Annotations?.ReadOnlyHint == true); + + // Verify that the tool without annotations was filtered out + Assert.DoesNotContain(result.Tools, t => t.Name == "tool-no-annotations"); + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeOwnedResourcesOnly() + { + // Arrange + var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); + + // Act + await toolLoader.DisposeAsync(); + + // Assert - Discovery strategy should NOT be disposed (it's owned by DI container) + await mockDiscoveryStrategy.DidNotReceive().DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ShouldClearInternalCollections() + { + // Arrange + var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); + + // Initialize tool loader by calling ListToolsHandler + var request = CreateListToolsRequest(); + await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Act + await toolLoader.DisposeAsync(); + + // Assert - After disposal, calling operations should work but with empty state + // (This tests that collections were cleared) + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + Assert.NotNull(result.Tools); + // Tools might be re-populated from discovery strategy, but internal state was cleared + } + + [Fact] + public async Task DisposeAsync_ShouldBeIdempotent() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(); + + // Act - dispose multiple times + await toolLoader.DisposeAsync(); + await toolLoader.DisposeAsync(); + await toolLoader.DisposeAsync(); + + // Assert - should not throw + // (Idempotency is verified by not throwing exceptions) + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeInitializationSemaphore() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(); + + // Act + await toolLoader.DisposeAsync(); + + // Assert - This tests that the semaphore is disposed + // If the semaphore wasn't disposed properly, subsequent operations might have issues + // but this is mainly for coverage and resource cleanup verification + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs new file mode 100644 index 0000000000..72d306f550 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class ServerToolLoaderTests +{ + private static (ServerToolLoader toolLoader, IMcpDiscoveryStrategy mockDiscoveryStrategy) CreateToolLoader(ToolLoaderOptions? options = null) + { + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var mockDiscoveryStrategy = Substitute.For(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ToolLoaderOptions()); + + var toolLoader = new ServerToolLoader(mockDiscoveryStrategy, toolLoaderOptions, logger); + return (toolLoader, mockDiscoveryStrategy); + } + + private static ModelContextProtocol.Server.RequestContext CreateRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments ?? new Dictionary() + } + }; + } + + [Fact] + public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() + { + // Arrange - use real RegistryDiscoveryStrategy since ServerToolLoader depends on it + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var serviceStartOptions = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + var discoveryLogger = loggerFactory.CreateLogger(); + var discoveryStrategy = new RegistryDiscoveryStrategy(serviceStartOptions, discoveryLogger); + var logger = loggerFactory.CreateLogger(); + + var toolLoader = new ServerToolLoader(discoveryStrategy, toolLoaderOptions, logger); + var request = CreateCallToolRequest("documentation", + new Dictionary + { + { "intent", JsonDocument.Parse("\"search for information about implementing MCP servers\"").RootElement }, + { "command", JsonDocument.Parse("\"microsoft_docs_search\"").RootElement }, + { "parameters", JsonDocument.Parse(""" + { + "question": "how to implement mcp server in azure" + } + """).RootElement } + }); + + // Act - Call CallToolHandler WITHOUT calling ListToolsHandler first + // This should work without requiring ListToolsHandler to be called first + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - The tool call should succeed + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + } + + [Fact] + public async Task ListToolsHandler_WithNoServers_ReturnsEmptyToolList() + { + // Arrange + var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); + var request = CreateRequest(); + + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Empty(result.Tools); + } + + [Fact] + public async Task ListToolsHandler_WithRealRegistryDiscovery_ReturnsExpectedStructure() + { + // Arrange - use real RegistryDiscoveryStrategy + var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var serviceStartOptions = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + var discoveryLogger = loggerFactory.CreateLogger(); + var discoveryStrategy = new RegistryDiscoveryStrategy(serviceStartOptions, discoveryLogger); + var logger = loggerFactory.CreateLogger(); + + var toolLoader = new ServerToolLoader(discoveryStrategy, toolLoaderOptions, logger); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.True(result.Tools.Count >= 0); // Should return at least an empty list + + // Each tool should have proper structure if any exist + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotEmpty(tool.Name); + Assert.NotNull(tool.Description); + Assert.True(tool.InputSchema.ValueKind != JsonValueKind.Undefined, "InputSchema should be defined"); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs new file mode 100644 index 0000000000..8d69dbe4ed --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class SingleProxyToolLoaderTests +{ + private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discoveryStrategy) CreateToolLoader(bool useRealDiscovery = true) + { + var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + + if (useRealDiscovery) + { + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + var commandGroupLogger = serviceProvider.GetRequiredService>(); + var commandGroupDiscoveryStrategy = new CommandGroupDiscoveryStrategy( + CommandFactoryHelpers.CreateCommandFactory(serviceProvider), + options, + commandGroupLogger + ); + var registryLogger = serviceProvider.GetRequiredService>(); + var registryDiscoveryStrategy = new RegistryDiscoveryStrategy(options, registryLogger); + var compositeLogger = serviceProvider.GetRequiredService>(); + var compositeDiscoveryStrategy = new CompositeDiscoveryStrategy([ + commandGroupDiscoveryStrategy, + registryDiscoveryStrategy + ], compositeLogger); + var toolLoader = new SingleProxyToolLoader(compositeDiscoveryStrategy, logger); + return (toolLoader, compositeDiscoveryStrategy); + } + else + { + var mockDiscoveryStrategy = Substitute.For(); + var toolLoader = new SingleProxyToolLoader(mockDiscoveryStrategy, logger); + return (toolLoader, mockDiscoveryStrategy); + } + } + + private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest( + string toolName = "azure", + Dictionary? arguments = null) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments ?? new Dictionary() + } + }; + } + + [Fact] + public async Task ListToolsHandler_ReturnsAzureToolWithExpectedSchema() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var request = CreateListToolsRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var azureTool = result.Tools.FirstOrDefault(t => t.Name == "azure"); + Assert.NotNull(azureTool); + Assert.Contains("real-time, programmatic access to all Azure products", azureTool.Description); + + // Verify the tool has proper structure + Assert.True(azureTool.InputSchema.ValueKind != JsonValueKind.Undefined); + Assert.NotNull(azureTool.Annotations); + } + + [Fact] + public async Task ListToolsHandler_WithMockedDiscovery_ReturnsSingleAzureTool() + { + // Arrange + var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(useRealDiscovery: false); + var request = CreateListToolsRequest(); + + // Setup mock to return empty servers (SingleProxyToolLoader always returns the azure tool) + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) + .Returns(Task.FromResult(Enumerable.Empty())); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Tools); + + var azureTool = result.Tools.First(); + Assert.Equal("azure", azureTool.Name); + Assert.Contains("real-time, programmatic access to all Azure products", azureTool.Description); + } + + [Fact] + public async Task CallToolHandler_WithLearnMode_ReturnsRootToolsList() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var arguments = new Dictionary + { + ["learn"] = JsonDocument.Parse("true").RootElement, + ["intent"] = JsonDocument.Parse("\"List available tools\"").RootElement + }; + var request = CreateCallToolRequest("azure", arguments); + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Null(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Should contain information about available tools + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithToolLearnMode_ThrowsExceptionForUnknownTool() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var arguments = new Dictionary + { + ["learn"] = JsonDocument.Parse("true").RootElement, + ["tool"] = JsonDocument.Parse("\"nonexistent\"").RootElement, // Use a tool that doesn't exist + ["intent"] = JsonDocument.Parse("\"Learn about nonexistent tool\"").RootElement + }; + var request = CreateCallToolRequest("azure", arguments); + + // Act & Assert + // The current implementation throws KeyNotFoundException for unknown tools + await Assert.ThrowsAsync(async () => + await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CallToolHandler_WithIntentOnly_AutoEnablesLearnMode() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var arguments = new Dictionary + { + ["intent"] = JsonDocument.Parse("\"Show me available Azure tools\"").RootElement + // Intent only, should trigger learn mode automatically based on the implementation + }; + var request = CreateCallToolRequest("azure", arguments); + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Null(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Should return learn mode information since intent was provided without tool/command + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + // The actual behavior shows available tools list + Assert.Contains("Here are the available list of tools", textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithMissingToolAndCommand_ReturnsGuidanceMessage() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var arguments = new Dictionary + { + // No learn, tool, or command parameters - should get guidance message + }; + var request = CreateCallToolRequest("azure", arguments); + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Null(result.IsError); // This is guidance, not an error + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.OfType().First(); + Assert.Contains("tool\" and \"command\" parameters are required", textContent.Text); + Assert.Contains("Run again with the \"learn\" argument", textContent.Text); + } + + [Fact] + public async Task CallToolHandler_WithNullParams_ReturnsGuidanceMessage() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = null + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Null(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content.OfType().First(); + Assert.Contains("tool\" and \"command\" parameters are required", textContent.Text); + } + + [Fact] + public async Task SingleProxyToolLoader_CachesRootToolsJson() + { + // Arrange + var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); + var arguments = new Dictionary + { + ["learn"] = JsonDocument.Parse("true").RootElement + }; + var request = CreateCallToolRequest("azure", arguments); + + // Act - Call twice to test caching + var result1 = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + var result2 = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Both calls should succeed and return consistent results + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.Null(result1.IsError); + Assert.Null(result2.IsError); + + // Content should be consistent (testing that caching works) + var content1 = result1.Content.OfType().FirstOrDefault()?.Text; + var content2 = result2.Content.OfType().FirstOrDefault()?.Text; + Assert.NotNull(content1); + Assert.NotNull(content2); + Assert.Equal(content1, content2); + } + + [Fact] + public void SingleProxyToolLoader_Constructor_ThrowsOnNullArguments() + { + // Arrange + var logger = Substitute.For>(); + var discoveryStrategy = Substitute.For(); + + // Act & Assert + Assert.Throws(() => new SingleProxyToolLoader(null!, logger)); + Assert.Throws(() => new SingleProxyToolLoader(discoveryStrategy, null!)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs new file mode 100644 index 0000000000..542f98aa27 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Helpers; + +/// +/// A builder for creating mock instances for testing purposes. +/// Provides a fluent API for registering mock tools with custom handlers. +/// +public sealed class MockMcpClientBuilder +{ + private readonly Dictionary _tools = new(); + + /// + /// Adds a mock tool with a custom Tool object and handler using fluent API. + /// + /// The Tool object containing name, description, and schema. + /// The handler function to execute when the tool is called. + /// The current instance for method chaining. + public MockMcpClientBuilder AddTool(Tool tool, Func?, CallToolResult> handler) + { + var mockTool = new MockTool(tool, handler); + _tools[tool.Name] = mockTool; + return this; + } + + /// + /// Adds a mock tool with a custom handler using fluent API. + /// + /// The name of the tool. + /// The description of the tool. + /// The handler function to execute when the tool is called. + /// The current instance for method chaining. + public MockMcpClientBuilder AddTool(string name, string description, Func?, CallToolResult> handler) + { + var tool = new Tool + { + Name = name, + Description = description, + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement + }; + return AddTool(tool, handler); + } + + /// + /// Adds a mock tool with a custom handler using fluent API. + /// + /// The name of the tool. + /// The description of the tool. + /// The handler function to execute when the tool is called. + /// The current instance for method chaining. + public MockMcpClientBuilder AddTool(string name, string description, Func handler) + { + return AddTool(name, description, _ => handler()); + } + + /// + /// Adds a mock tool with a simple text response. + /// + /// The name of the tool. + /// The description of the tool. + /// The text response to return. + /// The current instance for method chaining. + public MockMcpClientBuilder AddTool(string name, string description, string response) + { + return AddTool(name, description, () => new CallToolResult + { + Content = [new TextContentBlock { Text = response }], + IsError = false + }); + } + + /// + /// Adds a mock tool that returns an error response. + /// + /// The name of the tool. + /// The description of the tool. + /// The error message to return. + /// The current instance for method chaining. + public MockMcpClientBuilder AddErrorTool(string name, string description, string errorMessage) + { + return AddTool(name, description, () => new CallToolResult + { + Content = [new TextContentBlock { Text = errorMessage }], + IsError = true + }); + } + + /// + /// Removes a tool from the mock client. + /// + /// The name of the tool to remove. + /// The current instance for method chaining. + public MockMcpClientBuilder RemoveTool(string name) + { + _tools.Remove(name); + return this; + } + + /// + /// Clears all registered tools. + /// + /// The current instance for method chaining. + public MockMcpClientBuilder ClearTools() + { + _tools.Clear(); + return this; + } + + /// + /// Builds and returns a mock instance configured with the registered tools. + /// + /// A mock instance. + public McpClient Build() + { + var mockClient = Substitute.For(); + + // Setup tools/list response + mockClient.SendRequestAsync( + Arg.Is(req => req.Method == "tools/list"), + TestContext.Current.CancellationToken) + .Returns(callInfo => HandleListToolsRequest(callInfo.Arg())); + + // Setup tools/call response + mockClient.SendRequestAsync( + Arg.Is(req => req.Method == "tools/call"), + TestContext.Current.CancellationToken) + .Returns(callInfo => HandleCallToolRequest(callInfo.Arg())); + + return mockClient; + } + + /// + /// Handles the tools/list request and returns registered tools. + /// + private Task HandleListToolsRequest(JsonRpcRequest request) + { + var tools = _tools.Values.Select(mockTool => mockTool.Tool).ToList(); + + var result = new ListToolsResult { Tools = tools }; + var json = JsonSerializer.SerializeToNode(result, ServerJsonContext.Default.ListToolsResult); + + return Task.FromResult(new JsonRpcResponse + { + Id = request.Id, + Result = json + }); + } + + /// + /// Handles the tools/call request and executes the registered tool. + /// + private Task HandleCallToolRequest(JsonRpcRequest request) + { + try + { + var callParams = JsonSerializer.Deserialize(request.Params!); + + if (callParams?.Name == null || !_tools.TryGetValue(callParams.Name, out var mockTool)) + { + var errorResult = new CallToolResult + { + Content = [new TextContentBlock { Text = $"Tool '{callParams?.Name ?? "null"}' not found" }], + IsError = true + }; + + var errorJson = JsonSerializer.SerializeToNode(errorResult); + return Task.FromResult(new JsonRpcResponse + { + Id = request.Id, + Result = errorJson + }); + } + + // Convert JsonElement arguments to dictionary + var arguments = callParams.Arguments?.ToDictionary( + kvp => kvp.Key, + kvp => ConvertJsonElementToObject(kvp.Value) + ); + + var result = mockTool.Handler(arguments); + var resultJson = JsonSerializer.SerializeToNode(result); + + return Task.FromResult(new JsonRpcResponse + { + Id = request.Id, + Result = resultJson + }); + } + catch (Exception ex) + { + var errorResult = new CallToolResult + { + Content = [new TextContentBlock { Text = $"Error calling tool: {ex.Message}" }], + IsError = true + }; + + var errorJson = JsonSerializer.SerializeToNode(errorResult); + return Task.FromResult(new JsonRpcResponse + { + Id = request.Id, + Result = errorJson + }); + } + } + + /// + /// Converts a JsonElement to an appropriate object type. + /// + private static object? ConvertJsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt32(out var intValue) => intValue, + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element // Keep as JsonElement for complex types + }; + } +} + +/// +/// Internal representation of a mock tool. +/// +internal sealed class MockTool( + Tool tool, + Func?, CallToolResult> handler) +{ + public Tool Tool { get; } = tool; + public Func?, CallToolResult> Handler { get; } = handler; +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs new file mode 100644 index 0000000000..bf173d996e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using ModelContextProtocol.Client; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Helpers; + +/// +/// A builder for creating mock implementations of for testing purposes. +/// Provides a fluent API for adding servers and configuring mock behavior. +/// +public sealed class MockMcpDiscoveryStrategyBuilder +{ + private readonly List _providers = new(); + + /// + /// Initializes a new instance of the MockMcpDiscoveryStrategyBuilder. + /// + public MockMcpDiscoveryStrategyBuilder() + { + } + + /// + /// Adds a new server with the specified client to the mock discovery strategy. + /// + /// The unique identifier for the server. + /// The display name of the server. If null, uses the serverId. + /// The description of the server. If null, uses a default description. + /// The mock client to return for this server. + /// The current instance for method chaining. + public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, string? serverName = null, string? description = null, McpClient? client = null) + { + var mockProvider = Substitute.For(); + var metadata = new McpServerMetadata + { + Id = serverId, + Name = serverName ?? serverId, + Description = description ?? $"Mock server for {serverId}" + }; + + mockProvider.CreateMetadata().Returns(metadata); + + if (client != null) + { + mockProvider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(client)); + } + else + { + // If no client is provided, create a basic substitute + var defaultClient = Substitute.For(); + mockProvider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(defaultClient)); + } + + _providers.Add(mockProvider); + return this; + } + + /// + /// Adds a new server using a MockMcpClientBuilder for the client. + /// + /// The unique identifier for the server. + /// The display name of the server. If null, uses the serverId. + /// The description of the server. If null, uses a default description. + /// The MockMcpClientBuilder to use for creating the client. + /// The current instance for method chaining. + public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, string? serverName, string? description, MockMcpClientBuilder clientBuilder) + { + var client = clientBuilder.Build(); + return AddServer(serverId, serverName, description, client); + } + + /// + /// Adds a new server using a MockMcpClientBuilder for the client. + /// + /// The unique identifier for the server. + /// The MockMcpClientBuilder to use for creating the client. + /// The current instance for method chaining. + public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, MockMcpClientBuilder clientBuilder) + { + return AddServer(serverId, null, null, clientBuilder); + } + + /// + /// Removes a server from the mock discovery strategy. + /// + /// The unique identifier of the server to remove. + /// The current instance for method chaining. + public MockMcpDiscoveryStrategyBuilder RemoveServer(string serverId) + { + var providerToRemove = _providers.FirstOrDefault(p => + p.CreateMetadata().Id.Equals(serverId, StringComparison.OrdinalIgnoreCase)); + + if (providerToRemove != null) + { + _providers.Remove(providerToRemove); + } + + return this; + } + + /// + /// Clears all servers from the mock discovery strategy. + /// + /// The current instance for method chaining. + public MockMcpDiscoveryStrategyBuilder ClearServers() + { + _providers.Clear(); + return this; + } + + + + /// + /// Builds and returns the configured mock discovery strategy. + /// + /// The configured mock discovery strategy. + public IMcpDiscoveryStrategy Build() + { + var mockStrategy = Substitute.For(); + + // Configure DiscoverServersAsync to return the current providers + mockStrategy.DiscoverServersAsync(Arg.Any()).Returns(Task.FromResult>(_providers)); + + // Configure FindServerProviderAsync to find providers by name (case-insensitive) + mockStrategy.FindServerProviderAsync(Arg.Any(), Arg.Any()).Returns(callInfo => + { + var serverName = callInfo.Arg(); + var provider = _providers.FirstOrDefault(p => + p.CreateMetadata().Name.Equals(serverName, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new KeyNotFoundException($"No MCP server found with the name '{serverName}'."); + } + + return Task.FromResult(provider); + }); + + // Configure GetOrCreateClientAsync to return the appropriate client + mockStrategy.GetOrCreateClientAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(callInfo => + { + var serverName = callInfo.Arg(); + var clientOptions = callInfo.ArgAt(1) ?? new McpClientOptions(); + + var provider = _providers.FirstOrDefault(p => + p.CreateMetadata().Name.Equals(serverName, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new KeyNotFoundException($"No MCP server found with the name '{serverName}'."); + } + + // Return the client from the provider + return provider.CreateClientAsync(clientOptions, TestContext.Current.CancellationToken); + }); + + return mockStrategy; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/McpServerToolAttributeTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/McpServerToolAttributeTests.cs new file mode 100644 index 0000000000..09e5674b45 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/McpServerToolAttributeTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using ModelContextProtocol.Server; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class McpServerToolAttributeTests +{ + [Fact] + public void AllExecuteAsyncMethodsWithMcpServerToolAttribute_ShouldHaveValidTitle() + { + // Arrange + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + + var titleValidationErrors = new List(); + + // Act - Get all command types and check their ExecuteAsync methods + foreach (var (commandName, command) in commandFactory.AllCommands) + { + // Get the ExecuteAsync method + var executeAsyncMethod = command.GetType().GetMethod("ExecuteAsync"); + + if (executeAsyncMethod == null) + continue; + + // Check if the method has the McpServerTool attribute + var mcpServerToolAttribute = executeAsyncMethod.GetCustomAttribute(); + + if (mcpServerToolAttribute == null) + continue; + + var commandTypeName = command.GetType().FullName; + + // Check 1: Title property must not be null or whitespace + if (string.IsNullOrWhiteSpace(mcpServerToolAttribute.Title)) + { + titleValidationErrors.Add($"{commandTypeName}: Missing or empty Title property"); + continue; // Skip further validation if title is null/empty + } + + var title = mcpServerToolAttribute.Title.Trim(); + + // Check 2: Title must not be just whitespace after trimming + if (title.Length == 0) + { + titleValidationErrors.Add($"{commandTypeName}: Title contains only whitespace"); + continue; + } + + // Check 3: Title should be reasonably descriptive (at least 5 characters) + if (title.Length < 5) + { + titleValidationErrors.Add($"{commandTypeName}: Title too short ('{title}') - should be at least 5 characters"); + } + + // Check 4: Title should not be generic/placeholder + var genericTitles = new[] { "TODO", "PLACEHOLDER", "FIXME", "TBD", "Command", "Tool" }; + if (genericTitles.Any(generic => title.Equals(generic, StringComparison.OrdinalIgnoreCase))) + { + titleValidationErrors.Add($"{commandTypeName}: Title is generic placeholder ('{title}')"); + } + } + + // Assert + Assert.True(titleValidationErrors.Count == 0, + $"The following commands have ExecuteAsync methods with invalid McpServerTool Title properties:\n" + + string.Join("\n", titleValidationErrors)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs new file mode 100644 index 0000000000..eaf0cfdf03 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server; +using Azure.Mcp.Core.Areas.Server.Models; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Models; + +public class RegistryRootTests +{ + [Fact] + public void RegistryRoot_SerializesToJson_WithEmptyServers() + { + // Arrange + var registryRoot = new RegistryRoot + { + Servers = [] + }; + + // Act + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.Contains("\"servers\"", json); + Assert.Contains("{}", json); + } + + [Fact] + public void RegistryRoot_SerializesToJson_WithSingleServer() + { + // Arrange + var serverInfo = new RegistryServerInfo + { + Url = "https://example.com/mcp", + Description = "Test MCP Server", + Type = "stdio", + Command = "node", + Args = ["server.js", "--port", "3000"], + Env = new Dictionary + { + ["NODE_ENV"] = "production", + ["DEBUG"] = "true" + } + }; + + var registryRoot = new RegistryRoot + { + Servers = new Dictionary + { + ["test-server"] = serverInfo + } + }; + + // Act + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.Contains("\"servers\"", json); + Assert.Contains("\"test-server\"", json); + Assert.Contains("\"url\":\"https://example.com/mcp\"", json); + Assert.Contains("\"description\":\"Test MCP Server\"", json); + Assert.Contains("\"type\":\"stdio\"", json); + Assert.Contains("\"command\":\"node\"", json); + Assert.Contains("\"args\"", json); + Assert.Contains("\"server.js\"", json); + Assert.Contains("\"--port\"", json); + Assert.Contains("\"3000\"", json); + Assert.Contains("\"env\"", json); + Assert.Contains("\"NODE_ENV\":\"production\"", json); + Assert.Contains("\"DEBUG\":\"true\"", json); + } + + [Fact] + public void RegistryRoot_DeserializesFromJson_WithSingleServer() + { + // Arrange + var json = """ + { + "servers": { + "azure-mcp": { + "url": "https://github.com/azure/azure-mcp", + "description": "Azure MCP Server for managing Azure resources", + "type": "stdio", + "command": "dotnet", + "args": ["run", "--project", "AzureMcp.csproj"], + "env": { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id" + } + } + } + } + """; + + // Act + var registryRoot = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.NotNull(registryRoot); + Assert.NotNull(registryRoot.Servers); + Assert.Single(registryRoot.Servers); + Assert.Contains("azure-mcp", registryRoot.Servers); + + var serverInfo = registryRoot.Servers["azure-mcp"]; + Assert.NotNull(serverInfo); + Assert.Equal("https://github.com/azure/azure-mcp", serverInfo.Url); + Assert.Equal("Azure MCP Server for managing Azure resources", serverInfo.Description); + Assert.Equal("stdio", serverInfo.Type); + Assert.Equal("dotnet", serverInfo.Command); + Assert.NotNull(serverInfo.Args); + Assert.Equal(3, serverInfo.Args.Count); + Assert.Contains("run", serverInfo.Args); + Assert.Contains("--project", serverInfo.Args); + Assert.Contains("AzureMcp.csproj", serverInfo.Args); + Assert.NotNull(serverInfo.Env); + Assert.Equal(2, serverInfo.Env.Count); + Assert.Equal("test-client-id", serverInfo.Env["AZURE_CLIENT_ID"]); + Assert.Equal("test-tenant-id", serverInfo.Env["AZURE_TENANT_ID"]); + } + + [Fact] + public void RegistryRoot_DeserializesFromJson_WithMultipleServers() + { + // Arrange + var json = """ + { + "servers": { + "azure-mcp": { + "url": "https://github.com/azure/azure-mcp", + "description": "Azure MCP Server", + "type": "stdio", + "command": "dotnet", + "args": ["run"] + }, + "local-server": { + "description": "Local development server", + "type": "sse", + "url": "http://localhost:3000/sse" + } + } + } + """; + + // Act + var registryRoot = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.NotNull(registryRoot); + Assert.NotNull(registryRoot.Servers); + Assert.Equal(2, registryRoot.Servers.Count); + Assert.Contains("azure-mcp", registryRoot.Servers); + Assert.Contains("local-server", registryRoot.Servers); + + var azureServer = registryRoot.Servers["azure-mcp"]; + Assert.Equal("stdio", azureServer.Type); + Assert.Equal("dotnet", azureServer.Command); + + var localServer = registryRoot.Servers["local-server"]; + Assert.Equal("sse", localServer.Type); + Assert.Equal("http://localhost:3000/sse", localServer.Url); + Assert.Null(localServer.Command); + } + + [Fact] + public void RegistryRoot_SerializesAndDeserializes_RoundTrip() + { + // Arrange + var originalRegistry = new RegistryRoot + { + Servers = new Dictionary + { + ["server1"] = new RegistryServerInfo + { + Url = "https://server1.com", + Description = "First server", + Type = "stdio", + Command = "node", + Args = ["index.js"], + Env = new Dictionary { ["KEY1"] = "value1" } + }, + ["server2"] = new RegistryServerInfo + { + Url = "https://server2.com", + Description = "Second server", + Type = "sse" + } + } + }; + + // Act + var json = JsonSerializer.Serialize(originalRegistry, ServerJsonContext.Default.RegistryRoot); + var deserializedRegistry = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.NotNull(deserializedRegistry); + Assert.NotNull(deserializedRegistry.Servers); + Assert.Equal(2, deserializedRegistry.Servers.Count); + + var server1 = deserializedRegistry.Servers["server1"]; + Assert.Equal("https://server1.com", server1.Url); + Assert.Equal("First server", server1.Description); + Assert.Equal("stdio", server1.Type); + Assert.Equal("node", server1.Command); + Assert.NotNull(server1.Args); + Assert.Single(server1.Args); + Assert.Equal("index.js", server1.Args[0]); + Assert.NotNull(server1.Env); + Assert.Single(server1.Env); + Assert.Equal("value1", server1.Env["KEY1"]); + + var server2 = deserializedRegistry.Servers["server2"]; + Assert.Equal("https://server2.com", server2.Url); + Assert.Equal("Second server", server2.Description); + Assert.Equal("sse", server2.Type); + Assert.Null(server2.Command); + Assert.Null(server2.Args); + Assert.Null(server2.Env); + } + + [Fact] + public void RegistryRoot_HandlesNullServers() + { + // Arrange + var registryRoot = new RegistryRoot { Servers = null }; + + // Act + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); + var deserialized = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); + + // Assert + Assert.NotNull(deserialized); + Assert.Null(deserialized.Servers); + } + + [Fact] + public void RegistryServerInfo_IgnoresNamePropertyInJson() + { + // Arrange + var serverInfo = new RegistryServerInfo + { + Name = "test-name", // This should be ignored in JSON + Url = "https://example.com", + Description = "Test server" + }; + + // Act + var json = JsonSerializer.Serialize(serverInfo, ServerJsonContext.Default.RegistryServerInfo); + + // Assert + Assert.DoesNotContain("\"name\"", json); + Assert.DoesNotContain("test-name", json); + Assert.Contains("\"url\":\"https://example.com\"", json); + Assert.Contains("\"description\":\"Test server\"", json); + } + + [Fact] + public void RegistryServerInfo_NamePropertyNotDeserializedFromJson() + { + // Arrange + var json = """ + { + "name": "should-be-ignored", + "url": "https://example.com", + "description": "Test server" + } + """; + + // Act + var serverInfo = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryServerInfo); + + // Assert + Assert.NotNull(serverInfo); + Assert.Null(serverInfo.Name); // Name should not be deserialized + Assert.Equal("https://example.com", serverInfo.Url); + Assert.Equal("Test server", serverInfo.Description); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/OptionTypeTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/OptionTypeTests.cs new file mode 100644 index 0000000000..75cb52f261 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/OptionTypeTests.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Areas.Server.Commands; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class OptionTypeTests +{ + [Fact] + public void Option_List_String_ValueType_Should_Be_Array() + { + // Arrange + var option = new Option>("--test") + { + Description = "Test option" + }; + + // Act + var jsonType = option.ValueType.ToJsonType(); + + // Assert + Assert.Equal("array", jsonType); + } + + [Fact] + public void GetArrayElementType_Should_Return_String_For_List_String() + { + // Arrange + var listType = typeof(List); + + // Act + var result = TypeToJsonTypeMapper.GetArrayOrCollectionElementType(listType); + + // Assert + Assert.Equal(typeof(string), result); + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Array_Type() + { + // Arrange + var listType = typeof(List); + var description = "A list of strings"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(listType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + Assert.NotNull(result.Items); + Assert.Equal("string", result.Items.Type); + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_String_Type() + { + // Arrange + var stringType = typeof(string); + var description = "A string value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(stringType, description); + + // Assert + Assert.Equal("string", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Integer_Type() + { + // Arrange + var intType = typeof(int); + var description = "An integer value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(intType, description); + + // Assert + Assert.Equal("integer", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Should not have items for non-array types + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Boolean_Type() + { + // Arrange + var boolType = typeof(bool); + var description = "A boolean value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(boolType, description); + + // Assert + Assert.Equal("boolean", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Should not have items for non-array types + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Number_Type() + { + // Arrange + var doubleType = typeof(double); + var description = "A number value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(doubleType, description); + + // Assert + Assert.Equal("number", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Should not have items for non-array types + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Object_Type() + { + // Arrange + var objectType = typeof(object); + var description = "An object value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(objectType, description); + + // Assert + Assert.Equal("object", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Should not have items for non-array types + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Null_Description() + { + // Arrange + var stringType = typeof(string); + string? description = null; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(stringType, description); + + // Assert + Assert.Equal("string", result.Type); + Assert.Equal(string.Empty, result.Description); // Should default to empty string + Assert.Null(result.Items); + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Integer_Array() + { + // Arrange + var intArrayType = typeof(int[]); + var description = "An array of integers"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(intArrayType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + Assert.NotNull(result.Items); + Assert.Equal("integer", result.Items.Type); // Items should be integers + } + + [Fact] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Guid_Type() + { + // Arrange + var guidType = typeof(Guid); + var description = "A GUID value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(guidType, description); + + // Assert + Assert.Equal("string", result.Type); // GUIDs are serialized as strings + Assert.Equal(description, result.Description); + Assert.Null(result.Items); + } + + [Theory] + [InlineData(typeof(char), "string")] + [InlineData(typeof(DateTime), "string")] + [InlineData(typeof(TimeSpan), "string")] + [InlineData(typeof(uint), "integer")] + [InlineData(typeof(long), "integer")] + [InlineData(typeof(float), "number")] + [InlineData(typeof(decimal), "number")] + public void CreateOptionSchema_Should_Return_Correct_Schema_For_Various_Types(Type type, string expectedJsonType) + { + // Arrange + var description = $"A {type.Name} value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(type, description); + + // Assert + Assert.Equal(expectedJsonType, result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Non-array types should not have items + } + + [Theory] + [InlineData(typeof(int?), "integer")] + [InlineData(typeof(bool?), "boolean")] + [InlineData(typeof(DateTime?), "string")] + [InlineData(typeof(double?), "number")] + public void CreateOptionSchema_Should_Handle_Nullable_Types(Type nullableType, string expectedJsonType) + { + // Arrange + var description = $"A nullable {nullableType.Name} value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(nullableType, description); + + // Assert + Assert.Equal(expectedJsonType, result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Nullable types should not have items + } + + [Fact] + public void CreateOptionSchema_Should_Throw_ArgumentNullException_For_Null_Type() + { + // Arrange & Act & Assert + Assert.Throws(() => TypeToJsonTypeMapper.CreatePropertySchema(null!, "description")); + } + + [Fact] + public void GetArrayElementType_Should_Throw_ArgumentNullException_For_Null_Type() + { + // Arrange & Act & Assert + Assert.Throws(() => TypeToJsonTypeMapper.GetArrayOrCollectionElementType(null!)); + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Nested_Array_Types() + { + // Arrange + var nestedArrayType = typeof(List>); + var description = "A nested array"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(nestedArrayType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + Assert.NotNull(result.Items); + Assert.Equal("array", result.Items.Type); + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Dictionary_Types() + { + // Arrange + var dictType = typeof(Dictionary); + var description = "A dictionary"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(dictType, description); + + // Assert + Assert.Equal("object", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); + } + + public enum TestEnum + { + Value1, + Value2 + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Enum_Types() + { + // Arrange + var enumType = typeof(TestEnum); + var description = "An enum value"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(enumType, description); + + // Assert + Assert.Equal("integer", result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Deeply_Nested_Arrays() + { + // Arrange + var deeplyNestedType = typeof(List>>); + var description = "A deeply nested array"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(deeplyNestedType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + + // Check first level of nesting + Assert.NotNull(result.Items); + Assert.Equal("array", result.Items.Type); + + // Check second level of nesting + Assert.NotNull(result.Items.Items); + Assert.Equal("array", result.Items.Items.Type); + + // Check third level (final element type) + Assert.NotNull(result.Items.Items.Items); + Assert.Equal("integer", result.Items.Items.Items.Type); + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Mixed_Array_Types() + { + // Arrange + var mixedArrayType = typeof(List); // List containing arrays + var description = "A list of integer arrays"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(mixedArrayType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + + // Check inner array type + Assert.NotNull(result.Items); + Assert.Equal("array", result.Items.Type); + + // Check final element type + Assert.NotNull(result.Items.Items); + Assert.Equal("integer", result.Items.Items.Type); + } + + [Theory] + [InlineData(typeof(List), "integer")] + [InlineData(typeof(bool?[]), "boolean")] + [InlineData(typeof(IEnumerable), "string")] + public void CreateOptionSchema_Should_Handle_Arrays_With_Nullable_Elements(Type arrayWithNullableType, string expectedElementType) + { + // Arrange + var description = "An array with nullable elements"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(arrayWithNullableType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + Assert.NotNull(result.Items); + Assert.Equal(expectedElementType, result.Items.Type); + } + + [Theory] + [InlineData(typeof(HashSet), "string")] + [InlineData(typeof(Queue), "integer")] + [InlineData(typeof(Stack), "boolean")] + [InlineData(typeof(ISet), "number")] + public void CreateOptionSchema_Should_Handle_Other_Collection_Types(Type collectionType, string expectedElementType) + { + // Arrange + var description = $"A {collectionType.Name} collection"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(collectionType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + Assert.NotNull(result.Items); + Assert.Equal(expectedElementType, result.Items.Type); + } + + [Fact] + public void CreateOptionSchema_Should_Handle_Jagged_Arrays() + { + // Arrange + var jaggedArrayType = typeof(int[][]); + var description = "A jagged array"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(jaggedArrayType, description); + + // Assert + Assert.Equal("array", result.Type); + Assert.Equal(description, result.Description); + + // Check inner array type + Assert.NotNull(result.Items); + Assert.Equal("array", result.Items.Type); + + // Check final element type + Assert.NotNull(result.Items.Items); + Assert.Equal("integer", result.Items.Items.Type); + } + + [Theory] + [InlineData(typeof(Dictionary>))] + [InlineData(typeof(IDictionary))] + [InlineData(typeof(SortedDictionary))] + public void CreateOptionSchema_Should_Handle_Complex_Dictionary_Types(Type dictionaryType) + { + // Arrange + var description = "A complex dictionary"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(dictionaryType, description); + + // Assert + Assert.Equal("object", result.Type); // Dictionaries are objects + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Objects don't have items schema + } + + [Theory] + [InlineData(typeof(TestEnum?), "integer")] + [InlineData(typeof(ConsoleColor?), "integer")] + [InlineData(typeof(DayOfWeek?), "integer")] + public void CreateOptionSchema_Should_Handle_Nullable_Enums(Type nullableEnumType, string expectedType) + { + // Arrange + var description = "A nullable enum"; + + // Act + var result = TypeToJsonTypeMapper.CreatePropertySchema(nullableEnumType, description); + + // Assert + Assert.Equal(expectedType, result.Type); + Assert.Equal(description, result.Description); + Assert.Null(result.Items); // Nullable enums should not have items + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs new file mode 100644 index 0000000000..e03b2d95b7 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs @@ -0,0 +1,786 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Diagnostics; +using System.Net; +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +using TransportTypes = Azure.Mcp.Core.Areas.Server.Options.TransportTypes; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class ServiceStartCommandTests +{ + private readonly ServiceStartCommand _command; + + public ServiceStartCommandTests() + { + _command = new(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Arrange & Act + + // Assert + Assert.Equal("start", _command.GetCommand().Name); + Assert.Equal("Starts Azure MCP Server.", _command.GetCommand().Description!); + } + + [Theory] + [InlineData(null, "", "stdio")] + [InlineData("storage", "storage", "stdio")] + public void ServiceOption_ParsesCorrectly(string? inputService, string expectedService, string expectedTransport) + { + // Arrange + var parseResult = CreateParseResult(inputService); + + // Act + var actualServiceArray = parseResult.GetValue(ServiceOptionDefinitions.Namespace); + var actualService = (actualServiceArray != null && actualServiceArray.Length > 0) ? actualServiceArray[0] : ""; + var actualTransport = parseResult.GetValue(ServiceOptionDefinitions.Transport); + + // Assert + Assert.Equal(expectedService, actualService ?? ""); + Assert.Equal(expectedTransport, actualTransport); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InsecureDisableElicitationOption_ParsesCorrectly(bool expectedValue) + { + // Arrange + var parseResult = CreateParseResultWithInsecureDisableElicitation(expectedValue); + + // Act + var actualValue = parseResult.GetValue(ServiceOptionDefinitions.InsecureDisableElicitation); + + // Assert + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public void InsecureDisableElicitationOption_DefaultsToFalse() + { + // Arrange + var parseResult = CreateParseResult(null); + + // Act + var actualValue = parseResult.GetValue(ServiceOptionDefinitions.InsecureDisableElicitation); + + // Assert + Assert.False(actualValue); + } + + [Fact] + public void AllOptionsRegistered_IncludesInsecureDisableElicitation() + { + // Arrange & Act + var command = _command.GetCommand(); + + // Assert + var hasInsecureDisableElicitationOption = command.Options.Any(o => + o.Name == ServiceOptionDefinitions.InsecureDisableElicitation.Name); + Assert.True(hasInsecureDisableElicitationOption, "InsecureDisableElicitation option should be registered"); + } + + [Fact] + public void AllOptionsRegistered_IncludesTool() + { + // Arrange & Act + var command = _command.GetCommand(); + + // Assert + var hasToolOption = command.Options.Any(o => + o.Name == ServiceOptionDefinitions.Tool.Name); + Assert.True(hasToolOption, "Tool option should be registered"); + } + + [Theory] + [InlineData("azmcp_storage_account_get")] + [InlineData("azmcp_keyvault_secret_get")] + [InlineData(null)] + public void ToolOption_ParsesCorrectly(string? expectedTool) + { + // Arrange + var parseResult = CreateParseResultWithTool(expectedTool != null ? [expectedTool] : null); + + // Act + var actualTools = parseResult.GetValue(ServiceOptionDefinitions.Tool); + + // Assert + if (expectedTool == null) + { + Assert.True(actualTools == null || actualTools.Length == 0); + } + else + { + Assert.NotNull(actualTools); + Assert.Single(actualTools); + Assert.Equal(expectedTool, actualTools[0]); + } + } + + [Fact] + public void ToolOption_ParsesMultipleToolsCorrectly() + { + // Arrange + var expectedTools = new[] { "azmcp_storage_account_get", "azmcp_keyvault_secret_get" }; + var parseResult = CreateParseResultWithTool(expectedTools); + + // Act + var actualTools = parseResult.GetValue(ServiceOptionDefinitions.Tool); + + // Assert + Assert.NotNull(actualTools); + Assert.Equal(expectedTools.Length, actualTools.Length); + Assert.Equal(expectedTools, actualTools); + } + + [Theory] + [InlineData("sse")] + [InlineData("websocket")] + [InlineData("invalid")] + public async Task ExecuteAsync_InvalidTransport_ReturnsValidationError(string invalidTransport) + { + // Arrange + var parseResult = CreateParseResultWithTransport(invalidTransport); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains($"Invalid transport '{invalidTransport}'", response.Message); + Assert.Contains("Valid transports are: stdio, http.", response.Message); + } + + [Theory] + [InlineData("invalid")] + [InlineData("unknown")] + [InlineData("")] + public async Task ExecuteAsync_InvalidMode_ReturnsValidationError(string invalidMode) + { + // Arrange + var parseResult = CreateParseResultWithMode(invalidMode); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains($"Invalid mode '{invalidMode}'", response.Message); + Assert.Contains("Valid modes are: single, namespace, all, consolidated.", response.Message); + } + + [Theory] + [InlineData("single")] + [InlineData("namespace")] + [InlineData("all")] + [InlineData(null)] // null should be valid (uses default) + public async Task ExecuteAsync_ValidMode_DoesNotReturnValidationError(string? validMode) + { + // Arrange + var parseResult = CreateParseResultWithMode(validMode); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + + // Assert - Should not fail validation, though may fail later due to server startup + if (response.Status == HttpStatusCode.BadRequest && response.Message?.Contains("Invalid mode") == true) + { + Assert.Fail($"Mode '{validMode}' should be valid but got validation error: {response.Message}"); + } + } + + [Fact] + public void BindOptions_WithAllOptions_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var parseResult = CreateParseResultWithAllOptions(); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.Equal(TransportTypes.StdIo, options.Transport); + Assert.Equal(new[] { "storage", "keyvault" }, options.Namespace); + Assert.Equal("all", options.Mode); + Assert.True(options.ReadOnly); + Assert.True(options.Debug); + Assert.False(options.DangerouslyDisableHttpIncomingAuth); + Assert.True(options.InsecureDisableElicitation); + } + + [Fact] + public void BindOptions_WithTool_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var expectedTool = "azmcp_group_list"; + var parseResult = CreateParseResultWithTool([expectedTool]); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.NotNull(options.Tool); + Assert.Single(options.Tool); + Assert.Equal(expectedTool, options.Tool[0]); + Assert.Equal(TransportTypes.StdIo, options.Transport); + Assert.Equal("all", options.Mode); + } + + [Fact] + public void BindOptions_WithMultipleToolsAndExplicitMode_OverridesToAllMode() + { + // Arrange - Explicitly set mode to single but also provide multiple tools + var tools = new[] { "azmcp_group_list", "azmcp_subscription_list" }; + var parseResult = CreateParseResultWithToolsAndMode(tools, "single"); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.NotNull(options.Tool); + Assert.Equal(2, options.Tool.Length); + Assert.Equal(tools, options.Tool); + Assert.Equal("all", options.Mode); + } + + [Fact] + public void BindOptions_WithDefaults_ReturnsDefaultValues() + { + // Arrange + var parseResult = CreateParseResultWithMinimalOptions(); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.Equal(TransportTypes.StdIo, options.Transport); // Default transport + Assert.Null(options.Namespace); + Assert.Equal("namespace", options.Mode); // Default mode + Assert.False(options.ReadOnly); // Default readonly + Assert.False(options.Debug); + Assert.False(options.DangerouslyDisableHttpIncomingAuth); + Assert.False(options.InsecureDisableElicitation); + } + + [Fact] + public void Validate_WithValidOptions_ReturnsValidResult() + { + // Arrange + var parseResult = CreateParseResultWithTransport("stdio"); + var commandResult = parseResult.CommandResult; + + // Act + var result = _command.Validate(commandResult, null); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_WithInvalidTransport_ReturnsInvalidResult() + { + // Arrange + var parseResult = CreateParseResultWithTransport("invalid"); + var commandResult = parseResult.CommandResult; + + // Act + var result = _command.Validate(commandResult, null); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Invalid transport 'invalid'", string.Join('\n', result.Errors)); + } + + [Fact] + public void Validate_WithInvalidMode_ReturnsInvalidResult() + { + // Arrange + var parseResult = CreateParseResultWithMode("invalid"); + var commandResult = parseResult.CommandResult; + + // Act + var result = _command.Validate(commandResult, null); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Invalid mode 'invalid'", string.Join('\n', result.Errors)); + } + + [Fact] + public void Validate_WithNamespaceAndTool_ReturnsInvalidResult() + { + // Arrange + var parseResult = CreateParseResultWithNamespaceAndTool(); + var commandResult = parseResult.CommandResult; + + // Act + var result = _command.Validate(commandResult, null); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("--namespace and --tool options cannot be used together", string.Join('\n', result.Errors)); + } + + [Fact] + public async Task ExecuteAsync_WithNamespaceAndTool_ReturnsValidationError() + { + // Arrange + var parseResult = CreateParseResultWithNamespaceAndTool(); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--namespace and --tool options cannot be used together", response.Message); + } + + [Fact] + public void GetErrorMessage_WithTransportArgumentException_ReturnsCustomMessage() + { + // Arrange + var exception = new ArgumentException("Invalid transport 'sse'. Valid transports are: stdio."); + + // Act + var message = GetErrorMessage(exception); + + // Assert + Assert.Contains("Invalid transport option specified", message); + Assert.Contains("Use --transport stdio", message); + } + + [Fact] + public void GetErrorMessage_WithModeArgumentException_ReturnsCustomMessage() + { + // Arrange + var exception = new ArgumentException("Invalid mode 'invalid'. Valid modes are: single, namespace, all."); + + // Act + var message = GetErrorMessage(exception); + + // Assert + Assert.Contains("Invalid mode option specified", message); + Assert.Contains("Use --mode single, namespace, or all", message); + } + + [Fact] + public void GetErrorMessage_WithDangerouslyDisableHttpIncomingAuthException_ReturnsCustomMessage() + { + // Arrange + var exception = new InvalidOperationException("Using --dangerously-disable-http-incoming-auth requires..."); + + // Act + var message = GetErrorMessage(exception); + + // Assert + Assert.Contains("Configuration error to disable incoming HTTP authentication", message); + Assert.Contains("proper authentication is configured", message); + } + + [Fact] + public void GetErrorMessage_WithNamespaceAndToolException_ReturnsCustomMessage() + { + // Arrange + var exception = new ArgumentException("--namespace and --tool options cannot be used together"); + + // Act + var message = GetErrorMessage(exception); + + // Assert + Assert.Contains("Configuration error", message); + Assert.Contains("mutually exclusive", message); + } + + [Fact] + public void GetStatusCode_WithArgumentException_Returns400() + { + // Arrange + var exception = new ArgumentException("Invalid argument"); + + // Act + var statusCode = GetStatusCode(exception); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, statusCode); + } + + [Fact] + public void GetStatusCode_WithInvalidOperationException_Returns422() + { + // Arrange + var exception = new InvalidOperationException("Invalid operation"); + + // Act + var statusCode = GetStatusCode(exception); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, statusCode); + } + + [Fact] + public void GetStatusCode_WithGenericException_Returns500() + { + // Arrange + var exception = new Exception("Generic error"); + + // Act + var statusCode = GetStatusCode(exception); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, statusCode); + } + + [Fact] + public async Task ExecuteAsync_ValidTransport_DoesNotThrow() + { + // Arrange + var parseResult = CreateParseResultWithTransport("stdio"); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act & Assert - Check that ArgumentException is not thrown for valid transport + try + { + await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + } + catch (ArgumentException ex) when (ex.Message.Contains("transport")) + { + Assert.Fail($"ArgumentException should not be thrown for valid transport: {ex.Message}"); + } + catch + { + // Other exceptions are expected since the server can't actually start in a unit test + // We only care that ArgumentException about transport is not thrown + } + } + + [Fact] + public async Task ExecuteAsync_OmittedTransport_UsesDefaultAndDoesNotThrow() + { + // Arrange + var parseResult = CreateParseResultWithoutTransport(); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act & Assert - Check that ArgumentException is not thrown when transport is omitted + try + { + await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + } + catch (ArgumentException ex) when (ex.Message.Contains("transport")) + { + Assert.Fail($"ArgumentException should not be thrown when transport is omitted (should use default): {ex.Message}"); + } + catch + { + // Other exceptions are expected since the server can't actually start in a unit test + // We only care that ArgumentException about transport is not thrown + } + } + + + [Fact] + public void InitializedHandler_SetsStartupInformation() + { + // Arrange + var serviceStartOptions = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "test-mode", + Tool = ["test-tool1", "test-tool2"], + ReadOnly = false, + Debug = true, + Namespace = ["storage", "keyvault"], + InsecureDisableElicitation = false, + DangerouslyDisableHttpIncomingAuth = true, + }; + var activity = new Activity("test-activity"); + var mockTelemetry = Substitute.For(); + mockTelemetry.StartActivity(Arg.Any()).Returns(activity); + + + // Act + ServiceStartCommand.LogStartTelemetry(mockTelemetry, serviceStartOptions); + + // Assert + mockTelemetry.Received(1).StartActivity(ActivityName.ServerStarted); + + var dangerouslyDisableHttpIncomingAuth = GetAndAssertTagKeyValue(activity, TagName.DangerouslyDisableHttpIncomingAuth); + Assert.Equal(serviceStartOptions.DangerouslyDisableHttpIncomingAuth, dangerouslyDisableHttpIncomingAuth); + + var insecureDisableElicitation = GetAndAssertTagKeyValue(activity, TagName.InsecureDisableElicitation); + Assert.Equal(serviceStartOptions.InsecureDisableElicitation, insecureDisableElicitation); + + var transport = GetAndAssertTagKeyValue(activity, TagName.Transport); + Assert.Equal(serviceStartOptions.Transport, transport); + + var mode = GetAndAssertTagKeyValue(activity, TagName.ServerMode); + Assert.Equal(serviceStartOptions.Mode, mode); + + var tool = GetAndAssertTagKeyValue(activity, TagName.Tool); + Assert.Equal(string.Join(",", serviceStartOptions.Tool), tool); + + var readOnly = GetAndAssertTagKeyValue(activity, TagName.IsReadOnly); + Assert.Equal(serviceStartOptions.ReadOnly, readOnly); + + var debug = GetAndAssertTagKeyValue(activity, TagName.IsDebug); + Assert.Equal(serviceStartOptions.Debug, debug); + + var namespaces = GetAndAssertTagKeyValue(activity, TagName.Namespace); + Assert.Equal(string.Join(",", serviceStartOptions.Namespace), namespaces); + } + + [Fact] + public void InitializedHandler_SetsCorrectInformationWhenNull() + { + // Arrange + // Tool, Mode, and Namespace are null + var serviceStartOptions = new ServiceStartOptions + { + Transport = Core.Areas.Server.Options.TransportTypes.StdIo, + Mode = null, + ReadOnly = true, + Debug = false, + InsecureDisableElicitation = true, + DangerouslyDisableHttpIncomingAuth = false, + }; + var activity = new Activity("test-activity"); + var mockTelemetry = Substitute.For(); + mockTelemetry.StartActivity(Arg.Any()).Returns(activity); + + + // Act + ServiceStartCommand.LogStartTelemetry(mockTelemetry, serviceStartOptions); + + + + // Assert + mockTelemetry.Received(1).StartActivity(ActivityName.ServerStarted); + + var dangerouslyDisableHttpIncomingAuth = GetAndAssertTagKeyValue(activity, TagName.DangerouslyDisableHttpIncomingAuth); + Assert.Equal(serviceStartOptions.DangerouslyDisableHttpIncomingAuth, dangerouslyDisableHttpIncomingAuth); + + var insecureDisableElicitation = GetAndAssertTagKeyValue(activity, TagName.InsecureDisableElicitation); + Assert.Equal(serviceStartOptions.InsecureDisableElicitation, insecureDisableElicitation); + + var transport = GetAndAssertTagKeyValue(activity, TagName.Transport); + Assert.Equal(serviceStartOptions.Transport, transport); + + Assert.DoesNotContain(TagName.ServerMode, activity.TagObjects.Select(x => x.Key)); + + Assert.DoesNotContain(TagName.Tool, activity.TagObjects.Select(x => x.Key)); + + var readOnly = GetAndAssertTagKeyValue(activity, TagName.IsReadOnly); + Assert.Equal(serviceStartOptions.ReadOnly, readOnly); + + var debug = GetAndAssertTagKeyValue(activity, TagName.IsDebug); + Assert.Equal(serviceStartOptions.Debug, debug); + + Assert.DoesNotContain(TagName.Namespace, activity.TagObjects.Select(x => x.Key)); + } + + private static ParseResult CreateParseResult(string? serviceValue) + { + var root = new RootCommand + { + ServiceOptionDefinitions.Namespace, + ServiceOptionDefinitions.Transport + }; + var args = new List(); + if (!string.IsNullOrEmpty(serviceValue)) + { + args.Add("--namespace"); + args.Add(serviceValue); + } + // Add required transport default for test + args.Add("--transport"); + args.Add("stdio"); + + return root.Parse([.. args]); + } + + private ParseResult CreateParseResultWithInsecureDisableElicitation(bool insecureDisableElicitation) + { + var args = new List + { + "--transport", + "stdio" + }; + + if (insecureDisableElicitation) + { + args.Add("--insecure-disable-elicitation"); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithTransport(string transport) + { + var args = new List + { + "--transport", + transport, + "--mode", + "all", + "--read-only" + }; + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithoutTransport() + { + var args = new List + { + "--mode", + "all", + "--read-only" + }; + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithMode(string? mode) + { + var args = new List + { + "--transport", + "stdio" + }; + + if (mode is not null) + { + args.Add("--mode"); + args.Add(mode); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithAllOptions() + { + var args = new List + { + "--transport", "stdio", + "--namespace", "storage", + "--namespace", "keyvault", + "--mode", "all", + "--read-only", + "--debug", + "--insecure-disable-elicitation" + }; + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithTool(string[]? tools) + { + var args = new List + { + "--transport", "stdio" + }; + + if (tools is not null) + { + foreach (var tool in tools) + { + args.Add("--tool"); + args.Add(tool); + } + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithMinimalOptions() + { + return _command.GetCommand().Parse([]); + } + + private ParseResult CreateParseResultWithToolsAndMode(string[] tools, string mode) + { + var args = new List + { + "--transport", "stdio", + "--mode", mode + }; + + foreach (var tool in tools) + { + args.Add("--tool"); + args.Add(tool); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithNamespaceAndTool() + { + var args = new List + { + "--transport", "stdio", + "--namespace", "storage", + "--tool", "azmcp_storage_account_get" + }; + + return _command.GetCommand().Parse([.. args]); + } + + private ServiceStartOptions GetBoundOptions(ParseResult parseResult) + { + // Use reflection to access the protected BindOptions method + var method = typeof(ServiceStartCommand).GetMethod("BindOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (ServiceStartOptions)method!.Invoke(_command, [parseResult])!; + } + + private string GetErrorMessage(Exception exception) + { + // Use reflection to access the protected GetErrorMessage method + var method = typeof(ServiceStartCommand).GetMethod("GetErrorMessage", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (string)method!.Invoke(_command, [exception])!; + } + + private HttpStatusCode GetStatusCode(Exception exception) + { + // Use reflection to access the protected GetStatusCode method + var method = typeof(ServiceStartCommand).GetMethod("GetStatusCode", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (HttpStatusCode)method!.Invoke(_command, [exception])!; + } + + private static object GetAndAssertTagKeyValue(Activity activity, string tagName) + { + var matching = activity.TagObjects.SingleOrDefault(x => string.Equals(x.Key, tagName, StringComparison.OrdinalIgnoreCase)); + + Assert.False(matching.Equals(default(KeyValuePair)), $"Tag '{tagName}' was not found in activity tags."); + Assert.NotNull(matching.Value); + + return matching.Value; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/TypeToJsonTypeMapperTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/TypeToJsonTypeMapperTests.cs new file mode 100644 index 0000000000..beba85d4a0 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/TypeToJsonTypeMapperTests.cs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using Azure.Mcp.Core.Areas.Server.Commands; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server; + +public class TypeToJsonTypeMapperTests +{ + [Fact] + public void ToJsonType_WithNullType_ReturnsNull() + { + // Arrange + Type? nullType = null; + + // Act + var result = nullType.ToJsonType(); + + // Assert + Assert.Equal("null", result); + } + + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(char), "string")] + [InlineData(typeof(Guid), "string")] + [InlineData(typeof(DateTime), "string")] + [InlineData(typeof(DateTimeOffset), "string")] + [InlineData(typeof(TimeSpan), "string")] + [InlineData(typeof(Uri), "string")] + public void ToJsonType_WithStringTypes_ReturnsString(Type type, string expected) + { + // Act + var result = type.ToJsonType(); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(typeof(int), "integer")] + [InlineData(typeof(uint), "integer")] + [InlineData(typeof(long), "integer")] + [InlineData(typeof(ulong), "integer")] + [InlineData(typeof(short), "integer")] + [InlineData(typeof(ushort), "integer")] + [InlineData(typeof(byte), "integer")] + [InlineData(typeof(sbyte), "integer")] + public void ToJsonType_WithIntegerTypes_ReturnsInteger(Type type, string expected) + { + // Act + var result = type.ToJsonType(); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(typeof(float), "number")] + [InlineData(typeof(double), "number")] + [InlineData(typeof(decimal), "number")] + public void ToJsonType_WithNumberTypes_ReturnsNumber(Type type, string expected) + { + // Act + var result = type.ToJsonType(); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void ToJsonType_WithBooleanType_ReturnsBoolean() + { + // Act + var result = typeof(bool).ToJsonType(); + + // Assert + Assert.Equal("boolean", result); + } + + [Fact] + public void ToJsonType_WithArrayType_ReturnsArray() + { + // Act + var result = typeof(Array).ToJsonType(); + + // Assert + Assert.Equal("array", result); + + var someArray = new[] { 1, 2, 3 }.GetType(); + + Assert.Equal("array", someArray.ToJsonType()); + } + + [Fact] + public void ToJsonType_WithArrayType_ReturnsArray2() + { + // Act + var someArray = new[] { 1, 2, 3 }.GetType(); + + // Assert + Assert.Equal("array", someArray.ToJsonType()); + } + + + [Fact] + public void ToJsonType_WithObjectType_ReturnsObject() + { + // Act + var result = typeof(object).ToJsonType(); + + // Assert + Assert.Equal("object", result); + } + + [Theory] + [InlineData(typeof(int[]))] + [InlineData(typeof(string[]))] + [InlineData(typeof(List))] + [InlineData(typeof(IList))] + [InlineData(typeof(ICollection))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(ArrayList))] + public void ToJsonType_WithCollectionTypes_ReturnsArray(Type type) + { + // Act + var result = type.ToJsonType(); + + // Assert + Assert.Equal("array", result); + } + + [Fact] + public void ToJsonType_WithStringType_ReturnsString_NotArray() + { + // Note: string implements IEnumerable but should return "string", not "array" + // Act + var result = typeof(string).ToJsonType(); + + // Assert + Assert.Equal("string", result); + } + + public enum TestEnum + { + Value1, + Value2 + } + + [Fact] + public void ToJsonType_WithEnumType_ReturnsString() + { + // Act + var result = typeof(TestEnum).ToJsonType(); + + // Assert + Assert.Equal("integer", result); + } + + [Theory] + [InlineData(typeof(ConsoleColor))] + [InlineData(typeof(DayOfWeek))] + [InlineData(typeof(FileAttributes))] + public void ToJsonType_WithBuiltInEnumTypes_ReturnsString(Type enumType) + { + // Act + var result = enumType.ToJsonType(); + + // Assert + Assert.Equal("integer", result); + } + + public class CustomClass + { + public string Name { get; set; } = string.Empty; + } + + public struct CustomStruct + { + public int Value { get; set; } + } + + public interface ICustomInterface + { + void DoSomething(); + } + + [Theory] + [InlineData(typeof(CustomClass))] + [InlineData(typeof(CustomStruct))] + [InlineData(typeof(ICustomInterface))] + [InlineData(typeof(Exception))] + [InlineData(typeof(Stream))] + public void ToJsonType_WithCustomTypes_ReturnsObject(Type type) + { + // Act + var result = type.ToJsonType(); + + // Assert + Assert.Equal("object", result); + } + + [Theory] + [InlineData(typeof(int?), "integer")] + [InlineData(typeof(bool?), "boolean")] + [InlineData(typeof(DateTime?), "string")] + [InlineData(typeof(double?), "number")] + [InlineData(typeof(char?), "string")] + [InlineData(typeof(Guid?), "string")] + [InlineData(typeof(TestEnum?), "integer")] + public void ToJsonType_WithNullableTypes_ReturnsUnderlyingType(Type nullableType, string expectedType) + { + // Nullable types should return their underlying type, not "object" + // Act + var result = nullableType.ToJsonType(); + + // Assert + Assert.Equal(expectedType, result); + } + + [Fact] + public void ToJsonType_WithGenericType_ReturnsObject() + { + // Act + var result = typeof(Dictionary).ToJsonType(); + + // Assert + Assert.Equal("object", result); + } + + [Fact] + public void ToJsonType_WithNestedGenericType_ReturnsObject() + { + // Act + var result = typeof(Dictionary>).ToJsonType(); + + // Assert + Assert.Equal("object", result); + } + + [Fact] + public void ToJsonType_WithMultidimensionalArray_ReturnsArray() + { + // Act + var result = typeof(int[,]).ToJsonType(); + + // Assert + Assert.Equal("array", result); + } + + [Fact] + public void ToJsonType_WithJaggedArray_ReturnsArray() + { + // Act + var result = typeof(int[][]).ToJsonType(); + + // Assert + Assert.Equal("array", result); + } + + [Theory] + [InlineData(typeof(List))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(string?[]))] + public void ToJsonType_WithNullableElementArrays_ReturnsArray(Type arrayWithNullableElements) + { + // Act + var result = arrayWithNullableElements.ToJsonType(); + + // Assert + Assert.Equal("array", result); + } + + [Theory] + [InlineData(typeof(List>))] + [InlineData(typeof(IEnumerable>))] + [InlineData(typeof(int[][]))] + [InlineData(typeof(List))] + public void ToJsonType_WithNestedArrayTypes_ReturnsArray(Type nestedArrayType) + { + // Act + var result = nestedArrayType.ToJsonType(); + + // Assert + Assert.Equal("array", result); + } + + [Theory] + [InlineData(typeof(Dictionary>))] + [InlineData(typeof(IDictionary))] + [InlineData(typeof(SortedDictionary))] + public void ToJsonType_WithDictionaryTypes_ReturnsObject(Type dictionaryType) + { + // Act + var result = dictionaryType.ToJsonType(); + + // Assert + Assert.Equal("object", result); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs new file mode 100644 index 0000000000..bfcf5dd3e4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.Storage.Commands.Account; +using Azure.Mcp.Tools.Storage.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Subscription; + +public class SubscriptionCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IStorageService _storageService; + private readonly ILogger _logger; + private readonly AccountGetCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public SubscriptionCommandTests() + { + _storageService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_storageService); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Validate_WithEnvironmentVariableOnly_PassesValidation() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + + // Act + var parseResult = _commandDefinition.Parse([]); + + // Assert + Assert.Empty(parseResult.Errors); + } + + [Fact] + public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorrectSubscription() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + + var expectedAccounts = new List + { + new("account1", null, null, null, null, null, null, null, null, null), + new("account2", null, null, null, null, null, null, null, null, null) + }; + + _storageService.GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + Arg.Is("env-subs"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expectedAccounts)); + + var parseResult = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + + // Verify the service was called with the environment variable subscription + _ = _storageService.Received(1).GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "env-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithBothOptionAndEnvironmentVariable_PrefersOption() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + + var expectedAccounts = new List + { + new("account1", null, null, null, null, null, null, null, null, null), + new("account2", null, null, null, null, null, null, null, null, null) + }; + + _storageService.GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + Arg.Is("option-subs"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expectedAccounts)); + + var parseResult = _commandDefinition.Parse(["--subscription", "option-subs"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + + // Verify the service was called with the option subscription, not the environment variable + _ = _storageService.Received(1).GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "option-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); + _ = _storageService.DidNotReceive().GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "env-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs new file mode 100644 index 0000000000..dc4f537c44 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Subscription.Commands; +using Azure.Mcp.Core.Models; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Subscription; + +public class SubscriptionListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly McpServer _mcpServer; + private readonly ILogger _logger; + private readonly ISubscriptionService _subscriptionService; + private readonly SubscriptionListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public SubscriptionListCommandTests() + { + _mcpServer = Substitute.For(); + _subscriptionService = Substitute.For(); + _logger = Substitute.For>(); + var collection = new ServiceCollection() + .AddSingleton(_mcpServer) + .AddSingleton(_subscriptionService); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() + { + // Arrange + var expectedSubscriptions = new List + { + SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Subscription 1"), + SubscriptionTestHelpers.CreateSubscriptionData("sub2", "Subscription 2") + }; + + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedSubscriptions); + + var args = _commandDefinition.Parse(""); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(result.Results)); + var subscriptionsArray = jsonDoc.RootElement.GetProperty("subscriptions"); + + Assert.Equal(2, subscriptionsArray.GetArrayLength()); + + var first = subscriptionsArray[0]; + var second = subscriptionsArray[1]; + + Assert.Equal("sub1", first.GetProperty("subscriptionId").GetString()); + Assert.Equal("Subscription 1", first.GetProperty("displayName").GetString()); + Assert.Equal("sub2", second.GetProperty("subscriptionId").GetString()); + Assert.Equal("Subscription 2", second.GetProperty("displayName").GetString()); + + await _subscriptionService.Received(1).GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithTenantId_PassesTenantToService() + { + // Arrange + var tenantId = "test-tenant-id"; + var args = _commandDefinition.Parse($"--tenant {tenantId}"); + + _subscriptionService + .GetSubscriptions(Arg.Is(x => x == tenantId), Arg.Any(), Arg.Any()) + .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + await _subscriptionService.Received(1).GetSubscriptions( + Arg.Is(x => x == tenantId), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_EmptySubscriptionList_ReturnsNotNullResults() + { + // Arrange + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + var args = _commandDefinition.Parse(""); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorInResponse() + { + // Arrange + var expectedError = "Test error message"; + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException>(new Exception(expectedError))); + + var args = _commandDefinition.Parse(""); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.InternalServerError, result.Status); + Assert.Contains(expectedError, result.Message); + } + + [Fact] + public async Task ExecuteAsync_WithAuthMethod_PassesAuthMethodToCommand() + { + // Arrange + var authMethod = AuthMethod.Credential.ToString().ToLowerInvariant(); + var args = _commandDefinition.Parse($"--auth-method {authMethod}"); + + _subscriptionService + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + await _subscriptionService.Received(1).GetSubscriptions( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionTestHelpers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionTestHelpers.cs new file mode 100644 index 0000000000..ad3e57cec4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionTestHelpers.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; + +namespace Azure.Mcp.Core.UnitTests.Areas.Subscription; + +/// +/// Helper methods for creating test subscription data using the Azure SDK model factories. +/// This follows the recommended pattern from: +/// https://learn.microsoft.com/en-us/dotnet/azure/sdk/unit-testing-mocking +/// +public static class SubscriptionTestHelpers +{ + public static SubscriptionData CreateSubscriptionData(string subscriptionId, string displayName) + { + // Convert string ID to valid subscription ResourceIdentifier + var subGuid = Guid.NewGuid(); // Use random GUID for tests + var subPath = $"/subscriptions/{subGuid}"; + var resourceId = new ResourceIdentifier(subPath); + + // Create subscription policies using model factory + var policies = ResourceManagerModelFactory.SubscriptionPolicies( + locationPlacementId: "Public_2014-09-01", + quotaId: "PayAsYouGo_2014-09-01", + spendingLimit: SpendingLimit.Off); + + // Create subscription data using the official model factory + return ResourceManagerModelFactory.SubscriptionData( + resourceId, + subscriptionId, + displayName, + subGuid, + SubscriptionState.Enabled, + policies, + authorizationSource: "RoleBased", + managedByTenants: [], + tags: new Dictionary()); + } + + /// + /// Creates a subscription with minimal test data - useful when you only need ID and name + /// + public static SubscriptionData CreateMinimalSubscriptionData(string id, string name) => + CreateSubscriptionData(id, name); + + /// + /// Creates a list of test subscriptions with sequential IDs + /// + public static List CreateTestSubscriptions(int count) + { + var subs = new List(); + for (int i = 1; i <= count; i++) + { + subs.Add(CreateMinimalSubscriptionData( + $"sub-{i}", + $"Test Subscription {i}")); + } + return subs; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs new file mode 100644 index 0000000000..cf974cd9c8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs @@ -0,0 +1,868 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Areas.Tools.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Core.UnitTests.Areas.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Tools.UnitTests; + +public class ToolsListCommandTests +{ + private const int MinimumExpectedCommands = 3; + + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly CommandContext _context; + private readonly ToolsListCommand _command; + private readonly Command _commandDefinition; + + public ToolsListCommandTests() + { + var collection = new ServiceCollection(); + collection.AddLogging(); + + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + collection.AddSingleton(commandFactory); + + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _logger = Substitute.For>(); + _command = new(_logger); + _commandDefinition = _command.GetCommand(); + } + + /// + /// Helper method to deserialize response results to CommandInfo list + /// + private static List DeserializeResults(object results) + { + var json = JsonSerializer.Serialize(results); + return JsonSerializer.Deserialize>(json) ?? new List(); + } + + /// + /// Helper method to deserialize response results to ToolNamesResult + /// + private static ToolsListCommand.ToolNamesResult DeserializeToolNamesResult(object results) + { + var json = JsonSerializer.Serialize(results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Deserialize(json, options) ?? new ToolsListCommand.ToolNamesResult(new List()); + } + + /// + /// Verifies that the command returns a valid list of CommandInfo objects + /// when executed with a properly configured context. + /// + + [Fact] + public async Task ExecuteAsync_WithValidContext_ReturnsCommandInfoList() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + + Assert.NotNull(result); + Assert.NotEmpty(result); + + foreach (var command in result) + { + Assert.False(string.IsNullOrWhiteSpace(command.Name), "Command name should not be empty"); + Assert.False(string.IsNullOrWhiteSpace(command.Description), "Command description should not be empty"); + Assert.False(string.IsNullOrWhiteSpace(command.Command), "Command path should not be empty"); + + Assert.False(command.Command.StartsWith("azmcp ")); + + if (command.Options != null && command.Options.Count > 0) + { + foreach (var option in command.Options) + { + Assert.False(string.IsNullOrWhiteSpace(option.Name), "Option name should not be empty"); + Assert.False(string.IsNullOrWhiteSpace(option.Description), "Option description should not be empty"); + } + } + } + } + + /// + /// Verifies that JSON serialization and deserialization works correctly + /// and preserves data integrity during round-trip operations. + /// + [Fact] + public async Task ExecuteAsync_JsonSerializationStressTest_HandlesLargeResults() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + + // Verify JSON round-trip preserves all data + var serializedJson = JsonSerializer.Serialize(result); + Assert.Equal(json, serializedJson); + } + + /// + /// Verifies that the command properly filters out hidden commands + /// and only returns visible commands in the results. + /// + [Fact] + public async Task ExecuteAsync_WithValidContext_FiltersHiddenCommands() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + + Assert.NotNull(result); + + Assert.DoesNotContain(result, cmd => cmd.Name == "list" && cmd.Command.Contains("tool")); + + Assert.Contains(result, cmd => !string.IsNullOrEmpty(cmd.Name)); + } + + /// + /// Verifies that commands include their options with proper validation + /// and that option properties are correctly populated. + /// + [Fact] + public async Task ExecuteAsync_WithValidContext_IncludesOptionsForCommands() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + + Assert.NotNull(result); + + var commandWithOptions = result.FirstOrDefault(cmd => cmd.Options?.Count > 0); + Assert.NotNull(commandWithOptions); + Assert.NotNull(commandWithOptions.Options); + Assert.NotEmpty(commandWithOptions.Options); + + var option = commandWithOptions.Options.First(); + Assert.NotNull(option.Name); + Assert.NotNull(option.Description); + } + + /// + /// Verifies that the command handles null service provider gracefully + /// and returns appropriate error response. + /// + [Fact] + public async Task ExecuteAsync_WithNullServiceProvider_HandlesGracefully() + { + // Arrange + var faultyContext = new CommandContext(null!); + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(faultyContext, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("cannot be null", response.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that the command handles corrupted command factory gracefully + /// and returns appropriate error response with error details. + /// + [Fact] + public async Task ExecuteAsync_WithCorruptedCommandFactory_HandlesGracefully() + { + // Arrange + var faultyServiceProvider = Substitute.For(); + faultyServiceProvider.GetService(typeof(CommandFactory)) + .Returns(x => throw new InvalidOperationException("Corrupted command factory")); + + var faultyContext = new CommandContext(faultyServiceProvider); + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(faultyContext, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.Status); + Assert.Contains("Corrupted command factory", response.Message); + } + + /// + /// Verifies that the command returns specific known commands from different areas + /// and validates the structure and content of returned commands. + /// + [Fact] + public async Task ExecuteAsync_ReturnsSpecificKnownCommands() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + + Assert.NotNull(result); + Assert.NotEmpty(result); + + Assert.True(result.Count >= MinimumExpectedCommands, $"Expected at least {MinimumExpectedCommands} commands, got {result.Count}"); + + var allCommands = result.Select(cmd => cmd.Command).ToList(); + + // Should have subscription commands (commands include 'azmcp' prefix) + var subscriptionCommands = result.Where(cmd => cmd.Command.Contains("subscription")).ToList(); + Assert.True(subscriptionCommands.Count > 0, $"Expected subscription commands. All commands: {string.Join(", ", allCommands)}"); + + // Should have keyvault commands + var keyVaultCommands = result.Where(cmd => cmd.Command.Contains("keyvault")).ToList(); + Assert.True(keyVaultCommands.Count > 0, $"Expected keyvault commands. All commands: {string.Join(", ", allCommands)}"); + + // Should have storage commands + var storageCommands = result.Where(cmd => cmd.Command.Contains("storage")).ToList(); + Assert.True(storageCommands.Count > 0, $"Expected storage commands. All commands: {string.Join(", ", allCommands)}"); + + // Should have appconfig commands + var appConfigCommands = result.Where(cmd => cmd.Command.Contains("appconfig")).ToList(); + Assert.True(appConfigCommands.Count > 0, $"Expected appconfig commands. All commands: {string.Join(", ", allCommands)}"); + + // Verify specific known commands exist + Assert.Contains(result, cmd => cmd.Command == "subscription list"); + Assert.Contains(result, cmd => cmd.Command == "keyvault key list"); + Assert.Contains(result, cmd => cmd.Command == "storage account get"); + Assert.Contains(result, cmd => cmd.Command == "appconfig account list"); + + // Verify that each command has proper structure + foreach (var cmd in result.Take(4)) + { + Assert.NotEmpty(cmd.Name); + Assert.NotEmpty(cmd.Description); + Assert.NotEmpty(cmd.Command); + } + } + + /// + /// Verifies that command paths are properly formatted without extra spaces + /// and follow consistent formatting conventions. + /// + [Fact] + public async Task ExecuteAsync_CommandPathFormattingIsCorrect() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + + Assert.NotNull(result); + + foreach (var command in result) + { + // Command paths should not start or end with spaces + Assert.False(command.Command.StartsWith(' '), $"Command '{command.Command}' should not start with space"); + Assert.False(command.Command.EndsWith(' '), $"Command '{command.Command}' should not end with space"); + + // Command paths should not have double spaces + Assert.DoesNotContain(" ", command.Command); + } + } + + /// + /// Verifies that the --namespace-mode switch returns only distinct top-level namespaces. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceSwitch_ReturnsNamespacesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + // Serialize then deserialize as list of CommandInfo + var json = JsonSerializer.Serialize(response.Results); + var namespaces = JsonSerializer.Deserialize>(json); + + Assert.NotNull(namespaces); + Assert.NotEmpty(namespaces); + + // Should include some well-known namespaces (matching Name property) + Assert.Contains(namespaces, ci => ci.Name.Equals("subscription", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(namespaces, ci => ci.Name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(namespaces, ci => ci.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + + foreach (var ns in namespaces!) + { + Assert.False(string.IsNullOrWhiteSpace(ns.Name)); + Assert.False(string.IsNullOrWhiteSpace(ns.Command)); + + // For regular namespaces, Command equals Name + // For surfaced extension commands like "azqr", Command is "extension azqr" but Name is "azqr" + if (!ns.Command.Contains(' ')) + { + // Regular namespace: Command == Name + Assert.Equal(ns.Name, ns.Command); + } + else + { + // Surfaced extension command: Command is "{namespace} {commandName}", Name is just "{commandName}" + // When Azure MCP presents the commands as tools, the spaces in the commands are replaced by underscore + Assert.EndsWith(ns.Name, ns.Command.Replace(" ", "_")); + } + + Assert.Equal(ns.Name, ns.Name.Trim()); + Assert.DoesNotContain(" ", ns.Name); + // Namespace should not itself have options + Assert.Null(ns.Options); + } + } + + /// + /// Verifies that the command handles empty command factory gracefully + /// and returns empty results when no commands are available. + /// + [Fact] + public async Task ExecuteAsync_WithEmptyCommandFactory_ReturnsEmptyResults() + { + // Arrange + var emptyCollection = new ServiceCollection(); + emptyCollection.AddLogging(); + + // Create empty command factory with minimal dependencies + var tempServiceProvider = emptyCollection.BuildServiceProvider(); + var logger = tempServiceProvider.GetRequiredService>(); + var telemetryService = Substitute.For(); + var emptyAreaSetups = Array.Empty(); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version" + }); + + // Create a NEW service collection just for the empty command factory + var finalCollection = new ServiceCollection(); + finalCollection.AddLogging(); + + var emptyCommandFactory = new CommandFactory(tempServiceProvider, emptyAreaSetups, telemetryService, configurationOptions, logger); + finalCollection.AddSingleton(emptyCommandFactory); + + var emptyServiceProvider = finalCollection.BuildServiceProvider(); + var emptyContext = new CommandContext(emptyServiceProvider); + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(emptyContext, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + + var result = DeserializeResults(response.Results!); + + Assert.NotNull(result); + Assert.Empty(result); // Should be empty when no commands are available + } + + /// + /// Verifies that the command metadata indicates it is non-destructive and read-only. + /// + [Fact] + public void Metadata_IndicatesNonDestructiveAndReadOnly() + { + // Act + var metadata = _command.Metadata; + + // Assert + Assert.NotNull(metadata); + Assert.False(metadata.Destructive, "Tool list command should not be destructive"); + Assert.True(metadata.ReadOnly, "Tool list command should be read-only"); + } + + /// + /// Verifies that the command includes metadata for each tool in the output. + /// + [Fact] + public async Task ExecuteAsync_IncludesMetadataForAllCommands() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Verify that all commands have metadata + foreach (var command in result) + { + Assert.NotNull(command.Metadata); + + // Verify that metadata has the expected properties + // Destructive, ReadOnly, Idempotent, OpenWorld, Secret, LocalRequired + var metadata = command.Metadata; + + // Check that at least the main properties are accessible + Assert.True(metadata.Destructive || !metadata.Destructive, "Destructive should be defined"); + Assert.True(metadata.ReadOnly || !metadata.ReadOnly, "ReadOnly should be defined"); + Assert.True(metadata.Idempotent || !metadata.Idempotent, "Idempotent should be defined"); + Assert.True(metadata.OpenWorld || !metadata.OpenWorld, "OpenWorld should be defined"); + Assert.True(metadata.Secret || !metadata.Secret, "Secret should be defined"); + Assert.True(metadata.LocalRequired || !metadata.LocalRequired, "LocalRequired should be defined"); + } + } + + /// + /// Verifies that the --name-only option returns only tool names without descriptions. + /// + [Fact] + public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Explicitly verify that description and command fields are not present + Assert.False(jsonElement.TryGetProperty("description", out _), "Response should not contain 'description' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("command", out _), "Response should not contain 'command' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("options", out _), "Response should not contain 'options' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("metadata", out _), "Response should not contain 'metadata' property when using --name-only option"); + + // Verify that all names are properly formatted tokenized names + foreach (var name in result.Names) + { + Assert.False(string.IsNullOrWhiteSpace(name), "Tool name should not be empty"); + Assert.DoesNotContain(" ", name); + } + + // Should contain some well-known commands + Assert.Contains(result.Names, name => name.Contains("subscription")); + Assert.Contains(result.Names, name => name.Contains("storage")); + Assert.Contains(result.Names, name => name.Contains("keyvault")); + } + + /// + /// Verifies that the --namespace option filters tools correctly for a single namespace. + /// + [Fact] + public async Task ExecuteAsync_WithSingleNamespaceOption_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from the storage namespace + foreach (var command in result) + { + Assert.StartsWith("storage", command.Command); + } + + // Should contain some well-known storage commands + Assert.Contains(result, cmd => cmd.Command == "storage account get"); + } + + /// + /// Verifies that multiple --namespace options work correctly. + /// + [Fact] + public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from either storage or keyvault namespaces + foreach (var command in result) + { + var isStorageCommand = command.Command.StartsWith("storage"); + var isKeyvaultCommand = command.Command.StartsWith("keyvault"); + Assert.True(isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' should be from storage or keyvault namespace"); + } + + // Should contain commands from both namespaces + Assert.Contains(result, cmd => cmd.Command.StartsWith("storage")); + Assert.Contains(result, cmd => cmd.Command.StartsWith("keyvault")); + } + + /// + /// Verifies that --name-only and --namespace options work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from the storage namespace + foreach (var name in result.Names) + { + Assert.StartsWith("storage_", name); + } + + // Should contain some well-known storage commands + Assert.Contains(result.Names, name => name.Contains("account_get")); + } + + /// + /// Verifies that --name-only with multiple --namespace options works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from either storage or keyvault namespaces + foreach (var name in result.Names) + { + var isStorageName = name.StartsWith("storage_"); + var isKeyvaultName = name.StartsWith("keyvault_"); + Assert.True(isStorageName || isKeyvaultName, + $"Tool name '{name}' should be from storage or keyvault namespace"); + } + + // Should contain names from both namespaces + Assert.Contains(result.Names, name => name.StartsWith("storage_")); + Assert.Contains(result.Names, name => name.StartsWith("keyvault_")); + } + + /// + /// Verifies that option binding works correctly for the new options. + /// + [Fact] + public void BindOptions_WithNewOptions_BindsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage", "--namespace", "keyvault" }); + + // Use reflection to call the protected BindOptions method + var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(bindOptionsMethod); + + // Act + var options = bindOptionsMethod.Invoke(_command, new object?[] { parseResult }) as ToolsListOptions; + + // Assert + Assert.NotNull(options); + Assert.True(options.NameOnly); + Assert.False(options.NamespaceMode); + Assert.Equal(2, options.Namespaces.Count); + Assert.Contains("storage", options.Namespaces); + Assert.Contains("keyvault", options.Namespaces); + } + + /// + /// Verifies that parsing the new options works correctly. + /// + [Fact] + public void CanParseNewOptions() + { + // Arrange & Act + var parseResult1 = _commandDefinition.Parse(["--name-only"]); + var parseResult2 = _commandDefinition.Parse(["--namespace", "storage"]); + var parseResult3 = _commandDefinition.Parse(["--name-only", "--namespace", "storage", "--namespace", "keyvault"]); + + // Assert + Assert.False(parseResult1.Errors.Any(), $"Parse errors for --name-only: {string.Join(", ", parseResult1.Errors)}"); + Assert.False(parseResult2.Errors.Any(), $"Parse errors for --namespace: {string.Join(", ", parseResult2.Errors)}"); + Assert.False(parseResult3.Errors.Any(), $"Parse errors for combined options: {string.Join(", ", parseResult3.Errors)}"); + + // Verify values + Assert.True(parseResult1.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly.Name)); + + var namespaces2 = parseResult2.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces2); + Assert.Single(namespaces2); + Assert.Equal("storage", namespaces2[0]); + + var namespaces3 = parseResult3.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces3); + Assert.Equal(2, namespaces3.Length); + Assert.Contains("storage", namespaces3); + Assert.Contains("keyvault", namespaces3); + } + + /// + /// Verifies that --namespace-mode and --name-only work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndNameOnly_ReturnsNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Should contain only namespace names (not individual commands) + Assert.Contains(result.Names, name => name.Equals("subscription", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that --namespace-mode, --name-only, and --namespace filtering work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeNameOnlyAndNamespaceFilter_ReturnsFilteredNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Should contain only storage namespace (and possibly surfaced storage-related commands) + foreach (var name in result.Names) + { + Assert.True(name.Equals("storage", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("storage ", StringComparison.OrdinalIgnoreCase), + $"Name '{name}' should be from storage namespace"); + } + } + + /// + /// Verifies that --namespace-mode with multiple namespace filters works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndMultipleNamespaces_ReturnsFilteredNamespaces() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Should contain only storage and keyvault namespaces + foreach (var command in result) + { + var isStorageNamespace = command.Name.Equals("storage", StringComparison.OrdinalIgnoreCase); + var isKeyvaultNamespace = command.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase); + var isStorageCommand = command.Command.StartsWith("storage ", StringComparison.OrdinalIgnoreCase); + var isKeyvaultCommand = command.Command.StartsWith("keyvault ", StringComparison.OrdinalIgnoreCase); + + Assert.True(isStorageNamespace || isKeyvaultNamespace || isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' (Name: '{command.Name}') should be from storage or keyvault namespace"); + } + + // Should contain both namespaces + Assert.Contains(result, cmd => cmd.Name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result, cmd => cmd.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs new file mode 100644 index 0000000000..e61d51b217 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs @@ -0,0 +1,2 @@ +[assembly: Azure.Mcp.Tests.Helpers.ClearEnvironmentVariablesBeforeTest] +[assembly: Xunit.CollectionBehavior(Xunit.CollectionBehavior.CollectionPerAssembly)] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Azure.Mcp.Core.UnitTests.csproj b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Azure.Mcp.Core.UnitTests.csproj new file mode 100644 index 0000000000..b6a05696f4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Azure.Mcp.Core.UnitTests.csproj @@ -0,0 +1,18 @@ + + + true + Exe + + + + + + + + + + + + + + diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs new file mode 100644 index 0000000000..ab2d74fba1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Azure.Mcp.Tests.Client.Helpers; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Client; + +public class MockClientTests +{ + private readonly McpServerOptions _options; + + public MockClientTests() + { + _options = CreateOptions(); + } + + private static McpServerOptions CreateOptions(McpServerHandlers? serverHandlers = null) + { + return new McpServerOptions + { + ProtocolVersion = "2024", + InitializationTimeout = TimeSpan.FromSeconds(30), + Handlers = serverHandlers ?? new(), + ServerInfo = new Implementation { Name = "Azure MCP", Version = "1.0.0-beta" } + }; + } + + [Fact] + public async Task Invoke_Ping_Request_To_Server() + { + await Invoke_Request_To_Server( + method: "ping", + serverHandlers: null, + configureOptions: null, + assertResult: response => + { + JsonObject jObj = Assert.IsType(response); + Assert.Empty(jObj); + }); + } + + [Fact] + public async Task Invoke_Init_Command() + { + await Invoke_Request_To_Server( + method: "initialize", + serverHandlers: null, + configureOptions: null, + assertResult: response => + { + var result = JsonSerializer.Deserialize(response); + Assert.NotNull(result); + Assert.Equal("Azure MCP", result.ServerInfo.Name); + Assert.Equal("1.0.0-beta", result.ServerInfo.Version); + Assert.Equal("2024", result.ProtocolVersion); + }); + } + + [Fact] + public async Task Invoke_Az_List_Subscription_Command() + { + await Invoke_Request_To_Server( + method: "tools/call", + new() + { + CallToolHandler = (request, ct) => + { + if (request.Params?.Name == "azmcp_subscription_list") + { + return ValueTask.FromResult(new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = JsonSerializer.Serialize(new + { + subscriptions = new[] + { + new { id = "sub-1", name = "Test Sub A" }, + new { id = "sub-2", name = "Test Sub B" } + } + }) + } + ] + }); + } + + throw new Exception($"Unhandled tool name: {request.Params?.Name}"); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), + }, + requestParams: JsonSerializer.SerializeToNode(new + { + name = "azmcp_subscription_list", + arguments = new { } + }), + configureOptions: null, + assertResult: response => + { + var callToolResponse = JsonSerializer.Deserialize(response); + Assert.NotNull(callToolResponse); + Assert.NotEmpty(callToolResponse.Content); + + string? jsonContent = McpTestUtilities.GetFirstText(callToolResponse.Content); + Assert.NotNull(jsonContent); + + var json = JsonSerializer.Deserialize(jsonContent); + var subs = json?["subscriptions"]?.AsArray(); + Assert.NotNull(subs); + Assert.NotEmpty(subs!); + Assert.Equal("Test Sub A", subs![0]!["name"]?.ToString()); + }); + } + + + [Fact] + public async Task Invoke_List_Tools_Command() + { + await Invoke_Request_To_Server( + method: "tools/list", + new() + { + ListToolsHandler = (request, ct) => + { + return ValueTask.FromResult(new ListToolsResult + { + Tools = [new() { Name = "ListTools" }] + }); + }, + CallToolHandler = (request, ct) => throw new NotImplementedException(), + }, + configureOptions: null, + assertResult: response => + { + var result = JsonSerializer.Deserialize(response); + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + Assert.Equal("ListTools", result.Tools[0].Name); + }); + } + + [Fact] + public async Task Invoke_Dummy_Tool() + { + await Invoke_Request_To_Server( + method: "tools/call", + new() + { + CallToolHandler = (request, ct) => + { + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = "dummyTool" }] + }); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), + }, + configureOptions: null, + assertResult: response => + { + var result = JsonSerializer.Deserialize(response); + Assert.NotNull(result); + Assert.NotEmpty(result.Content); + Assert.Equal("dummyTool", (result.Content[0] as TextContentBlock)?.Text); + }); + } + + [Fact] + public async Task Invoke_Invalid_Tool_Returns_Error() + { + await Invoke_Request_To_Server( + method: "tools/call", + new() + { + CallToolHandler = (request, ct) => + { + // Simulate the behavior when an invalid tool is called + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = $"The tool {request.Params?.Name} was not found" }], + IsError = true + }); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), + }, + requestParams: JsonSerializer.SerializeToNode(new + { + name = "non_existent_tool", + arguments = new { } + }), + configureOptions: null, + assertResult: response => + { + var result = JsonSerializer.Deserialize(response); + Assert.NotNull(result); + Assert.True(result.IsError, "Expected error response for non-existent tool"); + Assert.NotEmpty(result.Content); + + var textContent = Assert.IsType(result.Content[0]); + Assert.Contains("The tool non_existent_tool was not found", textContent.Text); + }); + } + + + private async Task Invoke_Request_To_Server(string method, McpServerHandlers? serverHandlers, Action? configureOptions, Action assertResult) + { + await Invoke_Request_To_Server( + serverHandlers: serverHandlers, + method: method, + requestParams: null, + configureOptions: configureOptions, + assertResult: assertResult + ); + } + + private async Task Invoke_Request_To_Server(string method, McpServerHandlers? serverHandlers, JsonNode? requestParams, Action? configureOptions, Action assertResult) + { + await using var transport = new CustomTestTransport(); + var options = CreateOptions(serverHandlers); + configureOptions?.Invoke(options); + + await using var server = McpServer.Create(transport, options); + var runTask = server.RunAsync(); + + var receivedMessage = new TaskCompletionSource(); + + transport.MessageListener = (message) => + { + if (message is JsonRpcResponse response && response.Id.ToString() == "07") + receivedMessage.SetResult(response); + }; + + // Simulate a client sending a request to the server + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = method, + Params = requestParams, + Id = new RequestId("07"), + } + ); + + var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotNull(response); + + var node = JsonSerializer.SerializeToNode(response.Result); + assertResult(node); + + await transport.DisposeAsync(); + await runTask; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs new file mode 100644 index 0000000000..1d8ec764ec --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Commands; + +public class CommandFactoryTests +{ + private const string FullCommandName1 = "root_directCommand"; + private const string FullCommandName2 = "root_subgroup1_directCommand2"; + private const string FullCommandName3 = "root_subgroup1_directCommand3"; + private const string FullCommandName4 = "root_subgroup2_directCommand4"; + + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly ITelemetryService _telemetryService; + private readonly AzureMcpServerConfiguration _serverConfiguration; + private readonly IOptions _configurationOptions; + + public CommandFactoryTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + + _serverConfiguration = new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version" + }; + + _serviceProvider = services.BuildServiceProvider(); + _logger = Substitute.For>(); + _telemetryService = Substitute.For(); + _configurationOptions = Microsoft.Extensions.Options.Options.Create(_serverConfiguration); + } + + [Fact] + public void Separator_Should_Be_Underscore() + { + // This test verifies our fix for supporting dashes in command names + // by ensuring the separator is underscore instead of dash + + // Arrange & Act + var separator = CommandFactory.Separator; + + // Assert + Assert.Equal('_', separator); + } + + [Theory] + [InlineData("subscription", "list", "subscription_list")] + [InlineData("storage", "account_list", "storage_account_list")] + [InlineData("role", "assignment_list", "role_assignment_list")] + [InlineData("azmcp", "subscription_list", "azmcp_subscription_list")] + public void GetPrefix_Should_Use_Underscore_Separator(string currentPrefix, string additional, string expected) + { + // This test verifies that command hierarchies are joined with underscores + // which allows commands to use dashes naturally without conflicting with separators + + // Arrange & Act + var result = CallGetPrefix(currentPrefix, additional); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetPrefix_Should_Handle_Empty_CurrentPrefix() + { + // Arrange & Act + var result = CallGetPrefix(string.Empty, "subscription"); + + // Assert + Assert.Equal("subscription", result); + } + + [Fact] + public void GetPrefix_Should_Handle_Null_CurrentPrefix() + { + // Arrange & Act + var result = CallGetPrefix(null!, "subscription"); + + // Assert + Assert.Equal("subscription", result); + } + + [Theory] + [InlineData("list-roles")] + [InlineData("get-resource-group")] + [InlineData("create-storage-account")] + public void Command_Names_With_Dashes_Should_Not_Conflict_With_Separator(string commandNameWithDash) + { + // This test verifies that command names containing dashes don't conflict + // with our underscore separator, which was the core issue we're solving + + // Arrange + var prefix = "azmcp_role"; + + // Act + var result = CallGetPrefix(prefix, commandNameWithDash); + + // Assert + Assert.Contains('_', result); // Should contain our separator + Assert.Contains('-', result); // Should preserve dashes in command names + Assert.Equal($"{prefix}_{commandNameWithDash}", result); + + // Verify the dash in the command name doesn't interfere with parsing + var parts = result.Split('_'); + Assert.True(parts.Length >= 2); + Assert.Equal("azmcp", parts[0]); + Assert.Equal("role", parts[1]); + Assert.Equal(commandNameWithDash, parts[2]); + } + + [Fact] + public void Constructor_Throws_AreaSetups_Duplicate() + { + // Arrange + var duplicate = "Duplicate Name"; + var area = CreateIAreaSetup(duplicate); + var area1 = CreateIAreaSetup("name1"); + var area2 = CreateIAreaSetup(duplicate); + + var serviceAreas = new List { area, area1, area2 }; + + // Act & Assert + Assert.Throws(() => + new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger)); + } + + [Fact] + public void Constructor_Throws_AreaSetups_EmptyName() + { + // Arrange + var area = CreateIAreaSetup("Name"); + var area1 = CreateIAreaSetup("Name1"); + var area2 = CreateIAreaSetup(string.Empty); + + var serviceAreas = new List { area, area1, area2 }; + + // Act & Assert + Assert.Throws(() => + new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger)); + } + + [Theory] + [InlineData("name3_directCommand", "name3")] + [InlineData("name2_subgroup1_directCommand2", "name2")] + [InlineData("name3_subgroup2_directCommand4", "name3")] + public void GetServiceArea_Existing_SetupArea(string commandName, string expected) + { + // Arrange + var area1 = CreateIAreaSetup("name1"); + var area2 = CreateIAreaSetup("name2"); + var area3 = CreateIAreaSetup("name3"); + + var serviceAreas = new List { area1, area3, area2 }; + var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger); + + // Act + // Try in the case that the root prefix is not used. This is in the case that the tool + // is created using the IAreaSetup name as root. + var actual2 = factory.GetServiceArea(commandName); + + // Assert + Assert.Equal(expected, actual2); + } + + [Fact] + public void GetServiceArea_DoesNotExist() + { + // Arrange + var area1 = CreateIAreaSetup("name1"); + var area2 = CreateIAreaSetup("name2"); + var area3 = CreateIAreaSetup("name3"); + + var serviceAreas = new List { area1, area2, area3 }; + var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger); + + // All commands created in command factory are prefixed with the root command group, "azmcp". + var commandNameToTry = "azmcp" + CommandFactory.Separator + "name0_subgroup2_directCommand4"; + + // Act + var actual = factory.GetServiceArea(commandNameToTry); + + // Assert + Assert.Null(actual); + } + + [Fact] + public void CommandDictionaryCreated_WithPrefix() + { + // Arrange + var prefix = "abc"; + var commandGroup = CreateCommandGroup(); + + // Act + var commandDictionary = CommandFactory.CreateCommandDictionaryInner(commandGroup, prefix); + + // Assert + Assert.NotNull(commandDictionary); + Assert.NotEmpty(commandDictionary); + + // Expected 4 commands to be created. + Assert.Equal(4, commandDictionary.Count); + + Assert.Contains($"abc_{FullCommandName1}", commandDictionary.Keys); + Assert.Contains($"abc_{FullCommandName2}", commandDictionary.Keys); + Assert.Contains($"abc_{FullCommandName3}", commandDictionary.Keys); + Assert.Contains($"abc_{FullCommandName4}", commandDictionary.Keys); + } + + [Fact] + public void CommandDictionaryCreated_EmptyPrefix() + { + // Arrange + var commandGroup = CreateCommandGroup(); + + // Act + var commandDictionary = CommandFactory.CreateCommandDictionaryInner(commandGroup, string.Empty); + + // Assert + Assert.NotNull(commandDictionary); + Assert.NotEmpty(commandDictionary); + + // Expected 4 commands to be created. + Assert.Equal(4, commandDictionary.Count); + + Assert.Contains(FullCommandName1, commandDictionary.Keys); + Assert.Contains(FullCommandName2, commandDictionary.Keys); + Assert.Contains(FullCommandName3, commandDictionary.Keys); + Assert.Contains(FullCommandName4, commandDictionary.Keys); + } + + /// + /// Helper method to access the private GetPrefix method via reflection + /// + private static string CallGetPrefix(string currentPrefix, string additional) + { + return CommandFactory.GetPrefix(currentPrefix, additional); + } + + private static IAreaSetup CreateIAreaSetup(string areaName) + { + var area = Substitute.For(); + + area.Name.Returns(areaName); + area.RegisterCommands(Arg.Any()).Returns(caller => + { + var newCommandGroup = CreateCommandGroup(areaName); + return newCommandGroup; + }); + + return area; + } + + /// + /// Creates a "root" command group that has: + /// - 1 direct command + /// - 2 subgroups + /// - subgroup1: has 2 command + /// - subgroup2 has 1 command + /// + /// + private static CommandGroup CreateCommandGroup(string rootName = "root") + { + var group = new CommandGroup(rootName, "Test root"); + var subGroup = new CommandGroup("subgroup1", "Test subgroup"); + var subGroup2 = new CommandGroup("subgroup2", "Test subgroup"); + + var directCommand = Substitute.For(); + directCommand.Name.Returns(nameof(directCommand)); + directCommand.GetCommand().Returns(new Command(nameof(directCommand))); + + var directCommand2 = Substitute.For(); + directCommand2.Name.Returns(nameof(directCommand2)); + directCommand2.GetCommand().Returns(new Command(nameof(directCommand2))); + + + var directCommand3 = Substitute.For(); + directCommand3.Name.Returns(nameof(directCommand3)); + directCommand3.GetCommand().Returns(new Command(nameof(directCommand3))); + + var directCommand4 = Substitute.For(); + directCommand4.Name.Returns(nameof(directCommand4)); + directCommand4.GetCommand().Returns(new Command(nameof(directCommand4))); + + // Add commands to each group + group.Commands.Add(nameof(directCommand), directCommand); + + subGroup.Commands.Add(nameof(directCommand2), directCommand2); + subGroup.Commands.Add(nameof(directCommand3), directCommand3); + + subGroup2.Commands.Add(nameof(directCommand4), directCommand4); + + // Make subgroups + group.SubGroup.Add(subGroup); + group.SubGroup.Add(subGroup2); + + return group; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/DashSupportTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/DashSupportTests.cs new file mode 100644 index 0000000000..5528a9c50a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/DashSupportTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Commands; + +/// +/// Tests that demonstrate the fix for supporting dashes in command names. +/// This addresses the issue where commands like "azmcp list-roles" would create +/// ambiguous tool names if we used dashes as separators. +/// +public class DashSupportTests +{ + [Fact] + public void Separator_Change_Demonstration() + { + // This test documents the simple fix: changing separator from '-' to '_' + + // Before the fix: separator was '-' + // After the fix: separator is '_' + + // Arrange & Act + var separator = CommandFactory.Separator; + + // Assert + Assert.Equal('_', separator); + Assert.NotEqual('-', separator); + } + + [Theory] + [InlineData("list-roles")] + [InlineData("get-resource-group")] + [InlineData("create-storage-account")] + public void Commands_With_Dashes_Should_Work_With_Underscore_Separator(string commandWithDash) + { + // This test demonstrates that commands can now use dashes naturally + // because we use underscores as separators + + // Arrange + var prefix = "azmcp_role"; + + // Act - Simulate how the CommandFactory builds command names + var toolName = $"{prefix}_{commandWithDash}"; + + // Assert + Assert.Contains('_', toolName); // Uses underscore as separator + Assert.Contains('-', toolName); // Preserves dashes in command names + + // Verify no ambiguity in parsing + var parts = toolName.Split('_'); + Assert.True(parts.Length >= 3, $"Tool name '{toolName}' should have at least 3 parts when split by underscore"); + Assert.Equal("azmcp", parts[0]); + Assert.Equal("role", parts[1]); + Assert.Equal(commandWithDash, parts[2]); + } + + [Fact] + public void Before_And_After_Comparison() + { + // This test shows the difference between old and new approach + + var commandName = "list-roles"; + var prefix = "azmcp_role"; + + // New approach (after fix): Use underscore separator + var newToolName = $"{prefix}_{commandName}"; + Assert.Equal("azmcp_role_list-roles", newToolName); + + // Old approach would have been: "azmcp-role-list-roles" + // This would be ambiguous - is it "azmcp-role" + "list-roles" + // or "azmcp" + "role-list" + "roles"? + + // With underscores, it's clear: "azmcp" + "role" + "list-roles" + var parts = newToolName.Split('_'); + Assert.Equal(3, parts.Length); + Assert.Equal("azmcp", parts[0]); + Assert.Equal("role", parts[1]); + Assert.Equal("list-roles", parts[2]); + } + + [Theory] + [InlineData("subscription_list", "subscription list")] + [InlineData("role_assignment_list", "role assignment list")] + [InlineData("storage_account_list", "storage account list")] + public void Tool_Name_To_Command_Mapping_Examples(string toolNameSuffix, string expectedCommandSuffix) + { + // This test demonstrates how tool names map back to commands + + // Arrange + var fullToolName = $"azmcp_{toolNameSuffix}"; + + // Act - Simulate parsing tool name back to command structure + var parts = fullToolName.Split('_'); + + // Assert + Assert.True(parts.Length >= 2); + Assert.Equal("azmcp", parts[0]); + + // Verify the tool name structure + var commandParts = parts.Skip(1).ToArray(); + var reconstructedCommandSuffix = string.Join(" ", commandParts); + Assert.Equal(expectedCommandSuffix, reconstructedCommandSuffix); + + // Verify the tool name is unambiguous + Assert.DoesNotContain('-', fullToolName); // Tool names use underscores, not dashes + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/Extensions/CommandExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/Extensions/CommandExtensionsTests.cs new file mode 100644 index 0000000000..16edc0332c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/Extensions/CommandExtensionsTests.cs @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Commands; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Commands.Extensions; + +public class CommandExtensionsTests +{ + [Fact] + public void ParseFromDictionary_WithNullArguments_ReturnsEmptyParseResult() + { + // Arrange + var command = new Command("test", "Test command"); + + // Act + var result = command.ParseFromDictionary(null); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + } + + [Fact] + public void ParseFromDictionary_WithEmptyArguments_ReturnsEmptyParseResult() + { + // Arrange + var command = new Command("test", "Test command"); + var arguments = new Dictionary(); + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + } + + [Fact] + public void ParseFromDictionary_WithStringArgument_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--name") { Description = "Name option" }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["name"] = JsonSerializer.SerializeToElement("test-value") + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal("test-value", value); + } + + [Fact] + public void ParseFromDictionary_WithStringContainingQuotes_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--query") { Description = "Query option" }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["query"] = JsonSerializer.SerializeToElement("SalesTable | parse ClassName with * 'jsonField': ' value '' *") + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal("SalesTable | parse ClassName with * 'jsonField': ' value '' *", value); + } + + [Fact] + public void ParseFromDictionary_WithBooleanArguments_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var trueOption = new Option("--enabled") { Description = "Enabled option" }; + var falseOption = new Option("--disabled") { Description = "Disabled option" }; + command.Options.Add(trueOption); + command.Options.Add(falseOption); + + var arguments = new Dictionary + { + ["enabled"] = JsonSerializer.SerializeToElement(true), + ["disabled"] = JsonSerializer.SerializeToElement(false) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.True(result.GetValue(trueOption)); + Assert.False(result.GetValue(falseOption)); + } + + [Fact] + public void ParseFromDictionary_WithNumericArguments_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var intOption = new Option("--count") + { + Description = "Count option" + }; + var doubleOption = new Option("--rate") + { + Description = "Rate option" + }; + command.Options.Add(intOption); + command.Options.Add(doubleOption); + + var arguments = new Dictionary + { + ["count"] = JsonSerializer.SerializeToElement(42), + ["rate"] = JsonSerializer.SerializeToElement(3.14) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal(42, result.GetValue(intOption)); + Assert.Equal(3.14, result.GetValue(doubleOption)); + } + [Fact] + public void ParseFromDictionary_WithArrayArgument_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--items") + { + Description = "Items option" + }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["items"] = JsonSerializer.SerializeToElement(new[] { "item1", "item2", "item3" }) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal("item1 item2 item3", value); // Array is joined with spaces for single-value options + } + + [Fact] + public void ParseFromDictionary_WithStringArrayArgument_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--tags") + { + Description = "Tags option", + AllowMultipleArgumentsPerToken = true + }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["tags"] = JsonSerializer.SerializeToElement(new[] { "tag1", "tag2", "tag3" }) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var values = result.GetValue(option); + Assert.NotNull(values); + Assert.Equal(3, values.Length); + Assert.Equal("tag1", values[0]); + Assert.Equal("tag2", values[1]); + Assert.Equal("tag3", values[2]); + } + + [Fact] + public void ParseFromDictionary_WithNullValue_SkipsOption() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--name") + { + Description = "Name option" + }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["name"] = JsonSerializer.SerializeToElement(null) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Null(value); + } + [Fact] + public void ParseFromDictionary_WithCaseInsensitiveOptionNames_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--subscription") + { + Description = "Subscription option" + }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["SUBSCRIPTION"] = JsonSerializer.SerializeToElement("test-sub") + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal("test-sub", value); + } + + [Fact] + public void ParseFromDictionary_WithUnknownOption_IgnoresOption() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--known") { Description = "Known option" }; + command.Options.Add(option); + + var arguments = new Dictionary + { + ["known"] = JsonSerializer.SerializeToElement("known-value"), + ["unknown"] = JsonSerializer.SerializeToElement("unknown-value") + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal("known-value", value); + } + + [Fact] + public void ParseFromDictionary_WithComplexJsonString_ParsesCorrectly() + { + // Arrange + var command = new Command("test", "Test command"); + var option = new Option("--json-data") { Description = "JSON data option" }; + command.Options.Add(option); + + var jsonString = "{\"key\": \"value with 'single' and \\\"double\\\" quotes\"}"; + var arguments = new Dictionary + { + ["json-data"] = JsonSerializer.SerializeToElement(jsonString) + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + var value = result.GetValue(option); + Assert.Equal(jsonString, value); + } + + [Fact] + public void ParseFromDictionary_WithSingleQuotesInValues_ParsesCorrectly() + { + // Arrange + var command = new Command("test"); + var queryOption = new Option("--query") { Required = true }; + var nameOption = new Option("--name") { Required = false }; + command.Options.Add(queryOption); + command.Options.Add(nameOption); + + var arguments = new Dictionary + { + { "query", JsonSerializer.SerializeToElement("SELECT * FROM table WHERE column = 'value'") }, + { "name", JsonSerializer.SerializeToElement("O'Connor's Database") } + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal("SELECT * FROM table WHERE column = 'value'", result.GetValue(queryOption)); + Assert.Equal("O'Connor's Database", result.GetValue(nameOption)); + } + + [Fact] + public void ParseFromDictionary_WithDoubleQuotesInValues_ParsesCorrectly() + { + // Arrange + var command = new Command("test"); + var titleOption = new Option("--title"); + command.Options.Add(titleOption); + + var arguments = new Dictionary + { + { "title", JsonSerializer.SerializeToElement("The \"Best\" Solution") } + }; + + // Act + var result = command.ParseFromDictionary(arguments); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal("The \"Best\" Solution", result.GetValue(titleOption)); + } + + [Fact] + public void ParseFromDictionary_WithMixedQuotesInValues_ParsesCorrectly() + { + // Arrange + var command = new Command("test"); + var scriptOption = new Option("--script") { Required = true }; + command.Options.Add(scriptOption); + + var arguments = new Dictionary + { + { "script", JsonSerializer.SerializeToElement("echo \"User's home: '$HOME'\" && echo 'Path: \"$PATH\"'") } + }; + + // Act + var result = command.ParseFromDictionary(arguments); // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal("echo \"User's home: '$HOME'\" && echo 'Path: \"$PATH\"'", result.GetValue(scriptOption)); + } + + [Fact] + public void ParseFromRawMcpToolInput() + { + // Arrange + var command = new Command("test"); + var scriptOption = new Option("--raw-mcp-tool-input") { Required = true }; + command.Options.Add(scriptOption); + + var arguments = new Dictionary + { + { "name", JsonSerializer.SerializeToElement("abc") }, + { "path", JsonSerializer.SerializeToElement("123") } + }; + + // Act + var result = command.ParseFromRawMcpToolInput(arguments); // Assert + Assert.NotNull(result); + Assert.Empty(result.Errors); + Assert.Equal("{\"name\":\"abc\",\"path\":\"123\"}", result.GetValue(scriptOption)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs new file mode 100644 index 0000000000..155bfd9896 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Azure.Mcp.Core.Extensions; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Extensions; + +public class CommandResultExtensionsTests +{ + [Fact] + public void GetValueOrDefault_WithExplicitStringValue_ReturnsValue() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--name test-value"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Equal("test-value", result); + } + + [Fact] + public void GetValueOrDefault_WithMissingStringValue_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueOrDefault_WithExplicitIntValue_ReturnsValue() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 42"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public void GetValueOrDefault_WithMissingIntValue_ReturnsNull() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueOrDefault_WithExplicitLongValue_ReturnsValue() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--max-size-bytes 1073741824"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Equal(1073741824L, result); + } + + [Fact] + public void GetValueOrDefault_WithMissingLongValue_ReturnsNull() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueOrDefault_WithExplicitBoolValue_ReturnsValue() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant true"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueOrDefault_WithMissingBoolValue_ReturnsNull() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueOrDefault_WithExplicitZeroIntValue_ReturnsZero() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 0"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetValueOrDefault_WithExplicitFalseBoolValue_ReturnsFalse() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant false"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetValueOrDefault_WithBoolSwitch_ReturnsTrue() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--verbose"); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueOrDefault_WithMissingBoolSwitch_ReturnsNull() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueOrDefault_WithNullableIntDefaultValue_ReturnsDefault() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => 42 + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public void GetValueOrDefault_WithNullableIntNullDefaultValue_ReturnsNull() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => null + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueOrDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitStringValue_ReturnsValue() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--name test-value"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal("test-value", result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingStringValue_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitIntValue_ReturnsValue() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 42"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingIntValue_ReturnsNull() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitLongValue_ReturnsValue() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--max-size-bytes 1073741824"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(1073741824L, result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingLongValue_ReturnsNull() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitBoolValue_ReturnsValue() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant true"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingBoolValue_ReturnsNull() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitZeroIntValue_ReturnsZero() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 0"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitFalseBoolValue_ReturnsFalse() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant false"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetValueWithoutDefault_WithBoolSwitch_ReturnsTrue() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--verbose"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingBoolSwitch_ReturnsNull() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithDefaultValue_IgnoresDefault() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => 42 + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); // Should ignore default and return null + } + + [Fact] + public void GetValueWithoutDefault_WithNullDefaultValue_ReturnsNull() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => null + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithExplicitValue_ReturnsValue() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--name test-value"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Equal("test-value", result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithMissingValue_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithDefaultValue_IgnoresDefault() + { + // Arrange + var option = new Option("--name") + { + DefaultValueFactory = _ => "default-value" + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Null(result); // Should ignore default and return null + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithNonExistentOption_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--non-existent"); + + // Assert + Assert.Null(result); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/HttpClientServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/HttpClientServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..bf1a24f0c6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/HttpClientServiceCollectionExtensionsTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Services.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Extensions; + +public class HttpClientServiceCollectionExtensionsTests +{ + [Fact] + public void AddHttpClientServices_RegistersRequiredServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHttpClientServices(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var httpClientService = serviceProvider.GetService(); + Assert.NotNull(httpClientService); + Assert.IsType(httpClientService); + + var options = serviceProvider.GetService>(); + Assert.NotNull(options); + } + + [Fact] + public void AddHttpClientServices_WithConfiguration_AppliesCustomConfiguration() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHttpClientServices(options => + { + options.DefaultTimeout = TimeSpan.FromSeconds(45); + options.DefaultUserAgent = "CustomAgent"; + }); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var options = serviceProvider.GetRequiredService>(); + Assert.Equal(TimeSpan.FromSeconds(45), options.Value.DefaultTimeout); + Assert.Equal("CustomAgent", options.Value.DefaultUserAgent); + } + + [Fact] + public void AddHttpClientServices_ReadsEnvironmentVariables() + { + // Arrange + var services = new ServiceCollection(); + + // Set environment variables + Environment.SetEnvironmentVariable("HTTP_PROXY", "http://test.proxy:8080"); + Environment.SetEnvironmentVariable("NO_PROXY", "localhost"); + + try + { + // Act + services.AddHttpClientServices(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var options = serviceProvider.GetRequiredService>(); + Assert.Equal("http://test.proxy:8080", options.Value.HttpProxy); + Assert.Contains("localhost", options.Value.NoProxy); + } + finally + { + // Clean up + Environment.SetEnvironmentVariable("HTTP_PROXY", null); + Environment.SetEnvironmentVariable("NO_PROXY", null); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs new file mode 100644 index 0000000000..82bddcdc08 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs @@ -0,0 +1,218 @@ +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Elicitation; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Extensions; + +public class McpServerElicitationExtensionsTests +{ + [Fact] + public void SupportsElicitation_WithElicitationCapability_ReturnsTrue() + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + // Act + var result = server.SupportsElicitation(); + + // Assert + Assert.True(result); + } + + [Fact] + public void SupportsElicitation_WithoutElicitationCapability_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities(); // No Elicitation + server.ClientCapabilities.Returns(clientCapabilities); + + // Act + var result = server.SupportsElicitation(); + + // Assert + Assert.False(result); + } + + [Fact] + public void SupportsElicitation_WithNullClientCapabilities_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + server.ClientCapabilities.Returns((ClientCapabilities?)null); + + // Act + var result = server.SupportsElicitation(); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public void ShouldTriggerElicitation_WithJsonObjectMetadata_ReturnsExpectedResult(bool secretValue, bool expected) + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + var metadata = new JsonObject + { + ["Secret"] = JsonValue.Create(secretValue) + }; + + // Act + var result = server.ShouldTriggerElicitation("tool1", metadata); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void ShouldTriggerElicitation_WithNullMetadata_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + + // Act + var result = server.ShouldTriggerElicitation("tool1", null); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldTriggerElicitation_WithNonJsonObjectMetadata_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + var metadata = new Dictionary { { "Secret", true } }; + + // Act + var result = server.ShouldTriggerElicitation("tool1", metadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldTriggerElicitation_WithNonSupportingClient_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + server.ClientCapabilities.Returns((ClientCapabilities?)null); + + var metadata = new JsonObject + { + ["Secret"] = JsonValue.Create(true) + }; + + // Act + var result = server.ShouldTriggerElicitation("tool1", metadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldTriggerElicitation_WithMissingSecretProperty_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + var metadata = new JsonObject + { + ["Other"] = JsonValue.Create("value") + }; + + // Act + var result = server.ShouldTriggerElicitation("tool1", metadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldTriggerElicitation_WithSecretPropertyButInvalidValue_ReturnsFalse() + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + var metadata = new JsonObject + { + ["Secret"] = JsonValue.Create("not_a_boolean") + }; + + // Act + var result = server.ShouldTriggerElicitation("tool1", metadata); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RequestElicitationAsync_WithNonSupportingClient_ThrowsNotSupportedException() + { + // Arrange + var server = CreateMockServer(); + server.ClientCapabilities.Returns((ClientCapabilities?)null); + + var request = new ElicitationRequestParams + { + Message = "Test message" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => server.RequestElicitationAsync(request, TestContext.Current.CancellationToken)); + + Assert.Contains("elicitation", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RequestElicitationAsync_WithInvalidMessage_ThrowsArgumentException(string? message) + { + // Arrange + var server = CreateMockServer(); + var clientCapabilities = new ClientCapabilities { Elicitation = new() }; + server.ClientCapabilities.Returns(clientCapabilities); + + var request = new ElicitationRequestParams + { + Message = message! + }; + + // Act & Assert + await Assert.ThrowsAsync( + () => server.RequestElicitationAsync(request, TestContext.Current.CancellationToken)); + } + + private static McpServer CreateMockServer() + { + // Create a mock server that we can configure without constructor issues + var server = Substitute.For(); + + // Set up default client capabilities + server.ClientCapabilities.Returns(new ClientCapabilities()); + + return server; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CollectionTypeHelperTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CollectionTypeHelperTests.cs new file mode 100644 index 0000000000..404d6305f6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CollectionTypeHelperTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Collections.ObjectModel; +using Azure.Mcp.Core.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Helpers; + +public class CollectionTypeHelperTests +{ + [Theory] + [InlineData(typeof(string[]), true)] + [InlineData(typeof(int[]), true)] + [InlineData(typeof(bool[]), true)] + [InlineData(typeof(List), true)] + [InlineData(typeof(IList), true)] + [InlineData(typeof(ICollection), true)] + [InlineData(typeof(IEnumerable), true)] + [InlineData(typeof(HashSet), true)] + [InlineData(typeof(Queue), true)] + [InlineData(typeof(Stack), true)] + [InlineData(typeof(ObservableCollection), true)] + [InlineData(typeof(ArrayList), true)] + public void IsArrayType_Should_Return_True_For_Collection_Types(Type type, bool expected) + { + // Act + var result = CollectionTypeHelper.IsArrayType(type); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(typeof(string), false)] + [InlineData(typeof(int), false)] + [InlineData(typeof(bool), false)] + [InlineData(typeof(DateTime), false)] + [InlineData(typeof(Dictionary), false)] + [InlineData(typeof(IDictionary), false)] + [InlineData(typeof(SortedDictionary), false)] + public void IsArrayType_Should_Return_False_For_Non_Collection_Types(Type type, bool expected) + { + // Act + var result = CollectionTypeHelper.IsArrayType(type); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(typeof(Dictionary), true)] + [InlineData(typeof(IDictionary), true)] + [InlineData(typeof(SortedDictionary), true)] + public void IsDictionaryType_Should_Return_True_For_Dictionary_Types(Type type, bool expected) + { + // Act + var result = CollectionTypeHelper.IsDictionaryType(type); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(typeof(string[]), false)] + [InlineData(typeof(List), false)] + [InlineData(typeof(string), false)] + [InlineData(typeof(int), false)] + public void IsDictionaryType_Should_Return_False_For_Non_Dictionary_Types(Type type, bool expected) + { + // Act + var result = CollectionTypeHelper.IsDictionaryType(type); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void IsArrayType_Should_Handle_Nullable_Types() + { + // Arrange + var nullableIntArray = typeof(int?[]); + + // Act + var result = CollectionTypeHelper.IsArrayType(nullableIntArray); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsArrayType_Should_Throw_For_Null_Type() + { + // Act & Assert + Assert.Throws(() => CollectionTypeHelper.IsArrayType(null!)); + } + + [Fact] + public void IsDictionaryType_Should_Throw_For_Null_Type() + { + // Act & Assert + Assert.Throws(() => CollectionTypeHelper.IsDictionaryType(null!)); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs new file mode 100644 index 0000000000..c717c91456 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs @@ -0,0 +1,114 @@ +using System.CommandLine; +using Azure.Mcp.Core.Areas.Group.Commands; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Helpers; + +public class CommandHelperTests +{ + [Fact] + public void GetSubscription_EmptySubscriptionParameter_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", ""]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_MissingSubscriptionParameter_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult([]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_ValidSubscriptionParameter_ReturnsParameterValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "param-subs"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("param-subs", actual); + } + + [Fact] + public void GetSubscription_ParameterValueContainingSubscription_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "Azure subscription 1"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_ParameterValueContainingDefault_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "Some default name"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_NoEnvironmentVariableParameterValueContainingDefault_ReturnsParameterValue() + { + // Arrange + var parseResult = GetParseResult(["--subscription", "Some default name"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("Some default name", actual); + } + + [Fact] + public void GetSubscription_NoEnvironmentVariableParameterValueContainingSubscription_ReturnsParameterValue() + { + // Arrange + var parseResult = GetParseResult(["--subscription", "Azure subscription 1"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("Azure subscription 1", actual); + } + + private static ParseResult GetParseResult(params string[] args) + { + var command = new GroupListCommand(Substitute.For>()); + var commandDefinition = command.GetCommand(); + return commandDefinition.Parse(args); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs new file mode 100644 index 0000000000..665e5b4b45 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Helpers; + +public class OptionParsingHelpersTests +{ + #region ParseKeyValuePairStringToDictionary Tests + + [Theory] + [InlineData("Content-Type=application/json", "Content-Type", "application/json")] + [InlineData("Authorization=Bearer token", "Authorization", "Bearer token")] + [InlineData("X-Custom-Header=custom-value", "X-Custom-Header", "custom-value")] + public void ParseKeyValuePairStringToDictionary_SingleHeader_ReturnsCorrectDictionary(string input, string expectedKey, string expectedValue) + { + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.True(result.ContainsKey(expectedKey)); + Assert.Equal(expectedValue, result[expectedKey]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_MultipleHeaders_ReturnsCorrectDictionary() + { + // Arrange + var input = "Content-Type=application/json,Authorization=Bearer token,X-Custom=value"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + Assert.Equal("value", result["X-Custom"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_HeadersWithSpaces_TrimsCorrectly() + { + // Arrange + var input = " Content-Type = application/json , Authorization = Bearer token "; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_EmptyHeaderValues_HandlesCorrectly() + { + // Arrange + var input = "Header1=,Header2=value"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("value", result["Header2"]); + // Header1 should be ignored because it has empty value after split + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_InvalidFormat_IgnoresInvalidEntries() + { + // Arrange + var input = "ValidHeader=value,InvalidHeaderNoEquals,AnotherValid=test"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("value", result["ValidHeader"]); + Assert.Equal("test", result["AnotherValid"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_MultipleEqualsInValue_HandlesCorrectly() + { + // Arrange + var input = "Content-Type=application/json,Query=param1=value1¶m2=value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("param1=value1¶m2=value2", result["Query"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_DuplicateHeaders_LastValueWins() + { + // Arrange + var input = "Content-Type=application/json,Content-Type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("application/xml", result["Content-Type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_TrailingCommas_HandlesCorrectly() + { + // Arrange + var input = "Content-Type=application/json,,Authorization=Bearer token,"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseKeyValuePairStringToDictionary_NullOrWhitespace_ThrowsArgumentException(string input) + { + // Act & Assert + Assert.Throws(() => OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_NullInput_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => OptionParsingHelpers.ParseKeyValuePairStringToDictionary(null!)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_HeaderWithSpecialCharacters_HandlesCorrectly() + { + // Arrange + var input = "X-Request-ID=abc-123-def,X-Custom-Header=value_with_underscores"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("abc-123-def", result["X-Request-ID"]); + Assert.Equal("value_with_underscores", result["X-Custom-Header"]); + } + + #endregion + + #region Custom Separator Tests + + [Theory] + [InlineData("Key1:Value1;Key2:Value2", ':', ';', 2)] + [InlineData("Name=John,Age=30", '=', ',', 2)] + [InlineData("a~b&c~d", '~', '&', 2)] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ParsesCorrectly(string input, char keyValueSeparator, char pairSeparator, int expectedCount) + { + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, keyValueSeparator, pairSeparator); + + // Assert + Assert.Equal(expectedCount, result.Count); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ColonAndSemicolon() + { + // Arrange + var input = "Header1:value1;Header2:value2;Header3:value3"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("value1", result["Header1"]); + Assert.Equal("value2", result["Header2"]); + Assert.Equal("value3", result["Header3"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_TabAndNewline() + { + // Arrange + var input = "Name\tJohn\nAge\t30"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, '\t', '\n'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("John", result["Name"]); + Assert.Equal("30", result["Age"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_WithSpaces() + { + // Arrange + var input = " Key1 : Value1 ; Key2 : Value2 "; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ValueContainsPairSeparator() + { + // Arrange + var input = "URL:https://example.com:8080;Port:8080"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("https://example.com:8080", result["URL"]); + Assert.Equal("8080", result["Port"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_SameSeparatorLimitation() + { + // Arrange - When key-value and pair separators are the same, parsing becomes ambiguous + var input = "Key1|Value1|Key2|Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, '|', '|'); + + // Assert - This demonstrates the limitation - only the last valid pair is captured + // because the first split creates: ["Key1", "Value1", "Key2", "Value2"] + // and only pairs with exactly 2 elements after key-value split are valid + Assert.True(result.Count <= 1); // May be 0 or 1 depending on parsing logic + } + + #endregion + + #region StringComparer Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_OrdinalIgnoreCase_CaseInsensitive() + { + // Arrange + var input = "Content-Type=application/json,content-type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.OrdinalIgnoreCase); + + // Assert + Assert.Single(result); + Assert.Equal("application/xml", result["Content-Type"]); + Assert.Equal("application/xml", result["content-type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_Ordinal_CaseSensitive() + { + // Arrange + var input = "Content-Type=application/json,content-type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("application/xml", result["content-type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CurrentCultureIgnoreCase_CaseInsensitive() + { + // Arrange + var input = "NAME=John,name=Jane"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.CurrentCultureIgnoreCase); + + // Assert + Assert.Single(result); + Assert.Equal("Jane", result["NAME"]); + Assert.Equal("Jane", result["name"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_DefaultComparer_IsOrdinalIgnoreCase() + { + // Arrange + var input = "Header=value1,HEADER=value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("value2", result["Header"]); + Assert.Equal("value2", result["HEADER"]); + } + + #endregion + + #region Combined Custom Separators and StringComparer Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparatorsWithStringComparer_CaseSensitive() + { + // Arrange + var input = "Key1:Value1;key1:Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["key1"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparatorsWithStringComparer_CaseInsensitive() + { + // Arrange + var input = "Key1:Value1;KEY1:Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.OrdinalIgnoreCase, ':', ';'); + + // Assert + Assert.Single(result); + Assert.Equal("Value2", result["Key1"]); + Assert.Equal("Value2", result["KEY1"]); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_NullStringComparer_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary("key=value", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseKeyValuePairStringToDictionary_WithStringComparer_NullOrWhitespace_ThrowsArgumentException(string input) + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_WithStringComparer_NullInput_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary(null!, StringComparer.Ordinal)); + } + + #endregion +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs new file mode 100644 index 0000000000..2d310cb4d4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.RegularExpressions; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Infrastructure; + +public class VersionSyncTests +{ + private const string GlobalJsonFileName = "global.json"; + private const string DockerfileFileName = "Dockerfile"; + private static readonly string _repoRoot = GetRepoRoot(); + + [Fact] + public void DotNet_Versions_Should_Be_Synchronized_Between_GlobalJson_And_Dockerfile() + { + // Arrange + var globalJsonPath = Path.Combine(_repoRoot, GlobalJsonFileName); + var dockerfilePath = Path.Combine(_repoRoot, DockerfileFileName); + + // Act + var globalJsonSdkVersion = GetDotNetSdkVersionFromGlobalJson(globalJsonPath); + var dockerfileRuntimeVersion = GetDotNetRuntimeVersionFromDockerfile(dockerfilePath); + + // Assert + Assert.True(File.Exists(globalJsonPath), $"{GlobalJsonFileName} not found at {globalJsonPath}"); + Assert.True(File.Exists(dockerfilePath), $"{DockerfileFileName} not found at {dockerfilePath}"); + Assert.NotNull(globalJsonSdkVersion); + Assert.NotNull(dockerfileRuntimeVersion); + + // can't use System.Version to parse Semantic Versions. Need to ensure Major.Minor versions are compatible _only_ + // the right thing to check here _isn't_ the SDK version, but the versions of the runtime that the app targets + var sdkVersion = ParseMajorMinorVersion(globalJsonSdkVersion); + var runtimeVersion = ParseMajorMinorVersion(dockerfileRuntimeVersion); + + Assert.True(sdkVersion >= runtimeVersion, + $"Major.Minor versions should be compatible between {GlobalJsonFileName} SDK ({sdkVersion}) and {DockerfileFileName} runtime ({runtimeVersion}). " + + $"Found SDK: {sdkVersion.Major}.{sdkVersion.Minor}, Runtime: {runtimeVersion.Major}.{runtimeVersion.Minor}"); + } + + private static Version ParseMajorMinorVersion(string semverString) + { + var parts = semverString.Split('.'); + if (parts.Length < 2) + { + throw new ArgumentException($"Invalid version format: {semverString}. Expected at least Major.Minor format."); + } + + if (!int.TryParse(parts[0], out var major) || !int.TryParse(parts[1], out var minor)) + { + throw new ArgumentException($"Invalid version format: {semverString}. Major and Minor must be valid integers."); + } + + return new Version(major, minor); + } + + private static string GetDotNetSdkVersionFromGlobalJson(string globalJsonPath) + { + var jsonContent = File.ReadAllText(globalJsonPath); + + var document = JsonDocument.Parse( + jsonContent, + new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + } + ); + + return document.RootElement + .GetProperty("sdk") + .GetProperty("version") + .GetString() ?? throw new InvalidOperationException($"SDK version not found in {GlobalJsonFileName}"); + } + + private static string GetDotNetRuntimeVersionFromDockerfile(string dockerfilePath) + { + var dockerfileContent = File.ReadAllText(dockerfilePath); + + // Look for patterns like: + // - FROM mcr.microsoft.com/dotnet/aspnet:9.0.5-bookworm-slim + // - FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine + var pattern = @"FROM\s+mcr\.microsoft\.com/dotnet/aspnet:(\d+\.\d+(\.\d+)?)"; + var match = Regex.Match(dockerfileContent, pattern); + + if (match.Success) + { + return match.Groups[1].Value; + } + + throw new InvalidOperationException($"Could not find .NET runtime version in {DockerfileFileName} at {dockerfilePath}"); + } + + private static string GetRepoRoot() + { + var currentDir = Directory.GetCurrentDirectory(); + var dir = new DirectoryInfo(currentDir); + + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "global.json")) && File.Exists(Path.Combine(dir.FullName, "Dockerfile"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not find repository root containing global.json and Dockerfile"); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Options/RetryPolicyOptionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Options/RetryPolicyOptionsTests.cs new file mode 100644 index 0000000000..727cde3997 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Options/RetryPolicyOptionsTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Options; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Options; + +public class RetryPolicyOptionsTests +{ + [Fact] + public void TestAreEqual_WithFlagsSet() + { + var policy1 = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); + var policy2 = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); + var policyDifferentValue = GetPolicy(123, RetryMode.Exponential, 1, 5, 30); + + Assert.True(RetryPolicyOptions.AreEqual(policy1, policy1)); + Assert.True(RetryPolicyOptions.AreEqual(policy1, policy2)); + Assert.True(RetryPolicyOptions.AreEqual(null, null)); + Assert.False(RetryPolicyOptions.AreEqual(policy1, null)); + Assert.False(RetryPolicyOptions.AreEqual(null, policy1)); + Assert.False(RetryPolicyOptions.AreEqual(policy1, policyDifferentValue)); + } + + [Fact] + public void TestInequality_WithFlagsSet() + { + Assert.True(GetPolicy(3, RetryMode.Exponential, 1, 5, 30) != GetPolicy(999, RetryMode.Exponential, 1, 5, 30)); + Assert.True(GetPolicy(3, RetryMode.Exponential, 1, 5, 30) != GetPolicy(3, RetryMode.Fixed, 1, 5, 30)); + Assert.True(GetPolicy(3, RetryMode.Exponential, 1, 5, 30) != GetPolicy(3, RetryMode.Exponential, 999, 5, 30)); + Assert.True(GetPolicy(3, RetryMode.Exponential, 1, 5, 30) != GetPolicy(3, RetryMode.Exponential, 1, 999, 30)); + Assert.True(GetPolicy(3, RetryMode.Exponential, 1, 5, 30) != GetPolicy(3, RetryMode.Exponential, 1, 5, 999)); + } + + [Fact] + public void TestEqualityOperators_WithFlagsSet() + { + var policy1 = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); + var policy2 = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); + Assert.True(policy1 == policy2); + Assert.False(policy1 != policy2); + } + + [Fact] + public void Policies_With_DifferentValues_But_UnsetFlags_AreEqual() + { + // Create two policies differing in values but with no flags set (simulate deserialization or manual construction without flags) + var p1 = new RetryPolicyOptions { MaxRetries = 3, Mode = RetryMode.Exponential, DelaySeconds = 1, MaxDelaySeconds = 5, NetworkTimeoutSeconds = 30 }; + var p2 = new RetryPolicyOptions { MaxRetries = 999, Mode = RetryMode.Fixed, DelaySeconds = 10, MaxDelaySeconds = 50, NetworkTimeoutSeconds = 0 }; + // Since no Has* flags are set, differences are ignored. + Assert.True(RetryPolicyOptions.AreEqual(p1, p2)); + Assert.True(p1 == p2); + } + + [Fact] + public void Policies_With_Mismatched_Flags_NotEqual() + { + var pSpecified = GetPolicy(3, RetryMode.Exponential, 1, 5, 30); // flags set + var pUnspecified = new RetryPolicyOptions { MaxRetries = 3, Mode = RetryMode.Exponential, DelaySeconds = 1, MaxDelaySeconds = 5, NetworkTimeoutSeconds = 30 }; // flags unset + Assert.False(RetryPolicyOptions.AreEqual(pSpecified, pUnspecified)); + Assert.True(pSpecified != pUnspecified); + } + + private static RetryPolicyOptions GetPolicy(int maxRetries, RetryMode mode, double delay, double maxDelay, double timeout) + { + return new RetryPolicyOptions + { + MaxRetries = maxRetries, + Mode = mode, + DelaySeconds = delay, + MaxDelaySeconds = maxDelay, + NetworkTimeoutSeconds = timeout, + HasMaxRetries = true, + HasMode = true, + HasDelaySeconds = true, + HasMaxDelaySeconds = true, + HasNetworkTimeoutSeconds = true + }; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs new file mode 100644 index 0000000000..a09d8f470c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Azure.Authentication; + +/// +/// Tests for CustomChainedCredential configuration behavior. +/// These tests verify that credentials are created correctly based on environment variable settings. +/// Note: These tests verify creation behavior only. Actual authentication behavior requires live credentials. +/// +public class CustomChainedCredentialTests +{ + /// + /// Tests that default behavior (no AZURE_TOKEN_CREDENTIALS set) creates a credential successfully. + /// Expected: Uses default credential chain with InteractiveBrowserCredential fallback. + /// + [Fact] + public void DefaultBehavior_CreatesCredentialSuccessfully() + { + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that dev mode (AZURE_TOKEN_CREDENTIALS="dev") creates a credential successfully. + /// Expected: Uses development credentials with InteractiveBrowserCredential fallback. + /// + [Fact] + public void DevMode_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "dev"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that prod mode (AZURE_TOKEN_CREDENTIALS="prod") creates a credential successfully. + /// Expected: Uses production credentials (EnvironmentCredential, WorkloadIdentityCredential, ManagedIdentityCredential) + /// WITHOUT InteractiveBrowserCredential fallback. + /// + [Fact] + public void ProdMode_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that specific credential (AZURE_TOKEN_CREDENTIALS="ManagedIdentityCredential") creates successfully. + /// Expected: Uses ONLY ManagedIdentityCredential without InteractiveBrowserCredential fallback. + /// + [Fact] + public void SpecificCredential_ManagedIdentity_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that specific credential (AZURE_TOKEN_CREDENTIALS="AzureCliCredential") creates successfully. + /// Expected: Uses ONLY AzureCliCredential without InteractiveBrowserCredential fallback. + /// + [Fact] + public void SpecificCredential_AzureCli_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "AzureCliCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that explicit InteractiveBrowserCredential request creates successfully. + /// Expected: Uses InteractiveBrowserCredential when explicitly requested. + /// + [Fact] + public void SpecificCredential_InteractiveBrowser_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "InteractiveBrowserCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests all supported specific credential types create successfully. + /// Expected: Each credential type creates without errors. + /// + [Theory] + [InlineData("EnvironmentCredential")] + [InlineData("WorkloadIdentityCredential")] + [InlineData("VisualStudioCredential")] + [InlineData("VisualStudioCodeCredential")] + [InlineData("AzurePowerShellCredential")] + [InlineData("AzureDeveloperCliCredential")] + public void SpecificCredential_VariousTypes_CreateCredentialSuccessfully(string credentialType) + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", credentialType); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that User-Assigned Managed Identity (AZURE_CLIENT_ID set) creates successfully. + /// Expected: ManagedIdentityCredential is configured with the specified clientId. + /// + [Fact] + public void ManagedIdentityCredential_WithClientId_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + Environment.SetEnvironmentVariable("AZURE_CLIENT_ID", "12345678-1234-1234-1234-123456789012"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that System-Assigned Managed Identity (no AZURE_CLIENT_ID) creates successfully. + /// Expected: ManagedIdentityCredential is configured for system-assigned identity. + /// + [Fact] + public void ManagedIdentityCredential_WithoutClientId_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that "only broker credential" mode creates InteractiveBrowserCredential successfully. + /// Expected: Uses only InteractiveBrowserCredential with broker support. + /// + [Fact] + public void OnlyUseBrokerCredential_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL", "true"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that VS Code context without explicit setting creates credential successfully. + /// Expected: When VSCODE_PID is set and AZURE_TOKEN_CREDENTIALS is not set, + /// prioritizes VS Code credential in the chain. + /// + [Fact] + public void VSCodeContext_WithoutExplicitSetting_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that VS Code context with explicit prod setting respects the explicit setting. + /// Expected: When both VSCODE_PID and AZURE_TOKEN_CREDENTIALS are set, + /// AZURE_TOKEN_CREDENTIALS takes precedence. + /// + [Fact] + public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Helper method to create CustomChainedCredential using reflection since it's an internal class. + /// + private static TokenCredential CreateCustomChainedCredential() + { + var assembly = typeof(global::Azure.Mcp.Core.Services.Azure.Authentication.IAzureTokenCredentialProvider).Assembly; + var customChainedCredentialType = assembly.GetType("Azure.Mcp.Core.Services.Azure.Authentication.CustomChainedCredential"); + + Assert.NotNull(customChainedCredentialType); + + var constructor = customChainedCredentialType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(c => + { + var parameters = c.GetParameters(); + return parameters.Length == 2 && + parameters[0].ParameterType == typeof(string) && + parameters[1].ParameterType == typeof(ILogger<>).MakeGenericType(customChainedCredentialType); + }); + + Assert.NotNull(constructor); + + var credential = constructor.Invoke([null, null]) as TokenCredential; + Assert.NotNull(credential); + + return credential; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs new file mode 100644 index 0000000000..b9d13ab9a1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.ResourceManager; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Azure; + +public class BaseAzureServiceTests +{ + private const string TenantId = "test-tenant-id"; + private const string TenantName = "test-tenant-name"; + + private readonly ITenantService _tenantService = Substitute.For(); + private readonly TestAzureService _azureService; + + public BaseAzureServiceTests() + { + _azureService = new TestAzureService(_tenantService); + _tenantService.GetTenantId(TenantName, Arg.Any()).Returns(TenantId); + _tenantService.GetTokenCredentialAsync( + Arg.Any(), + Arg.Any()) + .Returns(Substitute.For()); + _tenantService.GetClient().Returns(_ => new HttpClient(new HttpClientHandler())); + } + + [Fact] + public async Task CreateArmClientAsync_DoesNotReuseClient() + { + // Act + var tenantName2 = "Other-Tenant-Name"; + var tenantId2 = "Other-Tenant-Id"; + + _tenantService.GetTenantId(tenantName2, Arg.Any()).Returns(tenantId2); + + var retryPolicyArgs = new RetryPolicyOptions + { + DelaySeconds = 5, + MaxDelaySeconds = 15, + MaxRetries = 3 + }; + + var client = await _azureService.GetArmClientAsync(TenantName, retryPolicyArgs); + var client2 = await _azureService.GetArmClientAsync(TenantName, retryPolicyArgs); + + Assert.NotEqual(client, client2); + + var otherClient = await _azureService.GetArmClientAsync(tenantName2, retryPolicyArgs); + + Assert.NotEqual(client, otherClient); + + // Not tested: we'd like to, but can't, verify the TokenCredential is reused + // between client and client2 but NOT with otherClient. ArmClient doesn't expose + // the credential nor the HttpPipeline the credential is included within. + } + + [Fact] + public async Task ResolveTenantIdAsync_ReturnsNullOnNull() + { + string? actual = await _azureService.ResolveTenantId(null, TestContext.Current.CancellationToken); + Assert.Null(actual); + } + + [Fact] + public void EscapeKqlString_EscapesSingleQuotes() + { + // Arrange + var input = "resource'with'quotes"; + var expected = "resource''with''quotes"; + + // Act + var result = _azureService.EscapeKqlStringTest(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeKqlString_EscapesBackslashes() + { + // Arrange + var input = @"resource\with\backslashes"; + var expected = @"resource\\with\\backslashes"; + + // Act + var result = _azureService.EscapeKqlStringTest(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeKqlString_EscapesBothQuotesAndBackslashes() + { + // Arrange + var input = @"resource\'with\'mixed"; + var expected = @"resource\\''with\\''mixed"; + + // Act + var result = _azureService.EscapeKqlStringTest(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeKqlString_HandlesNullAndEmptyStrings() + { + // Act & Assert + Assert.Equal(string.Empty, _azureService.EscapeKqlStringTest(null!)); + Assert.Equal(string.Empty, _azureService.EscapeKqlStringTest(string.Empty)); + } + + [Fact] + public void EscapeKqlString_HandlesRegularStringsWithoutEscaping() + { + // Arrange + var input = "regular-resource-name"; + + // Act + var result = _azureService.EscapeKqlStringTest(input); + + // Assert + Assert.Equal(input, result); + } + + [Fact] + public void InitializeUserAgentPolicy_UserAgentContainsTransportType() + { + // Initialize the user agent policy before creating test service + BaseAzureService.InitializeUserAgentPolicy(TransportTypes.StdIo); + TestAzureService testAzureService = new TestAzureService(_tenantService); + Assert.NotNull(testAzureService.GetUserAgent()); + Assert.Contains("azmcp-stdio", testAzureService.GetUserAgent()); + } + + [Fact] + public void InitializeUserAgentPolicy_ThrowsExceptionWhenTransportTypeIsNull() + { + var exception = Assert.Throws(() => BaseAzureService.InitializeUserAgentPolicy(null!)); + Assert.Equal("Value cannot be null. (Parameter 'transportType')", exception.Message); + } + + [Fact] + public void InitializeUserAgentPolicy_ThrowsExceptionWhenTransportTypeIsEmpty() + { + var exception = Assert.Throws(() => BaseAzureService.InitializeUserAgentPolicy(string.Empty)); + Assert.Equal("The value cannot be an empty string or composed entirely of whitespace. (Parameter 'transportType')", exception.Message); + } + + private sealed class TestAzureService(ITenantService tenantService) : BaseAzureService(tenantService) + { + public Task GetArmClientAsync(string? tenant = null, RetryPolicyOptions? retryPolicy = null) => + CreateArmClientAsync(tenant, retryPolicy); + + // Expose the protected ResolveTenantIdAsync method for testing + public Task ResolveTenantId(string? tenant, CancellationToken cancellationToken) => ResolveTenantIdAsync(tenant, cancellationToken); + + public string EscapeKqlStringTest(string value) => EscapeKqlString(value); + + public string GetUserAgent() => UserAgent; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/UserAgentPolicyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/UserAgentPolicyTests.cs new file mode 100644 index 0000000000..a08430574b --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/UserAgentPolicyTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Azure; + +public class UserAgentPolicyTests +{ + [Fact] + public void Constructor_SetsUserAgent() + { + // Arrange + const string ExpectedUserAgent = "TestUserAgent/1.0"; + + // Act + var policy = new UserAgentPolicy(ExpectedUserAgent); + + // Assert - Implicit test that constructor doesn't throw + Assert.NotNull(policy); + } + + [Fact] + public void Constructor_ThrowsEmptyUserAgent() + { + Assert.Throws(() => new UserAgentPolicy("")); + } + + [Fact] + public void OnSendingRequest_SetsUserAgentHeader() + { + // Arrange + const string ExpectedUserAgent = "TestUserAgent/1.0"; + var policy = new UserAgentPolicy(ExpectedUserAgent); + var request = new MockHttpRequest(); + var message = new HttpMessage(request, new MockResponseClassifier()); + + // Act + policy.OnSendingRequest(message); + + // Assert + + var headers = request.Headers; + + Assert.Single(headers); + + var actual = headers.Single(); + Assert.Equal(UserAgentPolicy.UserAgentHeader, actual.Name); + Assert.Equal(ExpectedUserAgent, actual.Value); + } + + [Fact] + public void OnSendingRequest_CallsBaseMethod() + { + // Arrange + const string UserAgent = "TestUserAgent/1.0"; + var derivedPolicy = new TestableUserAgentPolicy(UserAgent); + var request = new MockHttpRequest(); + var message = new HttpMessage(request, new MockResponseClassifier()); + + // Act + derivedPolicy.OnSendingRequest(message); + + // Assert + Assert.True(derivedPolicy.BaseOnSendingRequestCalled); + } + + // Helper class for testing the base method call + private class TestableUserAgentPolicy(string userAgent) : UserAgentPolicy(userAgent) + { + public bool BaseOnSendingRequestCalled { get; private set; } + + public override void OnSendingRequest(HttpMessage message) + { + base.OnSendingRequest(message); + BaseOnSendingRequestCalled = true; + } + } + + // Mock response classifier for creating HttpMessage instances + private class MockResponseClassifier : ResponseClassifier + { + public override bool IsErrorResponse(HttpMessage message) + { + return false; + } + } + + private class MockHttpRequest : Request + { + public override string ClientRequestId { get; set; } = "Test-Request-Id"; + + public List ReceivedHeaders { get; } = []; + + public override void Dispose() + { + } + + protected override void AddHeader(string name, string value) => ReceivedHeaders.Add(new HttpHeader(name, value)); + + protected override bool ContainsHeader(string name) => ReceivedHeaders.Any(x => x.Name == name); + + protected override IEnumerable EnumerateHeaders() + { + return ReceivedHeaders; + } + + protected override bool RemoveHeader(string name) + { + return false; + } + + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) + { + value = ""; + return false; + + } + + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) + { + values = null; + return false; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs new file mode 100644 index 0000000000..2aebac1721 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Caching; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Caching; + +public class CacheServiceTests +{ + private readonly ICacheService _cacheService; + private readonly IMemoryCache _memoryCache; + + public CacheServiceTests() + { + _memoryCache = new MemoryCache(Microsoft.Extensions.Options.Options.Create(new MemoryCacheOptions())); + _cacheService = new SingleUserCliCacheService(_memoryCache); + } + + [Fact] + public async Task SetAndGet_WithoutGroup_ShouldWorkAsExpected() + { + // Arrange + string group = "test-group"; + string key = "test-key"; + string value = "test-value"; + + // Clear any existing cache data + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + // Act + await _cacheService.SetAsync(group, key, value, cancellationToken: TestContext.Current.CancellationToken); + var result = await _cacheService.GetAsync(group, key, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(value, result); + } + + [Fact] + public async Task SetAndGet_WithGroup_ShouldWorkAsExpected() + { + // Arrange + string group = "test-group"; + string key = "test-key"; + string value = "test-value"; + + // Clear any existing cache data + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + // Act + await _cacheService.SetAsync(group, key, value, cancellationToken: TestContext.Current.CancellationToken); + var result = await _cacheService.GetAsync(group, key, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(value, result); + } + + [Fact] + public async Task GetGroupKeysAsync_ShouldReturnKeysInGroup() + { + // Arrange + string group = "test-group"; + string key1 = "test-key1"; + string key2 = "test-key2"; + string value1 = "test-value1"; + string value2 = "test-value2"; + + // Clear any existing cache data + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + // Act + await _cacheService.SetAsync(group, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + var groupKeys = await _cacheService.GetGroupKeysAsync(group, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, groupKeys.Count()); + Assert.Contains(key1, groupKeys); + Assert.Contains(key2, groupKeys); + } + + [Fact] + public async Task DeleteAsync_WithGroup_ShouldRemoveKeyFromGroup() + { + // Arrange + string group = "test-group"; + string key1 = "test-key1"; + string key2 = "test-key2"; + string value1 = "test-value1"; + string value2 = "test-value2"; + + // Clear any existing cache data + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + // Act + await _cacheService.SetAsync(group, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.DeleteAsync(group, key1, TestContext.Current.CancellationToken); + + var groupKeys = await _cacheService.GetGroupKeysAsync(group, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group, key2, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Single(groupKeys); + Assert.Contains(key2, groupKeys); + Assert.Null(result1); + Assert.Equal(value2, result2); + } + [Fact] + public async Task ClearAsync_ShouldRemoveAllCachedItems() + { + // Arrange + string group1 = "test-group1"; + string group2 = "test-group2"; + string key1 = "test-key1"; + string key2 = "test-key2"; + string value1 = "test-value1"; + string value2 = "test-value2"; + + // Clear any existing cache data first + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + await _cacheService.SetAsync(group1, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group2, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + + // Act + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + // Assert + var group1Keys = await _cacheService.GetGroupKeysAsync(group1, TestContext.Current.CancellationToken); + var group2Keys = await _cacheService.GetGroupKeysAsync(group2, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group1, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group2, key2, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(group1Keys); + Assert.Empty(group2Keys); + Assert.Null(result1); + Assert.Null(result2); + } + + [Fact] + public async Task ClearGroupAsync_ShouldRemoveOnlySpecificGroup() + { + // Arrange + string group1 = "test-group1"; + string group2 = "test-group2"; + string key1 = "test-key1"; + string key2 = "test-key2"; + string value1 = "test-value1"; + string value2 = "test-value2"; + + // Clear any existing cache data first + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); + + await _cacheService.SetAsync(group1, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group2, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + + // Act + await _cacheService.ClearGroupAsync(group1, TestContext.Current.CancellationToken); + + // Assert + var group1Keys = await _cacheService.GetGroupKeysAsync(group1, TestContext.Current.CancellationToken); + var group2Keys = await _cacheService.GetGroupKeysAsync(group2, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group1, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group2, key2, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(group1Keys); + Assert.Single(group2Keys); + Assert.Null(result1); + Assert.Equal(value2, result2); + Assert.Equal(value2, result2); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceIntegrationTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceIntegrationTests.cs new file mode 100644 index 0000000000..e6480d1704 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceIntegrationTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Services.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Http; + +/// +/// Integration tests demonstrating the HttpClient service functionality +/// +public class HttpClientServiceIntegrationTests +{ + [Fact] + public void FullIntegration_WithProxyEnvironmentVariables_ConfiguresCorrectly() + { + // Arrange + Environment.SetEnvironmentVariable("ALL_PROXY", "http://proxy.example.com:8080"); + Environment.SetEnvironmentVariable("NO_PROXY", "localhost,127.0.0.1,*.local"); + + try + { + var services = new ServiceCollection(); + + // Act - Register services with environment variable configuration + services.AddHttpClientServices(options => + { + options.DefaultUserAgent = "Azure MCP Test Agent"; + options.DefaultTimeout = TimeSpan.FromSeconds(30); + }); + + var serviceProvider = services.BuildServiceProvider(); + var httpClientService = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetRequiredService>(); + + // Assert - Verify configuration + Assert.Equal("http://proxy.example.com:8080", options.Value.AllProxy); + Assert.Equal("localhost,127.0.0.1,*.local", options.Value.NoProxy); + Assert.Equal("Azure MCP Test Agent", options.Value.DefaultUserAgent); + Assert.Equal(TimeSpan.FromSeconds(30), options.Value.DefaultTimeout); + + // Verify client creation + using var defaultClient = httpClientService.DefaultClient; + using var customClient = httpClientService.CreateClient(new Uri("https://management.azure.com")); + + Assert.NotNull(defaultClient); + Assert.NotNull(customClient); + Assert.Equal(new Uri("https://management.azure.com"), customClient.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(30), defaultClient.Timeout); + } + finally + { + // Clean up environment variables + Environment.SetEnvironmentVariable("ALL_PROXY", null); + Environment.SetEnvironmentVariable("NO_PROXY", null); + } + } + + [Fact] + public void ServiceRegistration_InAzureMcpContext_WorksCorrectly() + { + // This demonstrates how the service would be used in the actual Azure MCP context + // Arrange + var services = new ServiceCollection(); + + // Act - This simulates what happens in the real application + services.AddHttpClientServices(); + + // Simulate registering a service that depends on IHttpClientService + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var testService = serviceProvider.GetRequiredService(); + Assert.NotNull(testService); + + var httpClient = testService.GetTestHttpClient(); + Assert.NotNull(httpClient); + } + + private class TestServiceUsingHttpClient(IHttpClientService httpClientService) + { + private readonly IHttpClientService _httpClientService = httpClientService; + + public HttpClient GetTestHttpClient() => _httpClientService.DefaultClient; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs new file mode 100644 index 0000000000..d5ed9f40e1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Http; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Http; + +public class HttpClientServiceTests +{ + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new HttpClientService(null!, null!)); + } + + [Fact] + public void DefaultClient_ReturnsConfiguredHttpClient() + { + // Arrange + var options = new HttpClientOptions + { + DefaultTimeout = TimeSpan.FromSeconds(30), + }; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + using var service = new HttpClientService(optionsWrapper, null!); + + // Act + var client = service.DefaultClient; + + // Assert + Assert.NotNull(client); + Assert.Equal(TimeSpan.FromSeconds(30), client.Timeout); + } + + [Fact] + public void CreateClient_WithBaseAddress_ReturnsClientWithBaseAddress() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + using var service = new HttpClientService(optionsWrapper, null!); + var baseAddress = new Uri("https://example.com"); + + // Act + using var client = service.CreateClient(baseAddress); + + // Assert + Assert.NotNull(client); + Assert.Equal(baseAddress, client.BaseAddress); + } + + [Fact] + public void CreateClient_WithConfigureAction_AppliesConfiguration() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + using var service = new HttpClientService(optionsWrapper, null!); + var baseAddress = new Uri("https://example.com"); + + // Act + using var client = service.CreateClient(baseAddress, c => + { + c.DefaultRequestHeaders.Add("Custom-Header", "CustomValue"); + }); + + // Assert + Assert.NotNull(client); + Assert.Equal(baseAddress, client.BaseAddress); + Assert.True(client.DefaultRequestHeaders.Contains("Custom-Header")); + } + + [Fact] + public void CreateClient_WithProxyConfiguration_CreatesProxyEnabledClient() + { + // Arrange + var options = new HttpClientOptions + { + AllProxy = "http://proxy.example.com:8080", + NoProxy = "localhost,127.0.0.1" + }; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + using var service = new HttpClientService(optionsWrapper, null!); + + // Act + using var client = service.CreateClient(); + + // Assert + Assert.NotNull(client); + // Note: We can't easily test the proxy configuration without reflection + // or making the handler accessible, but this verifies the client is created + } + + [Fact] + public void Dispose_DisposesDefaultClient() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var service = new HttpClientService(optionsWrapper, null!); + var client = service.DefaultClient; // Force creation + + // Act + service.Dispose(); + + // Assert + Assert.Throws(() => service.CreateClient()); + } + + [Fact] + public void UserAgent_IsSetCorrectly() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + var client = service.DefaultClient; + + // Act + var userAgent = client.DefaultRequestHeaders.UserAgent; + + // Assert + Assert.Contains("azmcp-http/", userAgent.ToString()); + } + + [Fact] + public void UserAgent_UserAgentFromHttpClientOptionsIsIgnored() + { + // Arrange + var options = new HttpClientOptions(); + options.DefaultUserAgent = "CustomAgent/1.0"; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + var client = service.DefaultClient; + + // Act + var userAgent = client.DefaultRequestHeaders.UserAgent; + + // Assert + Assert.Contains("azmcp-http/", userAgent.ToString()); + } + +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/DefaultMachineInformationProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/DefaultMachineInformationProviderTests.cs new file mode 100644 index 0000000000..7fc229961f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/DefaultMachineInformationProviderTests.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; + +public class DefaultMachineInformationProviderTests +{ + [Fact] + public async Task ReturnsNullDeviceId() + { + var logger = Substitute.For>(); + var provider = new DefaultMachineInformationProvider(logger); + + var result = await provider.GetOrCreateDeviceId(); + + Assert.Null(result); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/MachineInformationProviderBaseTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/MachineInformationProviderBaseTests.cs new file mode 100644 index 0000000000..071712bedf --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/MachineInformationProviderBaseTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; + +public class MachineInformationProviderBaseTests +{ + private readonly ILogger _logger; + private readonly TestMachineInformationProvider _provider; + + public MachineInformationProviderBaseTests() + { + _logger = Substitute.For>(); + _provider = new TestMachineInformationProvider(_logger); + } + + [Fact] + public async Task GetMacAddressHash_WhenMacAddressExists_ReturnsHashedValue() + { + // Act + var result = await _provider.GetMacAddressHash(); + + // Assert + Assert.NotNull(result); + Assert.NotEqual("N/A", result); + // Should be a valid SHA-256 hash (64 hex characters) + Assert.Matches("^[a-f0-9]{64}$", result); + } + + [Fact] + public async Task GetMacAddressHash_WhenNoMacAddressFound_ReturnsNotAvailable() + { + // This test is challenging since we can't easily mock NetworkInterface.GetAllNetworkInterfaces() + // In a real scenario, you might want to refactor the code to make it more testable + // For now, we'll test the happy path and error handling + + // Act + var result = await _provider.GetMacAddressHash(); + + // Assert + Assert.NotNull(result); + // Result should either be a hash or "N/A" + Assert.True(result == "N/A" || result.Length == 64); + } + + [Fact] + public void GetMacAddress_ReturnsValidMacAddressOrNull() + { + // Act + var result = _provider.GetMacAddress(); + + // Assert + // Should either return null or a non-empty string + if (result != null) + { + Assert.NotEmpty(result); + } + } + + [Fact] + public void GenerateDeviceId_ReturnsValidGuidFormat() + { + // Act + var result = _provider.GenerateDeviceId(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Should be in format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + Assert.Matches(@"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", result); + + // Should be lowercase + Assert.Equal(result.ToLowerInvariant(), result); + + // Should be parsable as a GUID + Assert.True(Guid.TryParse(result, out _)); + } + + [Fact] + public void GenerateDeviceId_GeneratesUniqueIds() + { + // Act + var id1 = _provider.GenerateDeviceId(); + var id2 = _provider.GenerateDeviceId(); + + // Assert + Assert.NotEqual(id1, id2); + } + + [Theory] + [InlineData("test")] + [InlineData("")] + [InlineData("ABC123")] + [InlineData("special-characters!@#$%")] + [InlineData("unicode-測試")] + public void HashValue_WithVariousInputs_ReturnsValidSha256Hash(string input) + { + // Act + var result = _provider.HashValue(input); + + // Assert + Assert.NotNull(result); + Assert.Equal(64, result.Length); // SHA-256 produces 64 hex characters + Assert.Matches("^[a-f0-9]+$", result); // Should only contain lowercase hex + + // Verify it's actually the correct SHA-256 hash + var expectedHash = ComputeExpectedHash(input); + Assert.Equal(expectedHash, result); + } + + [Fact] + public void HashValue_SameInput_ReturnsSameHash() + { + // Arrange + const string input = "test-value"; + + // Act + var hash1 = _provider.HashValue(input); + var hash2 = _provider.HashValue(input); + + // Assert + Assert.Equal(hash1, hash2); + } + + [Fact] + public void HashValue_DifferentInputs_ReturnsDifferentHashes() + { + // Act + var hash1 = _provider.HashValue("input1"); + var hash2 = _provider.HashValue("input2"); + + // Assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public void Constants_HaveExpectedValues() + { + // Use reflection to access protected constants through the test class + var type = typeof(TestMachineInformationProvider); + + // These constants should be accessible through the base class + // We can verify them through the actual usage in the class + + // Test that NotAvailable constant is used correctly + var notAvailableField = type.BaseType!.GetField("NotAvailable", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.Equal("N/A", notAvailableField?.GetValue(null)); + } + + /// + /// Test class that allows us to simulate exception scenarios + /// + private class ExceptionThrowingProvider : MachineInformationProviderBase + { + public ExceptionThrowingProvider(ILogger logger) : base(logger) + { + } + + public override Task GetOrCreateDeviceId() + { + return Task.FromResult("test-device-id"); + } + + // Override to throw exception for testing error handling + protected override string? GetMacAddress() + { + throw new InvalidOperationException("Simulated network error"); + } + + // Expose protected method + public new Task GetMacAddressHash() => base.GetMacAddressHash(); + } + + [Fact] + public async Task GetMacAddressHash_WhenExceptionThrown_ReturnsNotAvailableAndLogsError() + { + // Arrange + var exceptionProvider = new ExceptionThrowingProvider(_logger); + + // Act + var result = await exceptionProvider.GetMacAddressHash(); + + // Assert + Assert.Equal("N/A", result); + + // Verify that an error was logged + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains("Unable to calculate MAC address hash")), + Arg.Any(), + Arg.Any>()); + } + + /// + /// Helper method to compute expected SHA-256 hash for comparison + /// + private static string ComputeExpectedHash(string input) + { + using var sha256 = SHA256.Create(); + var hashInput = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); + return BitConverter.ToString(hashInput).Replace("-", string.Empty).ToLowerInvariant(); + } +} + +/// +/// Test implementation of the abstract MachineInformationProviderBase class +/// +internal class TestMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + public override Task GetOrCreateDeviceId() + { + // Default implementation for testing - can be overridden in specific tests + return Task.FromResult("test-device-id"); + } + + public new string? GetMacAddress() => base.GetMacAddress(); + public new string GenerateDeviceId() => base.GenerateDeviceId(); + public new string HashValue(string value) => base.HashValue(value); +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryLogRecordEraserTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryLogRecordEraserTests.cs new file mode 100644 index 0000000000..f48578ba2d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryLogRecordEraserTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Telemetry; +using OpenTelemetry.Logs; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; + +public class TelemetryLogRecordEraserTests +{ + private readonly TelemetryLogRecordEraser _processor; + + public TelemetryLogRecordEraserTests() + { + _processor = new TelemetryLogRecordEraser(); + } + + [Fact] + public void OnEnd_ClearsAttributes_WhenLogRecordHasAttributes() + { + // Arrange + var logRecord = CreateLogRecord(); + var originalAttributes = new List> + { + new("key1", "value1"), + new("key2", 123), + new("sensitive", "secret-data") + }; + logRecord.Attributes = originalAttributes; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.NotNull(logRecord.Attributes); + Assert.Empty(logRecord.Attributes); + } + + [Fact] + public void OnEnd_ClearsBody_WhenLogRecordHasBody() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Body = "This is sensitive log body content"; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.Equal(string.Empty, logRecord.Body); + } + + [Fact] + public void OnEnd_ClearsFormattedMessage_WhenLogRecordHasFormattedMessage() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.FormattedMessage = "User {userId} performed action {action}"; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.Equal(string.Empty, logRecord.FormattedMessage); + } + + [Fact] + public void OnEnd_ClearsAllFields_WhenLogRecordHasAllData() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Attributes = new List> + { + new("userId", "12345"), + new("action", "login") + }; + logRecord.Body = "User login attempt"; + logRecord.FormattedMessage = "User 12345 attempted login"; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.NotNull(logRecord.Attributes); + Assert.Empty(logRecord.Attributes); + Assert.Equal(string.Empty, logRecord.Body); + Assert.Equal(string.Empty, logRecord.FormattedMessage); + } + + [Fact] + public async Task OnEnd_HandlesNullAttributes_Gracefully() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Attributes = null; + + // Act + var exception = await Record.ExceptionAsync(() => Task.Run( + () => _processor.OnEnd(logRecord), TestContext.Current.CancellationToken)); + + // Assert + Assert.Null(exception); + Assert.NotNull(logRecord.Attributes); + Assert.Empty(logRecord.Attributes); + } + + [Fact] + public void OnEnd_HandlesEmptyAttributes_Correctly() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Attributes = new List>(); + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.NotNull(logRecord.Attributes); + Assert.Empty(logRecord.Attributes); + } + + [Fact] + public async Task OnEnd_HandlesNullBody_Gracefully() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Body = null; + + // Act + var exception = await Record.ExceptionAsync(() => Task.Run( + () => _processor.OnEnd(logRecord), TestContext.Current.CancellationToken)); + + // Assert + Assert.Null(exception); + Assert.Equal(string.Empty, logRecord.Body); + } + + [Fact] + public async Task OnEnd_HandlesNullFormattedMessage_Gracefully() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.FormattedMessage = null; + + // Act + var exception = await Record.ExceptionAsync(() => Task.Run( + () => _processor.OnEnd(logRecord), TestContext.Current.CancellationToken)); + + // Assert + Assert.Null(exception); + Assert.Equal(string.Empty, logRecord.FormattedMessage); + } + + [Fact] + public void OnEnd_PreservesOtherLogRecordProperties() + { + // Arrange + var logRecord = CreateLogRecord(); + var originalTimestamp = logRecord.Timestamp; + var originalLogLevel = logRecord.LogLevel; + var originalCategoryName = logRecord.CategoryName; + + // Set data that should be cleared + logRecord.Attributes = new List> { new("test", "value") }; + logRecord.Body = "test body"; + logRecord.FormattedMessage = "test message"; + + // Act + _processor.OnEnd(logRecord); + + // Assert - Other properties should remain unchanged + Assert.Equal(originalTimestamp, logRecord.Timestamp); + Assert.Equal(originalLogLevel, logRecord.LogLevel); + Assert.Equal(originalCategoryName, logRecord.CategoryName); + } + + [Fact] + public void OnEnd_MultipleCallsOnSameRecord_DoesNotThrow() + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Attributes = new List> { new("test", "value") }; + logRecord.Body = "test body"; + logRecord.FormattedMessage = "test message"; + + // Act + var exception = Record.Exception(() => + { + _processor.OnEnd(logRecord); + _processor.OnEnd(logRecord); + _processor.OnEnd(logRecord); + }); + + // Assert + Assert.Null(exception); + Assert.Empty(logRecord.Attributes); + Assert.Equal(string.Empty, logRecord.Body); + Assert.Equal(string.Empty, logRecord.FormattedMessage); + } + + [Theory] + [InlineData("sensitive-data")] + [InlineData("")] + [InlineData(null)] + public void OnEnd_ClearsBody_ForVariousBodyValues(string? bodyValue) + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.Body = bodyValue; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.Equal(string.Empty, logRecord.Body); + } + + [Theory] + [InlineData("User {id} logged in")] + [InlineData("")] + [InlineData(null)] + public void OnEnd_ClearsFormattedMessage_ForVariousMessageValues(string? messageValue) + { + // Arrange + var logRecord = CreateLogRecord(); + logRecord.FormattedMessage = messageValue; + + // Act + _processor.OnEnd(logRecord); + + // Assert + Assert.Equal(string.Empty, logRecord.FormattedMessage); + } + + /// + /// Helper method to create a LogRecord. + /// + private static LogRecord CreateLogRecord() + { + var record = Activator.CreateInstance(typeof(LogRecord), true); + + Assert.NotNull(record); + + return (LogRecord)record; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs new file mode 100644 index 0000000000..b655e44190 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; + +public class TelemetryServiceTests +{ + private const string TestDeviceId = "test-device-id"; + private const string TestMacAddressHash = "test-hash"; + private readonly AzureMcpServerConfiguration _testConfiguration = new() + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + private readonly IOptions _mockOptions; + private readonly IMachineInformationProvider _mockInformationProvider; + private readonly IOptions _mockServiceOptions; + private readonly ILogger _logger; + + public TelemetryServiceTests() + { + _mockOptions = Substitute.For>(); + _mockOptions.Value.Returns(_testConfiguration); + + _mockServiceOptions = Substitute.For>(); + + _mockInformationProvider = Substitute.For(); + _mockInformationProvider.GetMacAddressHash().Returns(Task.FromResult(TestMacAddressHash)); + _mockInformationProvider.GetOrCreateDeviceId().Returns(Task.FromResult(TestDeviceId)); + + _logger = Substitute.For>(); + } + + [Fact] + public void StartActivity_WhenTelemetryDisabled_ShouldReturnNull() + { + // Arrange + _testConfiguration.IsTelemetryEnabled = false; + using var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + const string activityId = "test-activity"; + + // Act + var activity = service.StartActivity(activityId); + + // Assert + Assert.Null(activity); + } + + [Fact] + public void StartActivity_WithClientInfo_WhenTelemetryDisabled_ShouldReturnNull() + { + // Arrange + _testConfiguration.IsTelemetryEnabled = false; + using var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + const string activityId = "test-activity"; + var clientInfo = new Implementation + { + Name = "TestClient", + Version = "2.0.0" + }; + + // Act + using var activity = service.StartActivity(activityId, clientInfo); + + // Assert + Assert.Null(activity); + } + + [Fact] + public void Dispose_WithNullLogForwarder_ShouldNotThrow() + { + // Arrange + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + + // Act & Assert + var exception = Record.Exception(() => service.Dispose()); + Assert.Null(exception); + } + + [Fact] + public void Constructor_WithNullOptions_ShouldThrowArgumentNullException() + { + // Arrange, Act & Assert + Assert.Throws(() => new TelemetryService(_mockInformationProvider, null!, _mockServiceOptions, _logger)); + } + + [Fact] + public void Constructor_WithNullConfiguration_ShouldThrowNullReferenceException() + { + // Arrange + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns((AzureMcpServerConfiguration)null!); + + // Act & Assert + Assert.Throws(() => new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger)); + } + + [Fact] + public void GetDefaultTags_ThrowsWhenTagsNotInitialized() + { + // Arrange + _mockOptions.Value.Returns(_testConfiguration); + + // Act & Assert + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + + Assert.Throws(() => service.GetDefaultTags()); + } + + [Fact] + public void GetDefaultTags_ReturnsEmptyOnDisabled() + { + // Arrange + _testConfiguration.IsTelemetryEnabled = false; + + var serviceStartOptions = new ServiceStartOptions + { + Mode = "test-mode", + Debug = true, + Transport = TransportTypes.StdIo + }; + _mockServiceOptions.Value.Returns(serviceStartOptions); + + // Act + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + var tags = service.GetDefaultTags(); + + // Assert + Assert.Empty(tags); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task StartActivity_WithInvalidActivityId_ShouldHandleGracefully(string activityId) + { + // Arrange + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); + + // Act + var activity = service.StartActivity(activityId); + + // Assert + // ActivitySource.StartActivity typically handles null/empty names gracefully + // The exact behavior may depend on the .NET version and ActivitySource implementation + if (activity != null) + { + activity.Dispose(); + } + } + + [Fact] + public void StartActivity_WithoutInitialization_Throws() + { + // Arrange + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + // Act & Assert + // Test both overloads. + Assert.Throws(() => service.StartActivity("an-activity-id")); + + var clientInfo = new Implementation + { + Name = "Foo-Bar-MCP", + Version = "1.0.0", + Title = "Test MCP server" + }; + Assert.Throws(() => service.StartActivity("an-activity-id", clientInfo)); + } + + [Fact] + public async Task StartActivity_WhenInitializationFails_Throws() + { + // Arrange + var informationProvider = new ExceptionalInformationProvider(); + + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + var clientInfo = new Implementation + { + Name = "Foo-Bar-MCP", + Version = "1.0.0", + Title = "Test MCP server" + }; + + // Act & Assert + using var service = new TelemetryService(informationProvider, mockOptions, _mockServiceOptions, _logger); + + await Assert.ThrowsAsync(() => service.InitializeAsync()); + + Assert.Throws(() => service.StartActivity("an-activity-id", clientInfo)); + } + + [Fact] + public async Task StartActivity_ReturnsActivityWhenEnabled() + { + // Arrange + var serviceStartOptions = new ServiceStartOptions + { + Mode = "test-mode", + Debug = true, + Transport = TransportTypes.StdIo + }; + _mockServiceOptions.Value.Returns(serviceStartOptions); + + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + var operationName = "an-activity-id"; + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); + + var defaultTags = service.GetDefaultTags(); + + // Act + var activity = service.StartActivity(operationName); + + // Assert + if (activity != null) + { + Assert.Equal(operationName, activity.OperationName); + } + + AssertDefaultTags(defaultTags, serviceStartOptions); + } + + [Fact] + public async Task InitializeAsync_InvokedOnce() + { + // Arrange + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); + await service.InitializeAsync(); + + // Act + await _mockInformationProvider.Received(1).GetOrCreateDeviceId(); + await _mockInformationProvider.Received(1).GetMacAddressHash(); + } + + private static void AssertDefaultTags(IReadOnlyList> tags, + ServiceStartOptions? expectedServiceOptions = null) + { + var dictionary = tags.ToDictionary(); + Assert.NotEmpty(tags); + + AssertTag(dictionary, TelemetryConstants.TagName.DevDeviceId, TestDeviceId); + AssertTag(dictionary, TelemetryConstants.TagName.MacAddressHash, TestMacAddressHash); + + if (expectedServiceOptions != null) + { + Assert.NotNull(expectedServiceOptions.Mode); + AssertTag(dictionary, TelemetryConstants.TagName.ServerMode, expectedServiceOptions.Mode); + } + else + { + Assert.False(dictionary.ContainsKey(TelemetryConstants.TagName.ServerMode)); + } + } + + private static void AssertTag(IDictionary tags, string tagName, string expectedValue) + { + Assert.True(tags.ContainsKey(tagName)); + Assert.Equal(expectedValue, tags[tagName]); + } + + private class ExceptionalInformationProvider : IMachineInformationProvider + { + public Task GetMacAddressHash() => Task.FromResult("test-mac-address"); + + public Task GetOrCreateDeviceId() => Task.FromException( + new ArgumentNullException("test-exception")); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/UnixInformationProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/UnixInformationProviderTests.cs new file mode 100644 index 0000000000..e076bbea20 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/UnixInformationProviderTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; + +public class UnixInformationProviderTests +{ + private static readonly DirectoryInfo TestStorageDirectory = new DirectoryInfo(Path.DirectorySeparatorChar + Path.Join("test", "storage")); + private static readonly string TestStoragePath = TestStorageDirectory.ToString(); + private static readonly DirectoryInfo ExpectedCacheDirectory = new DirectoryInfo(Path.Join(TestStorageDirectory.ToString(), "Microsoft", "DeveloperTools")); + private static readonly string ExpectedCachePath = ExpectedCacheDirectory.ToString(); + + private readonly ILogger _logger; + private readonly TestUnixInformationProvider _provider; + + public UnixInformationProviderTests() + { + _logger = Substitute.For>(); + _provider = new TestUnixInformationProvider(_logger, TestStorageDirectory.ToString()); + } + + [Fact] + public async Task GetOrCreateDeviceId_WhenStoragePathThrows_ReturnsNull() + { + // Arrange + var provider = new TestUnixInformationProvider(_logger, storagePath: TestStorageDirectory.ToString(), throwOnGetStoragePath: true); + + // Act + var result = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetOrCreateDeviceId_WhenExistingDeviceIdExists_ReturnsExistingValue() + { + // Arrange + const string existingDeviceId = "existing-device-id"; + + var provider = new NoOpUnixInformationProvider(existingDeviceId, true); + + // Act + var result = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.Equal(existingDeviceId, result); + + Assert.Equal(ExpectedCachePath, provider.ReadDirectoryPath); + + // Should not have written anything to disk if device id exists. + Assert.Null(provider.WriteDirectoryPath); + Assert.Null(provider.WriteFileName); + } + + [Fact] + public async Task GetOrCreateDeviceId_WhenNoExistingDeviceId_CreatesNewDeviceId() + { + // Arrange + var provider = new NoOpUnixInformationProvider(null, true); + + // Act + var result = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.NotNull(result); + + string? read = provider.ReadDirectoryPath != null ? new DirectoryInfo(provider.ReadDirectoryPath).ToString() : null; + string? write = provider.WriteDirectoryPath != null ? new DirectoryInfo(provider.WriteDirectoryPath).ToString() : null; + Assert.Equal(ExpectedCacheDirectory.ToString(), read); + Assert.Equal(ExpectedCacheDirectory.ToString(), write); + + Assert.NotNull(provider.WriteValue); + } + + [Fact] + public async Task GetOrCreateDeviceId_WhenWriteValueToDiskFails_ReturnsNull() + { + // Arrange + var provider = new NoOpUnixInformationProvider(null, false); + + // Act + var result = await provider.GetOrCreateDeviceId(); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task WriteValueToDisk_WhenValueIsNullOrWhitespace_ReturnsFalse(string? value) + { + // Act + var result = await _provider.WriteValueToDisk("/test/path", "filename", value); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ReadValueFromDisk_WhenFileDoesNotExist_ReturnsNull() + { + // Act + var result = await _provider.ReadValueFromDisk("/nonexistent/path", "nonexistent.txt"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Constructor_WithValidLogger_DoesNotThrow() + { + // Act & Assert + var exception = Record.Exception(() => new TestUnixInformationProvider(_logger, TestStoragePath)); + Assert.Null(exception); + } + + [Fact] + public void GetStoragePath_WhenImplementationThrows_PropagatesException() + { + // Arrange + var provider = new TestUnixInformationProvider(_logger, TestStoragePath, throwOnGetStoragePath: true); + + // Act & Assert + Assert.Throws(() => provider.GetTestStoragePath()); + } + + private class NoOpLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + } + } + + private class NoOpUnixInformationProvider(string? readStorageResult, bool writeValueResult) + : UnixMachineInformationProvider(new NoOpLogger()) + { + private readonly string? _readStorageResult = readStorageResult; + private readonly bool _writeValueResult = writeValueResult; + + public override string GetStoragePath() => TestStoragePath; + + public string? ReadDirectoryPath { get; private set; } + public string? ReadFileName { get; private set; } + + public string? WriteDirectoryPath { get; private set; } + public string? WriteFileName { get; private set; } + public string? WriteValue { get; private set; } + + public override Task ReadValueFromDisk(string directoryPath, string fileName) + { + ReadDirectoryPath = directoryPath; + ReadFileName = fileName; + return Task.FromResult(_readStorageResult); + } + + public override Task WriteValueToDisk(string directoryPath, string fileName, string? value) + { + WriteDirectoryPath = directoryPath; + WriteFileName = fileName; + WriteValue = value; + + return Task.FromResult(_writeValueResult); + } + } + + private class TestUnixInformationProvider(ILogger logger, + string? storagePath, bool throwOnGetStoragePath = false, string? deviceId = null) + : UnixMachineInformationProvider(logger) + { + private readonly string? _storagePath = storagePath; + private readonly string? _deviceId = deviceId; + private readonly bool _throwOnGetStoragePath = throwOnGetStoragePath; + + public string GetTestStoragePath() => GetStoragePath(); + + public override Task GetOrCreateDeviceId() + { + if (string.IsNullOrEmpty(_deviceId)) + { + return base.GetOrCreateDeviceId(); + } + else + { + return Task.FromResult(_deviceId); + } + } + + public override string GetStoragePath() + { + if (_throwOnGetStoragePath) + { + throw new InvalidOperationException("No storage path available"); + } + return _storagePath ?? throw new InvalidOperationException("Storage path not set"); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs new file mode 100644 index 0000000000..bc9c2c6bb6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Threading; +using Xunit.v3; + +namespace Azure.Mcp.Tests.Client.Attributes; + +/// +/// Attribute to customize the test-proxy matcher for a specific test method. +/// Apply this to individual test methods to override default matching behavior for that test only. +/// +/// Tests other than what this is applied to will use the default matcher behavior as defined in default test configuration. +/// +public sealed class CustomMatcherAttribute : BeforeAfterTestAttribute +{ + private static readonly AsyncLocal Current = new(); + + /// + /// When true, the request/response body will be compared during playback matching. Otherwise, body comparison is skipped. Defaults to true. + /// + public bool CompareBodies { get; set; } + + /// + /// When true, query parameter ordering will be ignored during playback matching. Defaults to false. + /// + public bool IgnoreQueryOrdering { get; set; } + + public CustomMatcherAttribute( + bool compareBody = false, + bool ignoreQueryordering = false) + { + CompareBodies = compareBody; + IgnoreQueryOrdering = ignoreQueryordering; + } + + public override void Before(MethodInfo methodUnderTest, IXunitTest xunitTest) + { + base.Before(methodUnderTest, xunitTest); + Current.Value = this; + } + + public override void After(MethodInfo methodUnderTest, IXunitTest xunitTest) + { + base.After(methodUnderTest, xunitTest); + Current.Value = null; + } + + internal static CustomMatcherAttribute? GetActive() => Current.Value; +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj index 6f70a18ac1..edac310bc5 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj @@ -4,10 +4,11 @@ - + + diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs new file mode 100644 index 0000000000..f1a9d78a45 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Helpers; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace Azure.Mcp.Tests.Client; + +public abstract class CommandTestsBase(ITestOutputHelper output) : IAsyncLifetime, IDisposable +{ + protected const string TenantNameReason = "Service principals cannot use TenantName for lookup"; + + protected McpClient Client { get; private set; } = default!; + protected LiveTestSettings Settings { get; set; } = default!; + protected StringBuilder FailureOutput { get; } = new(); + protected ITestOutputHelper Output { get; } = output; + + public string[]? CustomArguments; + public TestMode TestMode = TestMode.Live; + + /// + /// Sets custom arguments for the MCP server. Call this before InitializeAsync(). + /// + /// Custom arguments to pass to the server (e.g., ["server", "start", "--mode", "single"]) + public void SetArguments(params string[] arguments) + { + CustomArguments = arguments; + } + + public virtual async ValueTask InitializeAsync() + { + await InitializeAsyncInternal(null); + } + + public static LiveTestSettings PlaybackSettings => new() + { + SubscriptionId = "00000000-0000-0000-0000-000000000000", + TenantId = "00000000-0000-0000-0000-000000000000", + ResourceBaseName = "Sanitized", + SubscriptionName = "Sanitized", + TenantName = "Sanitized", + TestMode = TestMode.Playback + }; + + protected virtual async ValueTask LoadSettingsAsync() + { + Settings = await TryLoadLiveSettingsAsync().ConfigureAwait(false) ?? PlaybackSettings; + + // if the user has set to playback in LiveTestSettings, they're + // intentionally checking playback mode, load the playback settings + // and ignore what we got from the .testsettings.json file + if (Settings.TestMode == TestMode.Playback) + { + Settings = PlaybackSettings; + } + + TestMode = Settings.TestMode; + } + + private async Task TryLoadLiveSettingsAsync() + { + try + { + var settingsFixture = new LiveTestSettingsFixture(); + await settingsFixture.InitializeAsync().ConfigureAwait(false); + return settingsFixture.Settings; + } + catch (FileNotFoundException) + { + return null; + } + } + + protected virtual async ValueTask InitializeAsyncInternal(TestProxyFixture? proxy = null) + { + await LoadSettingsAsync(); + + string executablePath = McpTestUtilities.GetAzMcpExecutablePath(); + + // Use custom arguments if provided, otherwise use standard mode (debug can be enabled via environment variable) + var debugEnvVar = Environment.GetEnvironmentVariable("AZURE_MCP_TEST_DEBUG"); + var enableDebug = string.Equals(debugEnvVar, "true", StringComparison.OrdinalIgnoreCase) || Settings.DebugOutput; + string[] defaultArgs = enableDebug + ? ["server", "start", "--mode", "all", "--debug"] + : ["server", "start", "--mode", "all"]; + var arguments = CustomArguments ?? defaultArgs; + + Dictionary envVarDictionary = [ + // Propagate playback signaling & sanitized identifiers to server process. + + // TODO: Temporarily commenting these out until we can solve for subscription id tests + // see https://github.com/microsoft/mcp/issues/1103 + // { "AZURE_TENANT_ID", Settings.TenantId }, + // { "AZURE_SUBSCRIPTION_ID", Settings.SubscriptionId } + ]; + + if (proxy != null && proxy.Proxy != null) + { + envVarDictionary.Add("TEST_PROXY_URL", proxy.Proxy.BaseUri); + + if (TestMode is TestMode.Playback) + { + envVarDictionary.Add("AZURE_TOKEN_CREDENTIALS", "PlaybackTokenCredential"); + } + } + + StdioClientTransportOptions transportOptions = new() + { + Name = "Test Server", + Command = executablePath, + Arguments = arguments, + // Direct stderr to test output helper as required by task + StandardErrorLines = line => Output.WriteLine($"[MCP Server] {line}"), + EnvironmentVariables = envVarDictionary + }; + + if (!string.IsNullOrEmpty(Settings.TestPackage)) + { + Environment.CurrentDirectory = Settings.SettingsDirectory; + transportOptions.Command = "npx"; + transportOptions.Arguments = ["-y", Settings.TestPackage, .. arguments]; + } + + var clientTransport = new StdioClientTransport(transportOptions); + Output.WriteLine("Attempting to start MCP Client"); + Client = await McpClient.CreateAsync(clientTransport); + Output.WriteLine("MCP client initialized successfully"); + } + + protected Task CallToolAsync(string command, Dictionary parameters) + { + return CallToolAsync(command, parameters, Client); + } + + protected async Task CallToolAsync(string command, Dictionary parameters, McpClient mcpClient) + { + // Use the same debug logic as MCP server initialization + var debugEnvVar = Environment.GetEnvironmentVariable("AZURE_MCP_TEST_DEBUG"); + var enableDebug = string.Equals(debugEnvVar, "true", StringComparison.OrdinalIgnoreCase) || Settings.DebugOutput; + + // Output will be streamed, so if we're not in debug mode, hold the debug output for logging in the failure case + Action writeOutput = enableDebug + ? s => Output.WriteLine(s) + : s => FailureOutput.AppendLine(s); + + writeOutput($"request: {JsonSerializer.Serialize(new { command, parameters })}"); + + CallToolResult result; + try + { + result = await mcpClient.CallToolAsync(command, parameters); + } + catch (ModelContextProtocol.McpException ex) + { + // MCP client throws exceptions for error responses, but we want to handle them gracefully + writeOutput($"MCP exception: {ex.Message}"); + throw; // Re-throw if we can't handle it + } + + var content = McpTestUtilities.GetFirstText(result.Content); + if (string.IsNullOrWhiteSpace(content)) + { + writeOutput($"response: {JsonSerializer.Serialize(result)}"); + throw new Exception("No JSON content found in the response."); + } + + JsonElement root; + try + { + root = JsonSerializer.Deserialize(content!); + if (root.ValueKind != JsonValueKind.Object) + { + throw new Exception("Invalid JSON response."); + } + + // Remove the `args` property and log the content + var trimmed = root.Deserialize()!; + trimmed.Remove("args"); + writeOutput($"response: {trimmed.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}"); + } + catch (Exception ex) + { + // If we can't json parse the content as a JsonObject, log the content and throw an exception + writeOutput($"response: {content}"); + throw new Exception("Failed to deserialize JSON response.", ex); + } + + return root.TryGetProperty("results", out var property) ? property : null; + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + public virtual async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + // subclasses should override this method to dispose resources + // overrides should still call base.Dispose(disposing) + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + // No unmanaged resources to release, but if we had, we'd release them here. + // _disposableResource?.Dispose(); + // _disposableResource = null; + + // Handle things normally disposed in DisposeAsyncCore + if (Client is IDisposable disposable) + { + disposable.Dispose(); + } + } + + // Failure output may contain request and response details that should be output for failed tests. + if (TestContext.Current?.TestState?.Result == TestResult.Failed && FailureOutput.Length > 0) + { + Output.WriteLine(FailureOutput.ToString()); + } + } + + // subclasses should override this method to dispose async resources + // overrides should still call base.DisposeAsyncCore() + protected virtual async ValueTask DisposeAsyncCore() + { + await Client.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs new file mode 100644 index 0000000000..2ccfc4e1b6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs @@ -0,0 +1,35 @@ +using System.ClientModel; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Client.Helpers; + +/// +/// Generate BinaryContent from objects or JSON strings. +/// +internal static class BinaryContentHelper +{ + private static readonly JsonSerializerOptions _defaultJsonOptions = new() + { + WriteIndented = false + }; + + /// + /// Serialize object to JSON UTF8 bytes and wrap into BinaryContent via BinaryData factory. + /// Avoid generic Create which expects IPersistableModel. + /// + public static BinaryContent FromObject(T value, JsonSerializerOptions? jsonOptions = null) + { + if (value is null) + { + return BinaryContent.Create(BinaryData.FromString("null")); + } + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, jsonOptions ?? _defaultJsonOptions); + return BinaryContent.Create(new BinaryData(bytes)); + } + + public static BinaryContent FromDictionary(IDictionary dict, JsonSerializerOptions? jsonOptions = null) + => FromObject(dict, jsonOptions); + + public static BinaryContent FromJsonString(string json) + => BinaryContent.Create(BinaryData.FromString(string.IsNullOrEmpty(json) ? "null" : json)); +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/CustomTestTransport.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/CustomTestTransport.cs new file mode 100644 index 0000000000..37c427b06d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/CustomTestTransport.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Channels; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Tests.Client.Helpers; + +public sealed class CustomTestTransport : ITransport +{ + private readonly Channel _messageChannel; + + public bool IsConnected { get; set; } + + public ChannelReader MessageReader => _messageChannel; + + public List SentMessages { get; } = []; + + public Action? MessageListener { get; set; } + + public string? SessionId => null; + + public CustomTestTransport() + { + _messageChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + IsConnected = true; + } + + public ValueTask DisposeAsync() + { + _messageChannel.Writer.TryComplete(); + IsConnected = false; + return ValueTask.CompletedTask; + } + + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + SentMessages.Add(message); + if (message is JsonRpcRequest request) + { + await WriteMessageAsync(request, cancellationToken); + MessageListener?.Invoke(message); + } + else if (message is JsonRpcNotification notification) + { + await WriteMessageAsync(notification, cancellationToken); + MessageListener?.Invoke(message); + } + else if (message is JsonRpcResponse response) + { + MessageListener?.Invoke(message); + } + else + { + throw new NotSupportedException($"Message type {message.GetType()} is not supported."); + } + } + + private async Task WriteMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + await _messageChannel.Writer.WriteAsync(message, cancellationToken); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs new file mode 100644 index 0000000000..20a8f9269f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tests.Helpers; + +namespace Azure.Mcp.Tests.Client.Helpers; + +public class LiveTestSettings +{ + public string PrincipalName { get; set; } = string.Empty; + public bool IsServicePrincipal { get; set; } + public string TenantId { get; set; } = string.Empty; + public string TenantName { get; set; } = string.Empty; + public string SubscriptionId { get; set; } = string.Empty; + public string SubscriptionName { get; set; } = string.Empty; + public string ResourceGroupName { get; set; } = string.Empty; + public string ResourceBaseName { get; set; } = string.Empty; + public string SettingsDirectory { get; set; } = string.Empty; + public string TestPackage { get; set; } = string.Empty; + public TestMode TestMode { get; set; } = TestMode.Live; + public bool DebugOutput { get; set; } + public Dictionary DeploymentOutputs { get; set; } = []; + public Dictionary EnvironmentVariables { get; set; } = []; +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs new file mode 100644 index 0000000000..98c4bc526d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs @@ -0,0 +1,77 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Xunit; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tests.Client.Helpers +{ + public class LiveTestSettingsFixture : IAsyncLifetime + { + public LiveTestSettings Settings { get; private set; } = new(); + + public virtual async ValueTask InitializeAsync() + { + var testSettingsFileName = ".testsettings.json"; + var directory = Path.GetDirectoryName(typeof(LiveTestSettingsFixture).Assembly.Location); + + while (!string.IsNullOrEmpty(directory)) + { + var testSettingsFilePath = Path.Combine(directory, testSettingsFileName); + if (File.Exists(testSettingsFilePath)) + { + var content = await File.ReadAllTextAsync(testSettingsFilePath); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + Settings = JsonSerializer.Deserialize(content, options) + ?? throw new Exception("Unable to deserialize live test settings"); + + foreach (var (key, value) in Settings.EnvironmentVariables) + { + Environment.SetEnvironmentVariable(key, value); + } + + Settings.SettingsDirectory = directory; + await SetPrincipalSettingsAsync(); + + return; + } + + directory = Path.GetDirectoryName(directory); + } + + throw new FileNotFoundException($"Test settings file '{testSettingsFileName}' not found in the assembly directory or its parent directories."); + } + + private async Task SetPrincipalSettingsAsync() + { + const string GraphScopeUri = "https://graph.microsoft.com/.default"; + var credential = new CustomChainedCredential(Settings.TenantId); + AccessToken token = await credential.GetTokenAsync(new TokenRequestContext([GraphScopeUri]), TestContext.Current.CancellationToken); + var jsonToken = new JwtSecurityToken(token.Token); + + var claims = JsonSerializer.Serialize(jsonToken.Claims.Select(x => x.Type)); + + var principalType = jsonToken.Claims.FirstOrDefault(c => c.Type == "idtyp")?.Value ?? + throw new Exception($"Unable to locate 'idtyp' claim in Entra ID token: {claims}"); + + Settings.IsServicePrincipal = string.Equals(principalType, "app", StringComparison.OrdinalIgnoreCase); + + var nameClaim = Settings.IsServicePrincipal ? "app_displayname" : "unique_name"; + + var principalName = jsonToken.Claims.FirstOrDefault(c => c.Type == nameClaim)?.Value ?? + throw new Exception($"Unable to locate 'unique_name' claim in Entra ID token: {claims}"); + + Settings.PrincipalName = principalName; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/McpTestUtilities.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/McpTestUtilities.cs new file mode 100644 index 0000000000..f328ac7035 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/McpTestUtilities.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Tests.Client.Helpers; + +public static class McpTestUtilities +{ + /// Gets the first text contents in the list. + public static string? GetFirstText(IList contents) + { + foreach (var c in contents) + { + if (c is EmbeddedResourceBlock { Resource: TextResourceContents { MimeType: "application/json" } text }) + { + return text.Text; + } + else if (c is TextContentBlock tc) + { + return tc.Text; + } + } + + return null; + } + + /// + /// Gets the path to the azmcp executable, handling OS-specific executable naming. + /// + /// The full path to the azmcp executable. + public static string GetAzMcpExecutablePath() + { + string testAssemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + string executableName = OperatingSystem.IsWindows() ? "azmcp.exe" : "azmcp"; + return Path.Combine(testAssemblyPath, executableName); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs new file mode 100644 index 0000000000..a4316b6ac0 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Azure.Mcp.Tests.Client.Helpers; + +/// +/// Provides path resolution for session records and related assets. +/// +public sealed class RecordingPathResolver +{ + private static readonly char[] _invalidChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; + + private readonly string _repoRoot; + + public RecordingPathResolver() + { + _repoRoot = ResolveRepositoryRoot() ?? Directory.GetCurrentDirectory(); + } + + /// + /// Attempt to locate the repository root by walking up until a .git directory/file or global.json is found. + /// + private static string? ResolveRepositoryRoot() + { + var dir = new DirectoryInfo(Assembly.GetExecutingAssembly().Location).Parent; + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, "global.json"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Unable to locate repository root. Ensure tests are running in a cloned repository."); + } + + public string RepositoryRoot => _repoRoot; + + /// + /// Sanitizes a test display/name into a file-system friendly component. + /// + public static string Sanitize(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "(unknown)"; + Span buffer = stackalloc char[name.Length]; + int i = 0; + foreach (var c in name) + { + buffer[i++] = _invalidChars.Contains(c) ? '_' : c; + } + return new string(buffer); + } + + /// + /// Builds the session directory path: /SessionRecords/ + /// Example: tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/SessionRecords/RecordedKeyVaultCommandTests + /// + public string GetSessionDirectory(Type testType, string? variantSuffix = null) + { + // Locate the test project directory by ascending from the assembly location until a matching *.csproj exists. + var projectDir = GetProjectDirectory(testType); + + // Compute relative path from repo root. + var relativeProjectPath = Path.GetRelativePath(_repoRoot, projectDir) + .Replace('\\', '/'); // Normalize separators for consistency. + + // Append SessionRecords and suffix. + var sessionDir = Path.Combine(relativeProjectPath, "SessionRecords") + .Replace('\\', '/'); + + // TODO: Consider caching projectDir per assembly for performance if needed. + return sessionDir; + } + + private static string GetProjectDirectory(Type testType) + { + // Locate the test project directory by ascending from the assembly location until a matching *.csproj exists. + var assemblyDir = Path.GetDirectoryName(testType.Assembly.Location)!; + var projectDir = FindProjectDirectory(assemblyDir, testType); + + return projectDir; + } + + private static string FindProjectDirectory(string startDirectory, Type testType) + { + var current = new DirectoryInfo(startDirectory); + var expectedProjectName = testType.Assembly.GetName().Name; // Typically matches .csproj file name. + + while (current != null) + { + // Look for any .csproj; prefer one matching assembly name. + var csprojFiles = current.GetFiles("*.csproj", SearchOption.TopDirectoryOnly); + if (csprojFiles.Length > 0) + { + var matching = csprojFiles.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f.Name) == expectedProjectName); + return (matching ?? csprojFiles.First()).Directory!.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException($"Unable to locate project directory for test type {testType.FullName} starting from {startDirectory}."); + } + + /// + /// Builds a deterministic file name from sanitized test name. + /// TODO: Add version qualifier / async suffix when those concepts are introduced. + /// + public static string BuildFileName(string sanitizedDisplayName, bool isAsync, string? versionQualifier = null) + { + var versionPart = string.IsNullOrWhiteSpace(versionQualifier) ? string.Empty : $"[{versionQualifier}]"; // TODO: provide real version qualifier + var asyncPart = isAsync ? "Async" : string.Empty; // TODO: This is literally looking at the test name. Probably not good enough. + return $"{sanitizedDisplayName}{versionPart}{asyncPart}.json"; + } + + /// + /// Attempts to find a nearest assets.json walking upwards. + /// + public string? GetAssetsJson(Type testType) + { + var projectDir = GetProjectDirectory(testType); + + var current = new DirectoryInfo(projectDir); + + var assetsFile = Path.Combine(current.FullName, "assets.json"); + + if (File.Exists(assetsFile)) + { + return assetsFile; + } + + return null; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs new file mode 100644 index 0000000000..7650950e58 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using Azure.Mcp.Tests.Helpers; +using Xunit; + +namespace Azure.Mcp.Tests.Client.Helpers +{ + /// + /// xUnit fixture that runs once per test class (or collection if used via [CollectionDefinition]). + /// Provides optional access to a shared TestProxy via Proxy property if tests need it later. + /// + public sealed class TestProxyFixture : IAsyncLifetime + { + public static string DetermineRepositoryRoot() + { + var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Environment.CurrentDirectory; + while (!string.IsNullOrEmpty(path)) + { + // we look for both directory and file because depending on user git config the .git may be a file instead of a directory + if (Directory.Exists(Path.Combine(path, ".git")) || File.Exists(Path.Combine(path, ".git"))) + return path; + var parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent) || parent == path) + break; + path = parent; + } + return Environment.CurrentDirectory; + } + + /// + /// Proxy instance created lazily. RecordedCommandTestsBase will start it after determining TestMode from LiveTestSettings. + /// + public TestProxy? Proxy { get; private set; } + + public ValueTask InitializeAsync() + { + return ValueTask.CompletedTask; + } + + public async Task StartProxyAsync() + { + var root = DetermineRepositoryRoot(); + Proxy = new TestProxy(); + await Proxy.Start(root); + } + + public ValueTask DisposeAsync() + { + if (Proxy is not null) + { + Proxy.Dispose(); + } + return ValueTask.CompletedTask; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs new file mode 100644 index 0000000000..9df8bdc9c3 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs @@ -0,0 +1,403 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; +using Xunit.Sdk; + +namespace Azure.Mcp.Tests.Client; + +public abstract class RecordedCommandTestsBase(ITestOutputHelper output, TestProxyFixture fixture) : CommandTestsBase(output), IClassFixture +{ + protected TestProxy? Proxy { get; private set; } = fixture.Proxy; + + protected string RecordingId { get; private set; } = string.Empty; + + /// + /// When true, a set of default "additional" sanitizers will be registered. Currently includes: + /// - Sanitize out value of ResourceBaseName from LiveTestSettings as a GeneralRegexSanitizer + /// + public virtual bool EnableDefaultSanitizerAdditions { get; set; } = true; + + /// + /// Sanitizers that will apply generally across all parts (URI, Body, HeaderValues) of the request/response. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List GeneralRegexSanitizers { get; } = new(); + + /// + /// Sanitizers that will apply a regex to specific headers. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List HeaderRegexSanitizers { get; } = new() + { + // Sanitize the WWW-Authenticate header which may contain tenant IDs or resource URLs to "Sanitized" + // During conversion to recordings, the actual tenant ID is captured in group 1 and replaced with a fixed GUID. + // REMOVAL of this formatting cause complete failure on tool side when it expects a valid URL with a GUID tenant ID. + // Hence the more complex replacement rather than a simple static string replace of the entire header value with `Sanitized` + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("WWW-Authenticate") + { + Regex = "https://login.microsoftonline.com/(.*?)\"", + GroupForReplace = "1", + Value = "00000000-0000-0000-0000-000000000000" + }) + }; + + /// + /// Sanitizers that apply a regex replacement to URIs. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List UriRegexSanitizers { get; } = new(); + + /// + /// Sanitizers that will apply a regex replacement to a specific json body key. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List BodyKeySanitizers { get; } = new(); + + /// + /// Sanitizers that will apply regex replacement to the body of requests/responses. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List BodyRegexSanitizers { get; } = new(); + + /// + /// The test-proxy has a default set of ~90 sanitizers for common sensitive data (GUIDs, tokens, timestamps, etc). This list allows opting out of specific default sanitizers by name. + /// Grab the names from the test-proxy source at https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs#L65) + /// Default Set: + /// - `AZSDK3430`: `$..id` + /// + public virtual List DisabledDefaultSanitizers { get; } = new() { "AZSDK3430" }; + + /// + /// During recording, variables saved to this dictionary will be propagated to the test-proxy and saved in the recording file. + /// During playback, these variables will be available within the test function body, and can be used to ensure that dynamic values from the recording are used where + /// specific values should be used. + /// + protected readonly Dictionary TestVariables = new Dictionary(); + + /// + /// When set, applies a custom matcher for _all_ playback tests from this test class. This can be overridden on a per-test basis using the attribute on test methods. + /// + public virtual CustomDefaultMatcher? TestMatcher { get; set; } = null; + + public virtual void RegisterVariable(string name, string value) + { + if (TestMode == TestMode.Playback) + { + // no-op in live/playback modes, as during playback the variables will be populated from the recording file automatically. + return; + } + + TestVariables[name] = value; + } + + // used to resolve a recording "path" given an invoking test + protected static readonly RecordingPathResolver PathResolver = new(); + + protected virtual bool IsAsync => false; + + // todo: use this when we have versioned tests to run this against. + protected virtual string? VersionQualifier => null; + + protected override async ValueTask LoadSettingsAsync() + { + await base.LoadSettingsAsync(); + } + + public override async ValueTask InitializeAsync() + { + // load settings first to determine test mode + await LoadSettingsAsync(); + + if (fixture.Proxy == null) + { + // start the proxy if needed + await StartProxyAsync(fixture); + } + + // start MCP client with proxy URL available + await base.InitializeAsyncInternal(fixture); + + // start recording/playback session + await StartRecordOrPlayback(); + + // apply custom matcher if test has attribute + await ApplyAttributeMatcherSettings(); + } + + private async Task ApplyAttributeMatcherSettings() + { + if (Proxy == null || TestMode != TestMode.Playback) + { + return; + } + + var attr = CustomMatcherAttribute.GetActive(); + if (attr == null) + { + return; + } + + var matcher = new CustomDefaultMatcher + { + IgnoreQueryOrdering = attr.IgnoreQueryOrdering, + CompareBodies = attr.CompareBodies, + }; + + await SetMatcher(matcher, RecordingId); + } + + private async Task SetMatcher(CustomDefaultMatcher matcher, string? recordingId = null) + { + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized. Cannot set a matcher for an uninitialized test proxy."); + } + + var matcherSb = new StringBuilder(); + matcherSb.Append($"CompareBodies={matcher.CompareBodies}, IgnoreQueryOrdering={matcher.IgnoreQueryOrdering}"); + if (!string.IsNullOrEmpty(matcher.IgnoredHeaders)) + { + matcherSb.Append($", IgnoredHeaders={matcher.IgnoredHeaders}"); + } + if (!string.IsNullOrEmpty(matcher.ExcludedHeaders)) + { + matcherSb.Append($", ExcludedHeaders={matcher.ExcludedHeaders}."); + } + + // per-test matcher setting + if (recordingId != null) + { + var options = new RequestOptions(); + options.AddHeader("x-recording-id", recordingId); + + Output.WriteLine($"Applying custom matcher to recordingId \"{recordingId}\": {matcherSb}"); + await Proxy.AdminClient.SetMatcherAsync("CustomDefaultMatcher", matcher, options); + } + // global matcher setting + else + { + Output.WriteLine($"Applying custom matcher to global settings: {matcherSb}"); + await Proxy.AdminClient.SetMatcherAsync("CustomDefaultMatcher", matcher); + } + } + + public async Task StartProxyAsync(TestProxyFixture fixture) + { + // we will use the same proxy instance throughout the test class instances, so we only need to start it if not already started. + if (TestMode is TestMode.Record or TestMode.Playback && fixture.Proxy == null) + { + await fixture.StartProxyAsync(); + Proxy = fixture.Proxy; + + // onetime on starting the proxy, we have initialized the livetest settings so lets add some additional sanitizers by default + if (EnableDefaultSanitizerAdditions) + { + PopulateDefaultSanitizers(); + } + + // onetime registration of default sanitizers + // and deregistering default sanitizers that we don't want + if (Proxy != null) + { + await DisableSanitizersAsync(); + await ApplySanitizersAsync(); + + // set session matcher for this class if specified + if (TestMatcher != null) + { + await SetMatcher(TestMatcher); + } + } + } + } + + private void PopulateDefaultSanitizers() + { + if (EnableDefaultSanitizerAdditions) + { + // Sanitize out the resource basename by default! + // This implies that tests shouldn't use this baseresourcename as part of their validation logic, as sanitization will replace it with "Sanitized" and cause confusion. + GeneralRegexSanitizers.Add(new GeneralRegexSanitizer(new GeneralRegexSanitizerBody() + { + Regex = Settings.ResourceBaseName, + Value = "Sanitized", + })); + } + } + + private async Task DisableSanitizersAsync() + { + if (DisabledDefaultSanitizers.Count > 0) + { + var toRemove = new SanitizerList(new List()); + foreach (var sanitizer in DisabledDefaultSanitizers) + { + toRemove.Sanitizers.Add(sanitizer); + } + await Proxy!.AdminClient.RemoveSanitizersAsync(toRemove); + } + } + + private async Task ApplySanitizersAsync() + { + List sanitizers = new(); + + sanitizers.AddRange(GeneralRegexSanitizers); + sanitizers.AddRange(BodyRegexSanitizers); + sanitizers.AddRange(HeaderRegexSanitizers); + sanitizers.AddRange(UriRegexSanitizers); + sanitizers.AddRange(BodyKeySanitizers); + + if (sanitizers.Count > 0) + { + await Proxy!.AdminClient.AddSanitizersAsync(sanitizers); + } + } + + public override async ValueTask DisposeAsync() + { + await StopRecordOrPlayback(); + + // On test failure, append proxy stderr for diagnostics. + if (TestContext.Current?.TestState?.Result == TestResult.Failed && Proxy != null) + { + var stderr = Proxy.SnapshotStdErr(); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Output.WriteLine("=== Test Proxy stderr (captured) ==="); + Output.WriteLine(stderr); + Output.WriteLine("=== End Test Proxy stderr ==="); + } + } + + await base.DisposeAsync(); + } + + private async Task StartRecordOrPlayback() + { + if (TestMode == TestMode.Live) + { + return; + } + + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized."); + } + + var testName = TryGetCurrentTestName(); + var pathToRecording = GetSessionFilePath(testName); + var assetsPath = PathResolver.GetAssetsJson(GetType()); + + var recordOptions = new Dictionary + { + { "x-recording-file", pathToRecording }, + }; + + if (!string.IsNullOrWhiteSpace(assetsPath)) + { + recordOptions["x-recording-assets-file"] = assetsPath; + } + var bodyContent = BinaryContentHelper.FromObject(recordOptions); + + if (TestMode == TestMode.Playback) + { + Output.WriteLine($"[Playback] Session file: {pathToRecording}"); + try + { + ClientResult>? playbackResult = await Proxy.Client.StartPlaybackAsync(new TestProxyStartInformation(pathToRecording, assetsPath, null)).ConfigureAwait(false); + + // Extract recording ID from response header + if (playbackResult.GetRawResponse().Headers.TryGetValue("x-recording-id", out var recordingId)) + { + RecordingId = recordingId ?? string.Empty; + Output.WriteLine($"[Playback] Recording ID: {RecordingId}"); + } + + foreach (var key in playbackResult.Value.Keys) + { + Output.WriteLine($"[Playback] Variable from recording: {key} = {playbackResult.Value[key]}"); + TestVariables[key] = playbackResult.Value[key]; + } + } + catch (Exception e) + { + Output.WriteLine(Proxy.SnapshotStdErr() ?? $"Proxy is null while attempting to snapshot stderr. Facing exception during start playback.{e.ToString()}"); + throw; + } + } + else if (TestMode == TestMode.Record) + { + Output.WriteLine($"[Record] Session file: {pathToRecording}"); + try + { + ClientResult result = Proxy.Client.StartRecord(bodyContent); + + // Extract recording ID from response header + if (result.GetRawResponse().Headers.TryGetValue("x-recording-id", out var recordingId)) + { + RecordingId = recordingId ?? string.Empty; + Output.WriteLine($"[Record] Recording ID: {RecordingId}"); + } + } + catch (Exception e) + { + Output.WriteLine(Proxy.SnapshotStdErr() ?? $"Proxy is null while attempting to snapshot stderr. Facing exception during start record.{e.ToString()}"); + throw; + } + } + + await Task.CompletedTask; + } + + private async Task StopRecordOrPlayback() + { + if (TestMode is TestMode.Live) + { + return; + } + + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized."); + } + + if (TestMode == TestMode.Playback) + { + await Proxy.Client.StopPlaybackAsync("placeholder-ignore").ConfigureAwait(false); + } + else if (TestMode == TestMode.Record) + { + Proxy.Client.StopRecord("placeholder-ignore", TestVariables); + } + await Task.CompletedTask; + } + + private static string TryGetCurrentTestName() + { + var name = TestContext.Current?.Test?.TestCase.TestCaseDisplayName; + if (string.IsNullOrWhiteSpace(name)) + { + throw new InvalidOperationException("Test name is not available. Recording requires a valid test name."); + } + return name; + } + + private string GetSessionFilePath(string displayName) + { + var sanitized = RecordingPathResolver.Sanitize(displayName); + var dir = PathResolver.GetSessionDirectory(GetType(), variantSuffix: null); + var fileName = RecordingPathResolver.BuildFileName(sanitized, IsAsync, VersionQualifier); + var fullPath = Path.Combine(dir, fileName).Replace('\\', '/'); + return fullPath; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs new file mode 100644 index 0000000000..6b63c7bd2c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Azure.Mcp.Tests.Generated; + +namespace Azure.Mcp.Tests.Client; + +/// +/// Lightweight test-proxy process manager used per test class to start/stop the Azure SDK test proxy. +/// This version intentionally avoids dependencies on prior internal abstractions that were missing +/// (e.g. TestEnvironment / ProcessTracker) while still providing stderr/stdout capture for failed tests. +/// +public sealed class TestProxy(bool debug = false) : IDisposable +{ + private readonly bool _debug = debug; + public StringBuilder stderr = new(); + public readonly StringBuilder stdout = new(); + private Process? _process; + private CancellationTokenSource? _cts; + private int? _httpPort; + private bool _disposed; + + public string BaseUri => _httpPort is int p ? $"http://127.0.0.1:{p}/" : throw new InvalidOperationException("Proxy not started"); + + public TestProxyClient Client { get; private set; } = default!; + public TestProxyAdminClient AdminClient { get; private set; } = default!; + + private static string? _cachedRootDir; + private static string? _cachedExecutable; + private static string? _cachedVersion; + + private async Task _getClient() + { + if (_cachedExecutable != null) + { + return _cachedExecutable; + } + + var proxyDir = GetProxyDirectory(); + var version = GetTargetVersion(); + + if (CheckProxyVersion(proxyDir, version)) + { + _cachedExecutable = FindExecutableInDirectory(proxyDir); + return _cachedExecutable; + } + + var assetName = GetAssetNameForPlatform(); + var url = $"https://github.com/Azure/azure-sdk-tools/releases/download/Azure.Sdk.Tools.TestProxy_{version}/{assetName}"; + var downloadPath = Path.Combine(proxyDir, assetName); + if (!File.Exists(downloadPath)) + { + using var client = new HttpClient(); + var bytes = await client.GetByteArrayAsync(url); + await File.WriteAllBytesAsync(downloadPath, bytes); + // record the downloaded version right here so we don't need to parse anything other than what + // is in this folder later + await File.WriteAllBytesAsync(Path.Combine(proxyDir, "version.txt"), Encoding.UTF8.GetBytes(version)); + } + + // if we've gotten to here then we need to decompress + if (assetName.EndsWith(".tar.gz")) + { + await using var compressedStream = File.OpenRead(downloadPath); + using var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress, leaveOpen: false); + TarFile.ExtractToDirectory(gzipStream, proxyDir, overwriteFiles: true); + } + else + { + ZipFile.ExtractToDirectory(downloadPath, proxyDir, overwriteFiles: true); + } + + _cachedExecutable = FindExecutableInDirectory(proxyDir); + + return _cachedExecutable; + } + + private bool CheckProxyVersion(string proxyDirectory, string version) + { + var versionFilePath = Path.Combine(proxyDirectory, "version.txt"); + if (File.Exists(versionFilePath)) + { + var existingVersion = File.ReadAllText(versionFilePath).Trim(); + if (existingVersion == version) + { + return true; + } + } + return false; + } + + private string GetAssetNameForPlatform() + { + var arch = RuntimeInformation.ProcessArchitecture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return (arch == Architecture.Arm64 ? "test-proxy-standalone-win-arm64.zip" : "test-proxy-standalone-win-x64.zip"); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return (arch == Architecture.Arm64 ? "test-proxy-standalone-osx-arm64.zip" : "test-proxy-standalone-osx-x64.zip"); + } + return (arch == Architecture.Arm64 ? "test-proxy-standalone-linux-arm64.tar.gz" : "test-proxy-standalone-linux-x64.tar.gz"); + } + + private string FindExecutableInDirectory(string dir) + { + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Azure.Sdk.Tools.TestProxy.exe" : "Azure.Sdk.Tools.TestProxy"; + foreach (var file in Directory.EnumerateFiles(dir, exeName, SearchOption.AllDirectories)) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + EnsureExecutable(file); + } + return file; + } + throw new FileNotFoundException($"Could not find {exeName} in {dir}"); + } + + private void EnsureExecutable(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + var mode = File.GetUnixFileMode(path); + if (!mode.HasFlag(UnixFileMode.UserExecute)) + { + File.SetUnixFileMode(path, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + } + + private string GetRootDirectory() + { + if (_cachedRootDir != null) + { + return _cachedRootDir; + } + var current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(); + while (current != null) + { + var gitPath = Path.Combine(current, ".git"); + if (File.Exists(gitPath) || Directory.Exists(gitPath)) + { + _cachedRootDir = current; + return _cachedRootDir; + } + current = Directory.GetParent(current)?.FullName; + } + + throw new InvalidOperationException("Could not find repository root (.git)"); + } + + private string GetTargetVersion() + { + if (_cachedVersion != null) + { + return _cachedVersion; + } + + var versionFile = Path.Combine(GetRootDirectory(), "eng", "common", "testproxy", "target_version.txt"); + if (!File.Exists(versionFile)) + { + throw new FileNotFoundException($"Test proxy version file not found: {versionFile}"); + } + _cachedVersion = File.ReadAllText(versionFile).Trim(); + return _cachedVersion; + } + + private string GetProxyDirectory() + { + var root = GetRootDirectory(); + var proxyDirectory = Path.Combine(root, ".proxy"); + if (!Directory.Exists(proxyDirectory)) + { + Directory.CreateDirectory(proxyDirectory); + } + return proxyDirectory; + } + + private string? GetExecutableFromAssetsDirectory() + { + var proxyDir = GetProxyDirectory(); + var toolDir = Path.Combine(proxyDir, "Azure.Sdk.Tools.TestProxy"); + + if (!Directory.Exists(toolDir)) + return null; + + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "test-proxy.exe" : "test-proxy"; + foreach (var file in Directory.EnumerateFiles(toolDir, exeName, SearchOption.AllDirectories)) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + EnsureExecutable(file); + } + return file; + } + + return null; + } + + public async Task Start(string repositoryRoot) + { + if (_process != null) + { + return; + } + + var proxyExe = GetExecutableFromAssetsDirectory() ?? await _getClient(); + + if (string.IsNullOrWhiteSpace(proxyExe) || !File.Exists(proxyExe)) + { + throw new InvalidOperationException("Unable to locate test-proxy executable."); + } + + var storageLocation = Environment.GetEnvironmentVariable("TEST_PROXY_STORAGE") ?? repositoryRoot; + var args = $"start --http-proxy --storage-location=\"{storageLocation}\""; + + ProcessStartInfo psi = new(proxyExe, args); + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.UseShellExecute = false; + psi.EnvironmentVariables["ASPNETCORE_URLS"] = "http://127.0.0.1:0"; // Let proxy choose free port + + _process = Process.Start(psi); + + if (_process == null) + { + throw new InvalidOperationException("Failed to start test proxy process."); + } + _cts = new CancellationTokenSource(); + _ = Task.Run(() => _pumpAsync(_process.StandardError, stderr, _cts.Token)); + _ = Task.Run(() => _pumpAsync(_process.StandardOutput, stdout, _cts.Token)); + + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("PROXY_MANUAL_START"))) + { + _httpPort = 5000; + } + else + { + _httpPort = _waitForHttpPort(TimeSpan.FromSeconds(15)); + } + + if (_httpPort is null) + { + throw new InvalidOperationException($"Failed to detect test-proxy HTTP port. Output: {stdout}\nErrors: {stderr}"); + } + + Client = new TestProxyClient(new Uri(BaseUri), new TestProxyClientOptions()); + AdminClient = Client.GetTestProxyAdminClient(); + } + + private static async Task _pumpAsync(StreamReader reader, StringBuilder sink, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !reader.EndOfStream) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line == null) + break; + lock (sink) + { + sink.AppendLine(line); + } + } + } + catch { /* swallow */ } + } + + private int? _waitForHttpPort(TimeSpan timeout) + { + var start = DateTime.UtcNow; + while ((DateTime.UtcNow - start) < timeout) + { + string text; + lock (stdout) + { + text = stdout.ToString(); + } + foreach (var line in text.Split('\n')) + { + if (_tryParsePort(line.Trim(), out var p)) + { + return p; + } + } + if (_process?.HasExited == true) + break; + Thread.Sleep(50); + } + return null; + } + + private static bool _tryParsePort(string line, out int port) + { + port = 0; + const string prefix = "Now listening on: http://127.0.0.1:"; + if (!line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + var remainder = line[prefix.Length..].TrimEnd('/', '\r'); + return int.TryParse(remainder, out port); + } + + /// + /// Snapshots the current stderr output from the testproxy. This is a destructive read; the internal buffer is cleared after the call. + /// + /// This means that if multiple tests fail in sequence, each test will only see the stderr output generated since the last call to SnapshotStdErr(), which means + /// we won't be seeing errors from previous test failures. This is intentional to ensure that each test only gets the relevant stderr output. + /// + /// + public string? SnapshotStdErr() + { + lock (stderr) + { + var toOutput = stderr.Length == 0 ? null : stderr.ToString(); + + stderr = new(); + + return toOutput; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _cts?.Cancel(); + _cts?.Dispose(); + + if (_process != null && !_process.HasExited) + { + _process.Kill(); + } + _process?.Dispose(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs new file mode 100644 index 0000000000..96cdca5e93 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class Argument + { + /// The value. + /// The name. + public static void AssertNotNull(T value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + } + + /// The value. + /// The name. + public static void AssertNotNull(T? value, string name) + where T : struct + { + if (!value.HasValue) + { + throw new ArgumentNullException(name); + } + } + + /// The value. + /// The name. + public static void AssertNotNullOrEmpty(IEnumerable value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value is ICollection collectionOfT && collectionOfT.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + if (value is ICollection collection && collection.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + using IEnumerator e = value.GetEnumerator(); + if (!e.MoveNext()) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + } + + /// The value. + /// The name. + public static void AssertNotNullOrEmpty(string value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value.Length == 0) + { + throw new ArgumentException("Value cannot be an empty string.", name); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs new file mode 100644 index 0000000000..5b57457029 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class BinaryContentHelper + { + /// + public static BinaryContent FromEnumerable(IEnumerable enumerable) + where T : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + foreach (var item in enumerable) + { + content.JsonWriter.WriteObjectValue(item, ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromEnumerable(IEnumerable enumerable) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + foreach (var item in enumerable) + { + if (item == null) + { + content.JsonWriter.WriteNullValue(); + } + else + { +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(item); +#else + using (JsonDocument document = JsonDocument.Parse(item)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + } + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromEnumerable(ReadOnlySpan span) + where T : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + int i = 0; + for (; i < span.Length; i++) + { + content.JsonWriter.WriteObjectValue(span[i], ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromDictionary(IDictionary dictionary) + where TValue : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartObject(); + foreach (var item in dictionary) + { + content.JsonWriter.WritePropertyName(item.Key); + content.JsonWriter.WriteObjectValue(item.Value, ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndObject(); + + return content; + } + + /// + public static BinaryContent FromDictionary(IDictionary dictionary) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartObject(); + foreach (var item in dictionary) + { + content.JsonWriter.WritePropertyName(item.Key); + if (item.Value == null) + { + content.JsonWriter.WriteNullValue(); + } + else + { +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + } + } + content.JsonWriter.WriteEndObject(); + + return content; + } + + /// + public static BinaryContent FromObject(object value) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteObjectValue(value, ModelSerializationExtensions.WireOptions); + return content; + } + + /// + public static BinaryContent FromObject(BinaryData value) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(value); +#else + using (JsonDocument document = JsonDocument.Parse(value)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + return content; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs new file mode 100644 index 0000000000..e67a377f91 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ChangeTrackingDictionary : IDictionary, IReadOnlyDictionary + where TKey : notnull + { + private IDictionary _innerDictionary; + + public ChangeTrackingDictionary() + { + } + + /// The inner dictionary. + public ChangeTrackingDictionary(IDictionary dictionary) + { + if (dictionary == null) + { + return; + } + _innerDictionary = new Dictionary(dictionary); + } + + /// The inner dictionary. + public ChangeTrackingDictionary(IReadOnlyDictionary dictionary) + { + if (dictionary == null) + { + return; + } + _innerDictionary = new Dictionary(); + foreach (var pair in dictionary) + { + _innerDictionary.Add(pair); + } + } + + /// Gets the IsUndefined. + public bool IsUndefined => _innerDictionary == null; + + /// Gets the Count. + public int Count => IsUndefined ? 0 : EnsureDictionary().Count; + + /// Gets the IsReadOnly. + public bool IsReadOnly => IsUndefined ? false : EnsureDictionary().IsReadOnly; + + /// Gets the Keys. + public ICollection Keys => IsUndefined ? Array.Empty() : EnsureDictionary().Keys; + + /// Gets the Values. + public ICollection Values => IsUndefined ? Array.Empty() : EnsureDictionary().Values; + + /// Gets or sets the value associated with the specified key. + public TValue this[TKey key] + { + get + { + if (IsUndefined) + { + throw new KeyNotFoundException(nameof(key)); + } + return EnsureDictionary()[key]; + } + set + { + EnsureDictionary()[key] = value; + } + } + + /// Gets the Keys. + IEnumerable IReadOnlyDictionary.Keys => Keys; + + /// Gets the Values. + IEnumerable IReadOnlyDictionary.Values => Values; + + public IEnumerator> GetEnumerator() + { + if (IsUndefined) + { + IEnumerator> enumerateEmpty() + { + yield break; + } + return enumerateEmpty(); + } + return EnsureDictionary().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The item to add. + public void Add(KeyValuePair item) + { + EnsureDictionary().Add(item); + } + + public void Clear() + { + EnsureDictionary().Clear(); + } + + /// The item to search for. + public bool Contains(KeyValuePair item) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Contains(item); + } + + /// The array to copy. + /// The index. + public void CopyTo(KeyValuePair[] array, int index) + { + if (IsUndefined) + { + return; + } + EnsureDictionary().CopyTo(array, index); + } + + /// The item to remove. + public bool Remove(KeyValuePair item) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Remove(item); + } + + /// The key. + /// The value to add. + public void Add(TKey key, TValue value) + { + EnsureDictionary().Add(key, value); + } + + /// The key to search for. + public bool ContainsKey(TKey key) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().ContainsKey(key); + } + + /// The key. + public bool Remove(TKey key) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Remove(key); + } + + /// The key to search for. + /// The value. + public bool TryGetValue(TKey key, out TValue value) + { + if (IsUndefined) + { + value = default; + return false; + } + return EnsureDictionary().TryGetValue(key, out value); + } + + public IDictionary EnsureDictionary() + { + return _innerDictionary ??= new Dictionary(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs new file mode 100644 index 0000000000..767fb58764 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ChangeTrackingList : IList, IReadOnlyList + { + private IList _innerList; + + public ChangeTrackingList() + { + } + + /// The inner list. + public ChangeTrackingList(IList innerList) + { + if (innerList != null) + { + _innerList = innerList; + } + } + + /// The inner list. + public ChangeTrackingList(IReadOnlyList innerList) + { + if (innerList != null) + { + _innerList = innerList.ToList(); + } + } + + /// Gets the IsUndefined. + public bool IsUndefined => _innerList == null; + + /// Gets the Count. + public int Count => IsUndefined ? 0 : EnsureList().Count; + + /// Gets the IsReadOnly. + public bool IsReadOnly => IsUndefined ? false : EnsureList().IsReadOnly; + + /// Gets or sets the value associated with the specified key. + public T this[int index] + { + get + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + return EnsureList()[index]; + } + set + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + EnsureList()[index] = value; + } + } + + public void Reset() + { + _innerList = null; + } + + public IEnumerator GetEnumerator() + { + if (IsUndefined) + { + IEnumerator enumerateEmpty() + { + yield break; + } + return enumerateEmpty(); + } + return EnsureList().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The item to add. + public void Add(T item) + { + EnsureList().Add(item); + } + + public void Clear() + { + EnsureList().Clear(); + } + + /// The item. + public bool Contains(T item) + { + if (IsUndefined) + { + return false; + } + return EnsureList().Contains(item); + } + + /// The array to copy to. + /// The array index. + public void CopyTo(T[] array, int arrayIndex) + { + if (IsUndefined) + { + return; + } + EnsureList().CopyTo(array, arrayIndex); + } + + /// The item. + public bool Remove(T item) + { + if (IsUndefined) + { + return false; + } + return EnsureList().Remove(item); + } + + /// The item. + public int IndexOf(T item) + { + if (IsUndefined) + { + return -1; + } + return EnsureList().IndexOf(item); + } + + /// The inner list. + /// The item. + public void Insert(int index, T item) + { + EnsureList().Insert(index, item); + } + + /// The inner list. + public void RemoveAt(int index) + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + EnsureList().RemoveAt(index); + } + + public IList EnsureList() + { + return _innerList ??= new List(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs new file mode 100644 index 0000000000..d26a34c549 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Threading.Tasks; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class ClientPipelineExtensions + { + public static async ValueTask ProcessMessageAsync(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + await pipeline.SendAsync(message).ConfigureAwait(false); + + if (message.Response.IsError && (options?.ErrorOptions & ClientErrorBehaviors.NoThrow) != ClientErrorBehaviors.NoThrow) + { + throw await ClientResultException.CreateAsync(message.Response).ConfigureAwait(false); + } + + PipelineResponse response = message.BufferResponse ? message.Response : message.ExtractResponse(); + return response; + } + + public static PipelineResponse ProcessMessage(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + pipeline.Send(message); + + if (message.Response.IsError && (options?.ErrorOptions & ClientErrorBehaviors.NoThrow) != ClientErrorBehaviors.NoThrow) + { + throw new ClientResultException(message.Response); + } + + PipelineResponse response = message.BufferResponse ? message.Response : message.ExtractResponse(); + return response; + } + + public static async ValueTask> ProcessHeadAsBoolMessageAsync(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + PipelineResponse response = await pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false); + switch (response.Status) + { + case >= 200 and < 300: + return ClientResult.FromValue(true, response); + case >= 400 and < 500: + return ClientResult.FromValue(false, response); + default: + return new ErrorResult(response, new ClientResultException(response)); + } + } + + public static ClientResult ProcessHeadAsBoolMessage(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + PipelineResponse response = pipeline.ProcessMessage(message, options); + switch (response.Status) + { + case >= 200 and < 300: + return ClientResult.FromValue(true, response); + case >= 400 and < 500: + return ClientResult.FromValue(false, response); + default: + return new ErrorResult(response, new ClientResultException(response)); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs new file mode 100644 index 0000000000..dbc10ea7cd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ClientUriBuilder + { + private UriBuilder _uriBuilder; + private StringBuilder _pathBuilder; + private StringBuilder _queryBuilder; + + public ClientUriBuilder() + { + } + + private UriBuilder UriBuilder => _uriBuilder ??= new UriBuilder(); + + private StringBuilder PathBuilder => _pathBuilder ??= new StringBuilder(UriBuilder.Path); + + private StringBuilder QueryBuilder => _queryBuilder ??= new StringBuilder(UriBuilder.Query); + + public void Reset(Uri uri) + { + _uriBuilder = new UriBuilder(uri); + _pathBuilder = new StringBuilder(UriBuilder.Path); + _queryBuilder = new StringBuilder(UriBuilder.Query); + } + + public void AppendPath(string value, bool escape) + { + if (escape) + { + value = Uri.EscapeDataString(value); + } + if (PathBuilder.Length > 0 && PathBuilder[PathBuilder.Length - 1] == '/' && value[0] == '/') + { + PathBuilder.Remove(PathBuilder.Length - 1, 1); + } + PathBuilder.Append(value); + UriBuilder.Path = PathBuilder.ToString(); + } + + public void AppendPath(bool value, bool escape = false) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(float value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(double value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(int value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(byte[] value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(DateTimeOffset value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(TimeSpan value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(Guid value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(long value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPathDelimited(IEnumerable value, string delimiter, string format = null, bool escape = true) + { + delimiter ??= ","; + IEnumerable stringValues = value.Select(v => TypeFormatters.ConvertToString(v, format)); + AppendPath(string.Join(delimiter, stringValues), escape); + } + + public void AppendQuery(string name, string value, bool escape) + { + if (QueryBuilder.Length > 0) + { + QueryBuilder.Append('&'); + } + if (escape) + { + value = Uri.EscapeDataString(value); + } + QueryBuilder.Append(name); + QueryBuilder.Append('='); + QueryBuilder.Append(value); + } + + public void AppendQuery(string name, bool value, bool escape = false) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, float value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, DateTimeOffset value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, TimeSpan value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, double value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, decimal value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, int value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, long value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, TimeSpan value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, byte[] value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, Guid value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQueryDelimited(string name, IEnumerable value, string delimiter, string format = null, bool escape = true) + { + delimiter ??= ","; + IEnumerable stringValues = value.Select(v => TypeFormatters.ConvertToString(v, format)); + AppendQuery(name, string.Join(delimiter, stringValues), escape); + } + + public Uri ToUri() + { + if (_pathBuilder != null) + { + UriBuilder.Path = _pathBuilder.ToString(); + } + if (_queryBuilder != null) + { + UriBuilder.Query = _queryBuilder.ToString(); + } + return UriBuilder.Uri; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs new file mode 100644 index 0000000000..c5b2d69896 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Property | AttributeTargets.Field))] + internal partial class CodeGenMemberAttribute : CodeGenTypeAttribute + { + /// The original name of the member. + public CodeGenMemberAttribute(string originalName) : base(originalName) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs new file mode 100644 index 0000000000..ea3ab406b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Struct), AllowMultiple = true, Inherited = true)] + internal partial class CodeGenSerializationAttribute : Attribute + { + /// The property name which these hooks apply to. + public CodeGenSerializationAttribute(string propertyName) + { + PropertyName = propertyName; + } + + /// The property name which these hooks apply to. + /// The serialization name of the property. + public CodeGenSerializationAttribute(string propertyName, string propertySerializationName) + { + PropertyName = propertyName; + PropertySerializationName = propertySerializationName; + } + + /// Gets or sets the property name which these hooks should apply to. + public string PropertyName { get; } + + /// Gets or sets the serialization name of the property. + public string PropertySerializationName { get; set; } + + /// + /// Gets or sets the method name to use when serializing the property value (property name excluded). + /// The signature of the serialization hook method must be or compatible with when invoking: private void SerializeHook(Utf8JsonWriter writer); + /// + public string SerializationValueHook { get; set; } + + /// + /// Gets or sets the method name to use when deserializing the property value from the JSON. + /// private static void DeserializationHook(JsonProperty property, ref TypeOfTheProperty propertyValue); // if the property is required + /// private static void DeserializationHook(JsonProperty property, ref Optional<TypeOfTheProperty> propertyValue); // if the property is optional + /// + public string DeserializationValueHook { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs new file mode 100644 index 0000000000..4011f94f0d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Enum | AttributeTargets.Struct), AllowMultiple = true)] + internal partial class CodeGenSuppressAttribute : Attribute + { + /// The member to suppress. + /// The types of the parameters of the member. + public CodeGenSuppressAttribute(string member, params Type[] parameters) + { + Member = member; + Parameters = parameters; + } + + /// Gets the Member. + public string Member { get; } + + /// Gets the Parameters. + public Type[] Parameters { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs new file mode 100644 index 0000000000..b0a76c87e1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Enum | AttributeTargets.Struct))] + internal partial class CodeGenTypeAttribute : Attribute + { + /// The original name of the type. + public CodeGenTypeAttribute(string originalName) + { + OriginalName = originalName; + } + + /// Gets the OriginalName. + public string OriginalName { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs new file mode 100644 index 0000000000..3fc9879859 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ErrorResult : ClientResult + { + private readonly PipelineResponse _response; + private readonly ClientResultException _exception; + + public ErrorResult(PipelineResponse response, ClientResultException exception) : base(default, response) + { + _response = response; + _exception = exception; + } + + /// Gets the Value. + public override T Value => throw _exception; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs new file mode 100644 index 0000000000..4ddf09d68e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class ModelSerializationExtensions + { + internal static readonly ModelReaderWriterOptions WireOptions = new ModelReaderWriterOptions("W"); + internal static readonly JsonDocumentOptions JsonDocumentOptions = new JsonDocumentOptions + { + MaxDepth = 256 + }; + + public static object GetObject(this JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + if (element.TryGetInt32(out int intValue)) + { + return intValue; + } + if (element.TryGetInt64(out long longValue)) + { + return longValue; + } + return element.GetDouble(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Undefined: + case JsonValueKind.Null: + return null; + case JsonValueKind.Object: + Dictionary dictionary = new Dictionary(); + foreach (var jsonProperty in element.EnumerateObject()) + { + dictionary.Add(jsonProperty.Name, jsonProperty.Value.GetObject()); + } + return dictionary; + case JsonValueKind.Array: + List list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(item.GetObject()); + } + return list.ToArray(); + default: + throw new NotSupportedException($"Not supported value kind {element.ValueKind}"); + } + } + + public static byte[] GetBytesFromBase64(this JsonElement element, string format) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + + return format switch + { + "U" => TypeFormatters.FromBase64UrlString(element.GetRequiredString()), + "D" => element.GetBytesFromBase64(), + _ => throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)) + }; + } + + public static DateTimeOffset GetDateTimeOffset(this JsonElement element, string format) => format switch + { + "U" when element.ValueKind == JsonValueKind.Number => DateTimeOffset.FromUnixTimeSeconds(element.GetInt64()), + _ => TypeFormatters.ParseDateTimeOffset(element.GetString(), format) + }; + + public static TimeSpan GetTimeSpan(this JsonElement element, string format) => TypeFormatters.ParseTimeSpan(element.GetString(), format); + + public static char GetChar(this JsonElement element) + { + if (element.ValueKind == JsonValueKind.String) + { + string text = element.GetString(); + if (text == null || text.Length != 1) + { + throw new NotSupportedException($"Cannot convert \"{text}\" to a char"); + } + return text[0]; + } + else + { + throw new NotSupportedException($"Cannot convert {element.ValueKind} to a char"); + } + } + + [Conditional("DEBUG")] + public static void ThrowNonNullablePropertyIsNull(this JsonProperty @property) + { + throw new JsonException($"A property '{@property.Name}' defined as non-nullable but received as null from the service. This exception only happens in DEBUG builds of the library and would be ignored in the release build"); + } + + public static string GetRequiredString(this JsonElement element) + { + string value = element.GetString(); + if (value == null) + { + throw new InvalidOperationException($"The requested operation requires an element of type 'String', but the target element has type '{element.ValueKind}'."); + } + return value; + } + + public static void WriteStringValue(this Utf8JsonWriter writer, DateTimeOffset value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, DateTime value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, TimeSpan value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, char value) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } + + public static void WriteBase64StringValue(this Utf8JsonWriter writer, byte[] value, string format) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + switch (format) + { + case "U": + writer.WriteStringValue(TypeFormatters.ToBase64UrlString(value)); + break; + case "D": + writer.WriteBase64StringValue(value); + break; + default: + throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)); + } + } + + public static void WriteNumberValue(this Utf8JsonWriter writer, DateTimeOffset value, string format) + { + if (format != "U") + { + throw new ArgumentOutOfRangeException(nameof(format), "Only 'U' format is supported when writing a DateTimeOffset as a Number."); + } + writer.WriteNumberValue(value.ToUnixTimeSeconds()); + } + + public static void WriteObjectValue(this Utf8JsonWriter writer, T value, ModelReaderWriterOptions options = null) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case IJsonModel jsonModel: + jsonModel.Write(writer, options ?? WireOptions); + break; + case byte[] bytes: + writer.WriteBase64StringValue(bytes); + break; + case BinaryData bytes0: + writer.WriteBase64StringValue(bytes0); + break; + case JsonElement json: + json.WriteTo(writer); + break; + case int i: + writer.WriteNumberValue(i); + break; + case decimal d: + writer.WriteNumberValue(d); + break; + case double d0: + if (double.IsNaN(d0)) + { + writer.WriteStringValue("NaN"); + } + else + { + writer.WriteNumberValue(d0); + } + break; + case float f: + writer.WriteNumberValue(f); + break; + case long l: + writer.WriteNumberValue(l); + break; + case string s: + writer.WriteStringValue(s); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case Guid g: + writer.WriteStringValue(g); + break; + case DateTimeOffset dateTimeOffset: + writer.WriteStringValue(dateTimeOffset, "O"); + break; + case DateTime dateTime: + writer.WriteStringValue(dateTime, "O"); + break; + case IEnumerable> enumerable: + writer.WriteStartObject(); + foreach (var pair in enumerable) + { + writer.WritePropertyName(pair.Key); + writer.WriteObjectValue(pair.Value, options); + } + writer.WriteEndObject(); + break; + case IEnumerable objectEnumerable: + writer.WriteStartArray(); + foreach (var item in objectEnumerable) + { + writer.WriteObjectValue(item, options); + } + writer.WriteEndArray(); + break; + case TimeSpan timeSpan: + writer.WriteStringValue(timeSpan, "P"); + break; + default: + throw new NotSupportedException($"Not supported type {value.GetType()}"); + } + } + + public static void WriteObjectValue(this Utf8JsonWriter writer, object value, ModelReaderWriterOptions options = null) + { + writer.WriteObjectValue(value, options); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs new file mode 100644 index 0000000000..1146285bad --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.Collections.Generic; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class Optional + { + public static bool IsCollectionDefined(IEnumerable collection) + { + return !(collection is ChangeTrackingList changeTrackingList && changeTrackingList.IsUndefined); + } + + public static bool IsCollectionDefined(IDictionary collection) + { + return !(collection is ChangeTrackingDictionary changeTrackingDictionary && changeTrackingDictionary.IsUndefined); + } + + public static bool IsCollectionDefined(IReadOnlyDictionary collection) + { + return !(collection is ChangeTrackingDictionary changeTrackingDictionary && changeTrackingDictionary.IsUndefined); + } + + public static bool IsDefined(T? value) + where T : struct + { + return value.HasValue; + } + + public static bool IsDefined(object value) + { + return value != null; + } + + public static bool IsDefined(string value) + { + return value != null; + } + + public static bool IsDefined(JsonElement value) + { + return value.ValueKind != JsonValueKind.Undefined; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs new file mode 100644 index 0000000000..e99c9f5e3f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class TypeFormatters + { + private const string RoundtripZFormat = "yyyy-MM-ddTHH:mm:ss.fffffffZ"; + public const string DefaultNumberFormat = "G"; + + public static string ToString(bool value) => value ? "true" : "false"; + + public static string ToString(DateTime value, string format) => value.Kind switch + { + DateTimeKind.Utc => ToString((DateTimeOffset)value, format), + _ => throw new NotSupportedException($"DateTime {value} has a Kind of {value.Kind}. Generated clients require it to be UTC. You can call DateTime.SpecifyKind to change Kind property value to DateTimeKind.Utc.") + }; + + public static string ToString(DateTimeOffset value, string format) => format switch + { + "D" => value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "U" => value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + "O" => value.ToUniversalTime().ToString(RoundtripZFormat, CultureInfo.InvariantCulture), + "o" => value.ToUniversalTime().ToString(RoundtripZFormat, CultureInfo.InvariantCulture), + "R" => value.ToString("r", CultureInfo.InvariantCulture), + _ => value.ToString(format, CultureInfo.InvariantCulture) + }; + + public static string ToString(TimeSpan value, string format) => format switch + { + "P" => System.Xml.XmlConvert.ToString(value), + _ => value.ToString(format, CultureInfo.InvariantCulture) + }; + + public static string ToString(byte[] value, string format) => format switch + { + "U" => ToBase64UrlString(value), + "D" => Convert.ToBase64String(value), + _ => throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)) + }; + + public static string ToBase64UrlString(byte[] value) + { + int numWholeOrPartialInputBlocks = checked (value.Length + 2) / 3; + int size = checked (numWholeOrPartialInputBlocks * 4); + char[] output = new char[size]; + + int numBase64Chars = Convert.ToBase64CharArray(value, 0, value.Length, output, 0); + + int i = 0; + for (; i < numBase64Chars; i++) + { + char ch = output[i]; + if (ch == '+') + { + output[i] = '-'; + } + else + { + if (ch == '/') + { + output[i] = '_'; + } + else + { + if (ch == '=') + { + break; + } + } + } + } + + return new string(output, 0, i); + } + + public static byte[] FromBase64UrlString(string value) + { + int paddingCharsToAdd = (value.Length % 4) switch + { + 0 => 0, + 2 => 2, + 3 => 1, + _ => throw new InvalidOperationException("Malformed input") + }; + char[] output = new char[(value.Length + paddingCharsToAdd)]; + int i = 0; + for (; i < value.Length; i++) + { + char ch = value[i]; + if (ch == '-') + { + output[i] = '+'; + } + else + { + if (ch == '_') + { + output[i] = '/'; + } + else + { + output[i] = ch; + } + } + } + + for (; i < output.Length; i++) + { + output[i] = '='; + } + + return Convert.FromBase64CharArray(output, 0, output.Length); + } + + public static DateTimeOffset ParseDateTimeOffset(string value, string format) => format switch + { + "U" => DateTimeOffset.FromUnixTimeSeconds(long.Parse(value, CultureInfo.InvariantCulture)), + _ => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal) + }; + + public static TimeSpan ParseTimeSpan(string value, string format) => format switch + { + "P" => System.Xml.XmlConvert.ToTimeSpan(value), + _ => TimeSpan.ParseExact(value, format, CultureInfo.InvariantCulture) + }; + + public static string ConvertToString(object value, string format = null) => value switch + { + null => "null", + string s => s, + bool b => ToString(b), + int or float or double or long or decimal => ((IFormattable)value).ToString(DefaultNumberFormat, CultureInfo.InvariantCulture), + byte[] b0 when format != null => ToString(b0, format), + IEnumerable s0 => string.Join(",", s0), + DateTimeOffset dateTime when format != null => ToString(dateTime, format), + TimeSpan timeSpan when format != null => ToString(timeSpan, format), + TimeSpan timeSpan0 => System.Xml.XmlConvert.ToString(timeSpan0), + Guid guid => guid.ToString(), + BinaryData binaryData => ConvertToString(binaryData.ToArray(), format), + _ => value.ToString() + }; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs new file mode 100644 index 0000000000..a7cfad2882 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class Utf8JsonBinaryContent : BinaryContent + { + private readonly MemoryStream _stream; + private readonly BinaryContent _content; + + public Utf8JsonBinaryContent() + { + _stream = new MemoryStream(); + _content = Create(_stream); + JsonWriter = new Utf8JsonWriter(_stream); + } + + /// Gets the JsonWriter. + public Utf8JsonWriter JsonWriter { get; } + + /// The stream containing the data to be written. + /// The cancellation token to use. + public override async Task WriteToAsync(Stream stream, CancellationToken cancellationToken = default) + { + await JsonWriter.FlushAsync().ConfigureAwait(false); + await _content.WriteToAsync(stream, cancellationToken).ConfigureAwait(false); + } + + /// The stream containing the data to be written. + /// The cancellation token to use. + public override void WriteTo(Stream stream, CancellationToken cancellationToken = default) + { + JsonWriter.Flush(); + _content.WriteTo(stream, cancellationToken); + } + + /// + public override bool TryComputeLength(out long length) + { + length = JsonWriter.BytesCommitted + JsonWriter.BytesPending; + return true; + } + + public override void Dispose() + { + JsonWriter.Dispose(); + _content.Dispose(); + _stream.Dispose(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs new file mode 100644 index 0000000000..7d385dc478 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.Collections.Generic; +using System.Linq; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// A factory class for creating instances of the models for mocking. + public static partial class MicrosoftClientModelTestFrameworkModelFactory + { + /// The TestProxyStartInformation. + /// + /// + /// A new instance for mocking. + public static TestProxyStartInformation TestProxyStartInformation(string xRecordingFile = default, string xRecordingAssetsFile = default) + { + return new TestProxyStartInformation(xRecordingFile, xRecordingAssetsFile, additionalBinaryDataProperties: null); + } + + /// The CustomDefaultMatcher. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static CustomDefaultMatcher CustomDefaultMatcher(bool? compareBodies = default, string excludedHeaders = default, string ignoredHeaders = default, bool? ignoreQueryOrdering = default, string ignoredQueryParameters = default) + { + return new CustomDefaultMatcher( + compareBodies, + excludedHeaders, + ignoredHeaders, + ignoreQueryOrdering, + ignoredQueryParameters, + additionalBinaryDataProperties: null); + } + + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + /// + /// A new instance for mocking. + public static SanitizerAddition SanitizerAddition(string name = default) + { + return new UnknownSanitizerAddition(name.ToSanitizerType(), additionalBinaryDataProperties: null); + } + + /// The BodyKeySanitizer. + /// + /// A new instance for mocking. + public static BodyKeySanitizer BodyKeySanitizer(BodyKeySanitizerBody body = default) + { + return new BodyKeySanitizer(SanitizerType.BodyKeySanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyKeySanitizerBody. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static BodyKeySanitizerBody BodyKeySanitizerBody(string jsonPath = default, string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new BodyKeySanitizerBody( + jsonPath, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties: null); + } + + /// The ApplyCondition. + /// + /// A new instance for mocking. + public static ApplyCondition ApplyCondition(string uriRegex = default) + { + return new ApplyCondition(uriRegex, additionalBinaryDataProperties: null); + } + + /// The BodyRegexSanitizer. + /// + /// A new instance for mocking. + public static BodyRegexSanitizer BodyRegexSanitizer(BodyRegexSanitizerBody body = default) + { + return new BodyRegexSanitizer(SanitizerType.BodyRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static BodyRegexSanitizerBody BodyRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new BodyRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The BodyStringSanitizer. + /// + /// A new instance for mocking. + public static BodyStringSanitizer BodyStringSanitizer(BodyStringSanitizerBody body = default) + { + return new BodyStringSanitizer(SanitizerType.BodyStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static BodyStringSanitizerBody BodyStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new BodyStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The GeneralRegexSanitizer. + /// + /// A new instance for mocking. + public static GeneralRegexSanitizer GeneralRegexSanitizer(GeneralRegexSanitizerBody body = default) + { + return new GeneralRegexSanitizer(SanitizerType.GeneralRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The GeneralRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static GeneralRegexSanitizerBody GeneralRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new GeneralRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The GeneralStringSanitizer. + /// + /// A new instance for mocking. + public static GeneralStringSanitizer GeneralStringSanitizer(GeneralStringSanitizerBody body = default) + { + return new GeneralStringSanitizer(SanitizerType.GeneralStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The GeneralStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static GeneralStringSanitizerBody GeneralStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new GeneralStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The HeaderRegexSanitizer. + /// + /// A new instance for mocking. + public static HeaderRegexSanitizer HeaderRegexSanitizer(HeaderRegexSanitizerBody body = default) + { + return new HeaderRegexSanitizer(SanitizerType.HeaderRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The HeaderRegexSanitizerBody. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static HeaderRegexSanitizerBody HeaderRegexSanitizerBody(string key = default, string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new HeaderRegexSanitizerBody( + key, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties: null); + } + + /// The HeaderStringSanitizer. + /// + /// A new instance for mocking. + public static HeaderStringSanitizer HeaderStringSanitizer(HeaderStringSanitizerBody body = default) + { + return new HeaderStringSanitizer(SanitizerType.HeaderStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The HeaderStringSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static HeaderStringSanitizerBody HeaderStringSanitizerBody(string key = default, string target = default, string value = default, ApplyCondition condition = default) + { + return new HeaderStringSanitizerBody(key, target, value, condition, additionalBinaryDataProperties: null); + } + + /// The OAuthResponseSanitizer. + /// A new instance for mocking. + public static OAuthResponseSanitizer OAuthResponseSanitizer() + { + return new OAuthResponseSanitizer(SanitizerType.OAuthResponseSanitizer, additionalBinaryDataProperties: null); + } + + /// The RegexEntrySanitizer. + /// + /// A new instance for mocking. + public static RegexEntrySanitizer RegexEntrySanitizer(RegexEntrySanitizerBody body = default) + { + return new RegexEntrySanitizer(SanitizerType.RegexEntrySanitizer, additionalBinaryDataProperties: null, body); + } + + /// The RegexEntrySanitizerBody. + /// + /// + /// A new instance for mocking. + public static RegexEntrySanitizerBody RegexEntrySanitizerBody(RegexEntryValues target = default, string regex = default) + { + return new RegexEntrySanitizerBody(target, regex, additionalBinaryDataProperties: null); + } + + /// The RemoveHeaderSanitizer. + /// + /// A new instance for mocking. + public static RemoveHeaderSanitizer RemoveHeaderSanitizer(RemoveHeaderSanitizerBody body = default) + { + return new RemoveHeaderSanitizer(SanitizerType.RemoveHeaderSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The RemoveHeaderSanitizerBody. + /// + /// A new instance for mocking. + public static RemoveHeaderSanitizerBody RemoveHeaderSanitizerBody(string headersForRemoval = default) + { + return new RemoveHeaderSanitizerBody(headersForRemoval, additionalBinaryDataProperties: null); + } + + /// The UriRegexSanitizer. + /// + /// A new instance for mocking. + public static UriRegexSanitizer UriRegexSanitizer(UriRegexSanitizerBody body = default) + { + return new UriRegexSanitizer(SanitizerType.UriRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static UriRegexSanitizerBody UriRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new UriRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The UriStringSanitizer. + /// + /// A new instance for mocking. + public static UriStringSanitizer UriStringSanitizer(UriStringSanitizerBody body = default) + { + return new UriStringSanitizer(SanitizerType.UriStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static UriStringSanitizerBody UriStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new UriStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The UriSubscriptionIdSanitizer. + /// + /// A new instance for mocking. + public static UriSubscriptionIdSanitizer UriSubscriptionIdSanitizer(UriSubscriptionIdSanitizerBody body = default) + { + return new UriSubscriptionIdSanitizer(SanitizerType.UriSubscriptionIdSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriSubscriptionIdSanitizerBody. + /// + /// + /// A new instance for mocking. + public static UriSubscriptionIdSanitizerBody UriSubscriptionIdSanitizerBody(string value = default, ApplyCondition condition = default) + { + return new UriSubscriptionIdSanitizerBody(value, condition, additionalBinaryDataProperties: null); + } + + /// The SanitizerList. + /// + /// A new instance for mocking. + public static SanitizerList SanitizerList(IEnumerable sanitizers = default) + { + sanitizers ??= new ChangeTrackingList(); + + return new SanitizerList(sanitizers.ToList(), additionalBinaryDataProperties: null); + } + + /// The RemovedSanitizers. + /// + /// A new instance for mocking. + public static RemovedSanitizers RemovedSanitizers(IEnumerable removed = default) + { + removed ??= new ChangeTrackingList(); + + return new RemovedSanitizers(removed.ToList(), additionalBinaryDataProperties: null); + } + + /// The RecordingOptions. + /// + /// + /// + /// + /// A new instance for mocking. + public static RecordingOptions RecordingOptions(bool? handleRedirects = default, string contextDirectory = default, StoreType? assetsStore = default, TransportCustomizations transport = default) + { + return new RecordingOptions(handleRedirects, contextDirectory, assetsStore, transport, additionalBinaryDataProperties: null); + } + + /// The TransportCustomizations. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static TransportCustomizations TransportCustomizations(bool? allowAutoRedirect = default, string tlsValidationCert = default, string tlsValidationCertHost = default, IEnumerable certificates = default, int? playbackResponseTime = default) + { + certificates ??= new ChangeTrackingList(); + + return new TransportCustomizations( + allowAutoRedirect, + tlsValidationCert, + tlsValidationCertHost, + certificates.ToList(), + playbackResponseTime, + additionalBinaryDataProperties: null); + } + + /// The TestProxyCertificate. + /// + /// + /// A new instance for mocking. + public static TestProxyCertificate TestProxyCertificate(string pemValue = default, string pemKey = default) + { + return new TestProxyCertificate(pemValue, pemKey, additionalBinaryDataProperties: null); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs new file mode 100644 index 0000000000..7e3b3b6b50 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The ApplyCondition. + public partial class ApplyCondition : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal ApplyCondition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(ApplyCondition)} does not support writing '{format}' format."); + } + writer.WritePropertyName("UriRegex"u8); + writer.WriteStringValue(UriRegex); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + ApplyCondition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual ApplyCondition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(ApplyCondition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeApplyCondition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static ApplyCondition DeserializeApplyCondition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string uriRegex = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("UriRegex"u8)) + { + uriRegex = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new ApplyCondition(uriRegex, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(ApplyCondition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + ApplyCondition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual ApplyCondition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeApplyCondition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(ApplyCondition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs new file mode 100644 index 0000000000..4e211f6cbf --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The ApplyCondition. + public partial class ApplyCondition + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public ApplyCondition(string uriRegex) + { + Argument.AssertNotNull(uriRegex, nameof(uriRegex)); + + UriRegex = uriRegex; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal ApplyCondition(string uriRegex, IDictionary additionalBinaryDataProperties) + { + UriRegex = uriRegex; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the UriRegex. + public string UriRegex { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs new file mode 100644 index 0000000000..ec35451f33 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizer. + public partial class BodyKeySanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyKeySanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyKeySanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyKeySanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyKeySanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyKeySanitizer DeserializeBodyKeySanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyKeySanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyKeySanitizerBody.DeserializeBodyKeySanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyKeySanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyKeySanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyKeySanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyKeySanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs new file mode 100644 index 0000000000..1dde62897a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizer. + public partial class BodyKeySanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyKeySanitizer(BodyKeySanitizerBody body) : base(SanitizerType.BodyKeySanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyKeySanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyKeySanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyKeySanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs new file mode 100644 index 0000000000..70f3d466bf --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizerBody. + public partial class BodyKeySanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyKeySanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("jsonPath"u8); + writer.WriteStringValue(JsonPath); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyKeySanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyKeySanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyKeySanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyKeySanitizerBody DeserializeBodyKeySanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string jsonPath = default; + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("jsonPath"u8)) + { + jsonPath = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyKeySanitizerBody( + jsonPath, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyKeySanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyKeySanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyKeySanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs new file mode 100644 index 0000000000..c5733ad0f5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizerBody. + public partial class BodyKeySanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public BodyKeySanitizerBody(string jsonPath) + { + Argument.AssertNotNull(jsonPath, nameof(jsonPath)); + + JsonPath = jsonPath; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyKeySanitizerBody(string jsonPath, string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + JsonPath = jsonPath; + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the JsonPath. + public string JsonPath { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..6e22629eee --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizer. + public partial class BodyRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyRegexSanitizer DeserializeBodyRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyRegexSanitizerBody.DeserializeBodyRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs new file mode 100644 index 0000000000..4e4b509b20 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizer. + public partial class BodyRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyRegexSanitizer(BodyRegexSanitizerBody body) : base(SanitizerType.BodyRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..eede767cc8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizerBody. + public partial class BodyRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyRegexSanitizerBody DeserializeBodyRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs new file mode 100644 index 0000000000..fb414d491b --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizerBody. + public partial class BodyRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public BodyRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..ed163f1b2f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizer. + public partial class BodyStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyStringSanitizer DeserializeBodyStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyStringSanitizerBody.DeserializeBodyStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs new file mode 100644 index 0000000000..5bbd3f4ce9 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizer. + public partial class BodyStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyStringSanitizer(BodyStringSanitizerBody body) : base(SanitizerType.BodyStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..62da1e2e05 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizerBody. + public partial class BodyStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyStringSanitizerBody DeserializeBodyStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs new file mode 100644 index 0000000000..4c750b7421 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizerBody. + public partial class BodyStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public BodyStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs new file mode 100644 index 0000000000..de61f5f163 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The CustomDefaultMatcher. + public partial class CustomDefaultMatcher : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(CompareBodies)) + { + writer.WritePropertyName("compareBodies"u8); + writer.WriteBooleanValue(CompareBodies.Value); + } + if (Optional.IsDefined(ExcludedHeaders)) + { + writer.WritePropertyName("excludedHeaders"u8); + writer.WriteStringValue(ExcludedHeaders); + } + if (Optional.IsDefined(IgnoredHeaders)) + { + writer.WritePropertyName("ignoredHeaders"u8); + writer.WriteStringValue(IgnoredHeaders); + } + if (Optional.IsDefined(IgnoreQueryOrdering)) + { + writer.WritePropertyName("ignoreQueryOrdering"u8); + writer.WriteBooleanValue(IgnoreQueryOrdering.Value); + } + if (Optional.IsDefined(IgnoredQueryParameters)) + { + writer.WritePropertyName("ignoredQueryParameters"u8); + writer.WriteStringValue(IgnoredQueryParameters); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + CustomDefaultMatcher IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual CustomDefaultMatcher JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeCustomDefaultMatcher(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static CustomDefaultMatcher DeserializeCustomDefaultMatcher(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? compareBodies = default; + string excludedHeaders = default; + string ignoredHeaders = default; + bool? ignoreQueryOrdering = default; + string ignoredQueryParameters = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("compareBodies"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + compareBodies = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("excludedHeaders"u8)) + { + excludedHeaders = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("ignoredHeaders"u8)) + { + ignoredHeaders = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("ignoreQueryOrdering"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + ignoreQueryOrdering = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("ignoredQueryParameters"u8)) + { + ignoredQueryParameters = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new CustomDefaultMatcher( + compareBodies, + excludedHeaders, + ignoredHeaders, + ignoreQueryOrdering, + ignoredQueryParameters, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + CustomDefaultMatcher IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual CustomDefaultMatcher PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeCustomDefaultMatcher(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(CustomDefaultMatcher customDefaultMatcher) + { + if (customDefaultMatcher == null) + { + return null; + } + return BinaryContent.Create(customDefaultMatcher, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs new file mode 100644 index 0000000000..540165872d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The CustomDefaultMatcher. + public partial class CustomDefaultMatcher + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public CustomDefaultMatcher() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal CustomDefaultMatcher(bool? compareBodies, string excludedHeaders, string ignoredHeaders, bool? ignoreQueryOrdering, string ignoredQueryParameters, IDictionary additionalBinaryDataProperties) + { + CompareBodies = compareBodies; + ExcludedHeaders = excludedHeaders; + IgnoredHeaders = ignoredHeaders; + IgnoreQueryOrdering = ignoreQueryOrdering; + IgnoredQueryParameters = ignoredQueryParameters; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the CompareBodies. + public bool? CompareBodies { get; set; } + + /// Gets or sets the ExcludedHeaders. + public string ExcludedHeaders { get; set; } + + /// Gets or sets the IgnoredHeaders. + public string IgnoredHeaders { get; set; } + + /// Gets or sets the IgnoreQueryOrdering. + public bool? IgnoreQueryOrdering { get; set; } + + /// Gets or sets the IgnoredQueryParameters. + public string IgnoredQueryParameters { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..2fef611890 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizer. + public partial class GeneralRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (GeneralRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralRegexSanitizer DeserializeGeneralRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + GeneralRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = GeneralRegexSanitizerBody.DeserializeGeneralRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (GeneralRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs new file mode 100644 index 0000000000..8637da98b5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizer. + public partial class GeneralRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public GeneralRegexSanitizer(GeneralRegexSanitizerBody body) : base(SanitizerType.GeneralRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal GeneralRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, GeneralRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public GeneralRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..3ae47c9d70 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizerBody. + public partial class GeneralRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual GeneralRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralRegexSanitizerBody DeserializeGeneralRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual GeneralRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs new file mode 100644 index 0000000000..1a7f572921 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizerBody. + public partial class GeneralRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public GeneralRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal GeneralRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..f89cb6169d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizer. + public partial class GeneralStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (GeneralStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralStringSanitizer DeserializeGeneralStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + GeneralStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = GeneralStringSanitizerBody.DeserializeGeneralStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (GeneralStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs new file mode 100644 index 0000000000..85a63eb855 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizer. + public partial class GeneralStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public GeneralStringSanitizer(GeneralStringSanitizerBody body) : base(SanitizerType.GeneralStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal GeneralStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, GeneralStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public GeneralStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..efbadc1582 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizerBody. + public partial class GeneralStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual GeneralStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralStringSanitizerBody DeserializeGeneralStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual GeneralStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs new file mode 100644 index 0000000000..8613c1072c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizerBody. + public partial class GeneralStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public GeneralStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal GeneralStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..58b79dba71 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizer. + public partial class HeaderRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (HeaderRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderRegexSanitizer DeserializeHeaderRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + HeaderRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = HeaderRegexSanitizerBody.DeserializeHeaderRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (HeaderRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs new file mode 100644 index 0000000000..6b6f45e0fd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizer. + public partial class HeaderRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public HeaderRegexSanitizer(HeaderRegexSanitizerBody body) : base(SanitizerType.HeaderRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal HeaderRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, HeaderRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public HeaderRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..d667743b5a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizerBody. + public partial class HeaderRegexSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderRegexSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("key"u8); + writer.WriteStringValue(Key); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual HeaderRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderRegexSanitizerBody DeserializeHeaderRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string key = default; + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("key"u8)) + { + key = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderRegexSanitizerBody( + key, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual HeaderRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs new file mode 100644 index 0000000000..c8cf3e3c03 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizerBody. + public partial class HeaderRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public HeaderRegexSanitizerBody(string key) + { + Argument.AssertNotNull(key, nameof(key)); + + Key = key; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal HeaderRegexSanitizerBody(string key, string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Key = key; + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Key. + public string Key { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..9dcdc126e5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizer. + public partial class HeaderStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (HeaderStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderStringSanitizer DeserializeHeaderStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + HeaderStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = HeaderStringSanitizerBody.DeserializeHeaderStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (HeaderStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs new file mode 100644 index 0000000000..0a9eb7227a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizer. + public partial class HeaderStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public HeaderStringSanitizer(HeaderStringSanitizerBody body) : base(SanitizerType.HeaderStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal HeaderStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, HeaderStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public HeaderStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..d8713205fb --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizerBody. + public partial class HeaderStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("key"u8); + writer.WriteStringValue(Key); + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual HeaderStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderStringSanitizerBody DeserializeHeaderStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string key = default; + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("key"u8)) + { + key = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderStringSanitizerBody(key, target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual HeaderStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs new file mode 100644 index 0000000000..bba1ae0d62 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizerBody. + public partial class HeaderStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// or is null. + public HeaderStringSanitizerBody(string key, string target) + { + Argument.AssertNotNull(key, nameof(key)); + Argument.AssertNotNull(target, nameof(target)); + + Key = key; + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal HeaderStringSanitizerBody(string key, string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Key = key; + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Key. + public string Key { get; } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs new file mode 100644 index 0000000000..cd7f9a0dbd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class MatcherTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this MatcherType value) => value switch + { + MatcherType.BodilessMatcher => "BodilessMatcher", + MatcherType.CustomDefaultMatcher => "CustomDefaultMatcher", + MatcherType.HeaderlessMatcher => "HeaderlessMatcher", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown MatcherType value.") + }; + + /// The value to deserialize. + public static MatcherType ToMatcherType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodilessMatcher")) + { + return MatcherType.BodilessMatcher; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "CustomDefaultMatcher")) + { + return MatcherType.CustomDefaultMatcher; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderlessMatcher")) + { + return MatcherType.HeaderlessMatcher; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown MatcherType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs new file mode 100644 index 0000000000..35055c2854 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum MatcherType + { + /// BodilessMatcher. + BodilessMatcher, + /// CustomDefaultMatcher. + CustomDefaultMatcher, + /// HeaderlessMatcher. + HeaderlessMatcher + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs new file mode 100644 index 0000000000..bc3ca546dd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + /// + /// Context class which will be filled in by the System.ClientModel.SourceGeneration. + /// For more information + /// + [ModelReaderWriterBuildable(typeof(ApplyCondition))] + [ModelReaderWriterBuildable(typeof(BodyKeySanitizer))] + [ModelReaderWriterBuildable(typeof(BodyKeySanitizerBody))] + [ModelReaderWriterBuildable(typeof(BodyRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(BodyRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(BodyStringSanitizer))] + [ModelReaderWriterBuildable(typeof(BodyStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(CustomDefaultMatcher))] + [ModelReaderWriterBuildable(typeof(GeneralRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(GeneralRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(GeneralStringSanitizer))] + [ModelReaderWriterBuildable(typeof(GeneralStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(HeaderRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(HeaderRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(HeaderStringSanitizer))] + [ModelReaderWriterBuildable(typeof(HeaderStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(OAuthResponseSanitizer))] + [ModelReaderWriterBuildable(typeof(RecordingOptions))] + [ModelReaderWriterBuildable(typeof(RegexEntrySanitizer))] + [ModelReaderWriterBuildable(typeof(RegexEntrySanitizerBody))] + [ModelReaderWriterBuildable(typeof(RemovedSanitizers))] + [ModelReaderWriterBuildable(typeof(RemoveHeaderSanitizer))] + [ModelReaderWriterBuildable(typeof(RemoveHeaderSanitizerBody))] + [ModelReaderWriterBuildable(typeof(SanitizerAddition))] + [ModelReaderWriterBuildable(typeof(SanitizerList))] + [ModelReaderWriterBuildable(typeof(TestProxyCertificate))] + [ModelReaderWriterBuildable(typeof(TestProxyStartInformation))] + [ModelReaderWriterBuildable(typeof(TransportCustomizations))] + [ModelReaderWriterBuildable(typeof(UnknownSanitizerAddition))] + [ModelReaderWriterBuildable(typeof(UriRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(UriRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(UriStringSanitizer))] + [ModelReaderWriterBuildable(typeof(UriStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(UriSubscriptionIdSanitizer))] + [ModelReaderWriterBuildable(typeof(UriSubscriptionIdSanitizerBody))] + public partial class MicrosoftClientModelTestFrameworkContext : ModelReaderWriterContext + { + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs new file mode 100644 index 0000000000..12b13938f9 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The OAuthResponseSanitizer. + public partial class OAuthResponseSanitizer : SanitizerAddition, IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + OAuthResponseSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (OAuthResponseSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeOAuthResponseSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static OAuthResponseSanitizer DeserializeOAuthResponseSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new OAuthResponseSanitizer(name, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + OAuthResponseSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (OAuthResponseSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeOAuthResponseSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs new file mode 100644 index 0000000000..7fd3113061 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The OAuthResponseSanitizer. + public partial class OAuthResponseSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + public OAuthResponseSanitizer() : base(SanitizerType.OAuthResponseSanitizer) + { + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal OAuthResponseSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties) : base(name, additionalBinaryDataProperties) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs new file mode 100644 index 0000000000..7fd45b1d2e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RecordingOptions. + public partial class RecordingOptions : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RecordingOptions)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(HandleRedirects)) + { + writer.WritePropertyName("HandleRedirects"u8); + writer.WriteBooleanValue(HandleRedirects.Value); + } + if (Optional.IsDefined(ContextDirectory)) + { + writer.WritePropertyName("ContextDirectory"u8); + writer.WriteStringValue(ContextDirectory); + } + if (Optional.IsDefined(AssetsStore)) + { + writer.WritePropertyName("AssetsStore"u8); + writer.WriteStringValue(AssetsStore.Value.ToSerialString()); + } + if (Optional.IsDefined(Transport)) + { + writer.WritePropertyName("Transport"u8); + writer.WriteObjectValue(Transport, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RecordingOptions IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RecordingOptions JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RecordingOptions)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRecordingOptions(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RecordingOptions DeserializeRecordingOptions(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? handleRedirects = default; + string contextDirectory = default; + StoreType? assetsStore = default; + TransportCustomizations transport = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("HandleRedirects"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + handleRedirects = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("ContextDirectory"u8)) + { + contextDirectory = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("AssetsStore"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + assetsStore = prop.Value.GetString().ToStoreType(); + continue; + } + if (prop.NameEquals("Transport"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + transport = TransportCustomizations.DeserializeTransportCustomizations(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RecordingOptions(handleRedirects, contextDirectory, assetsStore, transport, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RecordingOptions)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RecordingOptions IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RecordingOptions PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRecordingOptions(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RecordingOptions)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(RecordingOptions recordingOptions) + { + if (recordingOptions == null) + { + return null; + } + return BinaryContent.Create(recordingOptions, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs new file mode 100644 index 0000000000..5a1f1ff227 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RecordingOptions. + public partial class RecordingOptions + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public RecordingOptions() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal RecordingOptions(bool? handleRedirects, string contextDirectory, StoreType? assetsStore, TransportCustomizations transport, IDictionary additionalBinaryDataProperties) + { + HandleRedirects = handleRedirects; + ContextDirectory = contextDirectory; + AssetsStore = assetsStore; + Transport = transport; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the HandleRedirects. + public bool? HandleRedirects { get; set; } + + /// Gets or sets the ContextDirectory. + public string ContextDirectory { get; set; } + + /// Gets or sets the AssetsStore. + public StoreType? AssetsStore { get; set; } + + /// Gets or sets the Transport. + public TransportCustomizations Transport { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs new file mode 100644 index 0000000000..90246d339a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizer. + public partial class RegexEntrySanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RegexEntrySanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + RegexEntrySanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (RegexEntrySanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRegexEntrySanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RegexEntrySanitizer DeserializeRegexEntrySanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + RegexEntrySanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = RegexEntrySanitizerBody.DeserializeRegexEntrySanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RegexEntrySanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RegexEntrySanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (RegexEntrySanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRegexEntrySanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs new file mode 100644 index 0000000000..c6efbc7c72 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizer. + public partial class RegexEntrySanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public RegexEntrySanitizer(RegexEntrySanitizerBody body) : base(SanitizerType.RegexEntrySanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal RegexEntrySanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, RegexEntrySanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public RegexEntrySanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs new file mode 100644 index 0000000000..c061dd4c0e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizerBody. + public partial class RegexEntrySanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RegexEntrySanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target.ToSerialString()); + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RegexEntrySanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RegexEntrySanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRegexEntrySanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RegexEntrySanitizerBody DeserializeRegexEntrySanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + RegexEntryValues target = default; + string regex = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString().ToRegexEntryValues(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RegexEntrySanitizerBody(target, regex, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RegexEntrySanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RegexEntrySanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRegexEntrySanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs new file mode 100644 index 0000000000..288b5cd131 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizerBody. + public partial class RegexEntrySanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// is null. + public RegexEntrySanitizerBody(RegexEntryValues target, string regex) + { + Argument.AssertNotNull(regex, nameof(regex)); + + Target = target; + Regex = regex; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal RegexEntrySanitizerBody(RegexEntryValues target, string regex, IDictionary additionalBinaryDataProperties) + { + Target = target; + Regex = regex; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public RegexEntryValues Target { get; } + + /// Gets the Regex. + public string Regex { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs new file mode 100644 index 0000000000..0f737915f7 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class RegexEntryValuesExtensions + { + /// The value to serialize. + public static string ToSerialString(this RegexEntryValues value) => value switch + { + RegexEntryValues.Body => "body", + RegexEntryValues.Header => "header", + RegexEntryValues.Uri => "uri", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown RegexEntryValues value.") + }; + + /// The value to deserialize. + public static RegexEntryValues ToRegexEntryValues(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "body")) + { + return RegexEntryValues.Body; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "header")) + { + return RegexEntryValues.Header; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "uri")) + { + return RegexEntryValues.Uri; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown RegexEntryValues value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs new file mode 100644 index 0000000000..9b248d8111 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum RegexEntryValues + { + /// Body. + Body, + /// Header. + Header, + /// Uri. + Uri + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs new file mode 100644 index 0000000000..5bc5ba395a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizer. + public partial class RemoveHeaderSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemoveHeaderSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemoveHeaderSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (RemoveHeaderSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemoveHeaderSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemoveHeaderSanitizer DeserializeRemoveHeaderSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + RemoveHeaderSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = RemoveHeaderSanitizerBody.DeserializeRemoveHeaderSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemoveHeaderSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemoveHeaderSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (RemoveHeaderSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemoveHeaderSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs new file mode 100644 index 0000000000..84c5e1df2e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizer. + public partial class RemoveHeaderSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public RemoveHeaderSanitizer(RemoveHeaderSanitizerBody body) : base(SanitizerType.RemoveHeaderSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal RemoveHeaderSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, RemoveHeaderSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public RemoveHeaderSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..495927e808 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizerBody. + public partial class RemoveHeaderSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemoveHeaderSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("headersForRemoval"u8); + writer.WriteStringValue(HeadersForRemoval); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemoveHeaderSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RemoveHeaderSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemoveHeaderSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemoveHeaderSanitizerBody DeserializeRemoveHeaderSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string headersForRemoval = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("headersForRemoval"u8)) + { + headersForRemoval = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemoveHeaderSanitizerBody(headersForRemoval, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemoveHeaderSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RemoveHeaderSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemoveHeaderSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs new file mode 100644 index 0000000000..d303f9ea03 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizerBody. + public partial class RemoveHeaderSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public RemoveHeaderSanitizerBody(string headersForRemoval) + { + Argument.AssertNotNull(headersForRemoval, nameof(headersForRemoval)); + + HeadersForRemoval = headersForRemoval; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal RemoveHeaderSanitizerBody(string headersForRemoval, IDictionary additionalBinaryDataProperties) + { + HeadersForRemoval = headersForRemoval; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the HeadersForRemoval. + public string HeadersForRemoval { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs new file mode 100644 index 0000000000..aefc30a4a8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemovedSanitizers. + public partial class RemovedSanitizers : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemovedSanitizers() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Removed"u8); + writer.WriteStartArray(); + foreach (string item in Removed) + { + if (item == null) + { + writer.WriteNullValue(); + continue; + } + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemovedSanitizers IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RemovedSanitizers JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemovedSanitizers(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemovedSanitizers DeserializeRemovedSanitizers(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + IList removed = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Removed"u8)) + { + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + array.Add(null); + } + else + { + array.Add(item.GetString()); + } + } + removed = array; + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemovedSanitizers(removed, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemovedSanitizers IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RemovedSanitizers PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemovedSanitizers(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to deserialize the from. + public static explicit operator RemovedSanitizers(ClientResult result) + { + using PipelineResponse response = result.GetRawResponse(); + using JsonDocument document = JsonDocument.Parse(response.Content); + return DeserializeRemovedSanitizers(document.RootElement, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs new file mode 100644 index 0000000000..90596701c4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemovedSanitizers. + public partial class RemovedSanitizers + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + internal RemovedSanitizers(IEnumerable removed) + { + Removed = removed.ToList(); + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal RemovedSanitizers(IList removed, IDictionary additionalBinaryDataProperties) + { + Removed = removed; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Removed. + public IList Removed { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs new file mode 100644 index 0000000000..56a8587380 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + [PersistableModelProxy(typeof(UnknownSanitizerAddition))] + public abstract partial class SanitizerAddition : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal SanitizerAddition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Name"u8); + writer.WriteStringValue(Name.ToSerialString()); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerAddition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerAddition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static SanitizerAddition DeserializeSanitizerAddition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + if (element.TryGetProperty("Name"u8, out JsonElement discriminator)) + { + switch (discriminator.GetString()) + { + case "BodyKeySanitizer": + return BodyKeySanitizer.DeserializeBodyKeySanitizer(element, options); + case "BodyRegexSanitizer": + return BodyRegexSanitizer.DeserializeBodyRegexSanitizer(element, options); + case "BodyStringSanitizer": + return BodyStringSanitizer.DeserializeBodyStringSanitizer(element, options); + case "GeneralRegexSanitizer": + return GeneralRegexSanitizer.DeserializeGeneralRegexSanitizer(element, options); + case "GeneralStringSanitizer": + return GeneralStringSanitizer.DeserializeGeneralStringSanitizer(element, options); + case "HeaderRegexSanitizer": + return HeaderRegexSanitizer.DeserializeHeaderRegexSanitizer(element, options); + case "HeaderStringSanitizer": + return HeaderStringSanitizer.DeserializeHeaderStringSanitizer(element, options); + case "OAuthResponseSanitizer": + return OAuthResponseSanitizer.DeserializeOAuthResponseSanitizer(element, options); + case "RegexEntrySanitizer": + return RegexEntrySanitizer.DeserializeRegexEntrySanitizer(element, options); + case "RemoveHeaderSanitizer": + return RemoveHeaderSanitizer.DeserializeRemoveHeaderSanitizer(element, options); + case "UriRegexSanitizer": + return UriRegexSanitizer.DeserializeUriRegexSanitizer(element, options); + case "UriStringSanitizer": + return UriStringSanitizer.DeserializeUriStringSanitizer(element, options); + case "UriSubscriptionIdSanitizer": + return UriSubscriptionIdSanitizer.DeserializeUriSubscriptionIdSanitizer(element, options); + } + } + return UnknownSanitizerAddition.DeserializeUnknownSanitizerAddition(element, options); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerAddition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerAddition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs new file mode 100644 index 0000000000..49f0918866 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + public abstract partial class SanitizerAddition + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + private protected SanitizerAddition(SanitizerType name) + { + Name = name; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal SanitizerAddition(SanitizerType name, IDictionary additionalBinaryDataProperties) + { + Name = name; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Name. + internal SanitizerType Name { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs new file mode 100644 index 0000000000..5688a5e99e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The SanitizerList. + public partial class SanitizerList : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal SanitizerList() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerList)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Sanitizers"u8); + writer.WriteStartArray(); + foreach (string item in Sanitizers) + { + if (item == null) + { + writer.WriteNullValue(); + continue; + } + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerList IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual SanitizerList JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerList)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerList(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static SanitizerList DeserializeSanitizerList(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + IList sanitizers = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Sanitizers"u8)) + { + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + array.Add(null); + } + else + { + array.Add(item.GetString()); + } + } + sanitizers = array; + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new SanitizerList(sanitizers, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerList)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerList IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual SanitizerList PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerList(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerList)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(SanitizerList sanitizerList) + { + if (sanitizerList == null) + { + return null; + } + return BinaryContent.Create(sanitizerList, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs new file mode 100644 index 0000000000..c36207fed3 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The SanitizerList. + public partial class SanitizerList + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public SanitizerList(IEnumerable sanitizers) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + Sanitizers = sanitizers.ToList(); + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal SanitizerList(IList sanitizers, IDictionary additionalBinaryDataProperties) + { + Sanitizers = sanitizers; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Sanitizers. + public IList Sanitizers { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs new file mode 100644 index 0000000000..d39ab4315d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class SanitizerTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this SanitizerType value) => value switch + { + SanitizerType.BodyKeySanitizer => "BodyKeySanitizer", + SanitizerType.BodyRegexSanitizer => "BodyRegexSanitizer", + SanitizerType.BodyStringSanitizer => "BodyStringSanitizer", + SanitizerType.GeneralRegexSanitizer => "GeneralRegexSanitizer", + SanitizerType.GeneralStringSanitizer => "GeneralStringSanitizer", + SanitizerType.HeaderRegexSanitizer => "HeaderRegexSanitizer", + SanitizerType.HeaderStringSanitizer => "HeaderStringSanitizer", + SanitizerType.OAuthResponseSanitizer => "OAuthResponseSanitizer", + SanitizerType.RegexEntrySanitizer => "RegexEntrySanitizer", + SanitizerType.RemoveHeaderSanitizer => "RemoveHeaderSanitizer", + SanitizerType.UriRegexSanitizer => "UriRegexSanitizer", + SanitizerType.UriStringSanitizer => "UriStringSanitizer", + SanitizerType.UriSubscriptionIdSanitizer => "UriSubscriptionIdSanitizer", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown SanitizerType value.") + }; + + /// The value to deserialize. + public static SanitizerType ToSanitizerType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyKeySanitizer")) + { + return SanitizerType.BodyKeySanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyRegexSanitizer")) + { + return SanitizerType.BodyRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyStringSanitizer")) + { + return SanitizerType.BodyStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GeneralRegexSanitizer")) + { + return SanitizerType.GeneralRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GeneralStringSanitizer")) + { + return SanitizerType.GeneralStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderRegexSanitizer")) + { + return SanitizerType.HeaderRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderStringSanitizer")) + { + return SanitizerType.HeaderStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "OAuthResponseSanitizer")) + { + return SanitizerType.OAuthResponseSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "RegexEntrySanitizer")) + { + return SanitizerType.RegexEntrySanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "RemoveHeaderSanitizer")) + { + return SanitizerType.RemoveHeaderSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriRegexSanitizer")) + { + return SanitizerType.UriRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriStringSanitizer")) + { + return SanitizerType.UriStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriSubscriptionIdSanitizer")) + { + return SanitizerType.UriSubscriptionIdSanitizer; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown SanitizerType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs new file mode 100644 index 0000000000..c21147e7b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + internal enum SanitizerType + { + /// BodyKeySanitizer. + BodyKeySanitizer, + /// BodyRegexSanitizer. + BodyRegexSanitizer, + /// BodyStringSanitizer. + BodyStringSanitizer, + /// GeneralRegexSanitizer. + GeneralRegexSanitizer, + /// GeneralStringSanitizer. + GeneralStringSanitizer, + /// HeaderRegexSanitizer. + HeaderRegexSanitizer, + /// HeaderStringSanitizer. + HeaderStringSanitizer, + /// OAuthResponseSanitizer. + OAuthResponseSanitizer, + /// RegexEntrySanitizer. + RegexEntrySanitizer, + /// RemoveHeaderSanitizer. + RemoveHeaderSanitizer, + /// UriRegexSanitizer. + UriRegexSanitizer, + /// UriStringSanitizer. + UriStringSanitizer, + /// UriSubscriptionIdSanitizer. + UriSubscriptionIdSanitizer + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs new file mode 100644 index 0000000000..cbe5fbac28 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class StoreTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this StoreType value) => value switch + { + StoreType.GitStore => "GitStore", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown StoreType value.") + }; + + /// The value to deserialize. + public static StoreType ToStoreType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GitStore")) + { + return StoreType.GitStore; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown StoreType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs new file mode 100644 index 0000000000..6acac5466c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum StoreType + { + /// GitStore. + GitStore + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs new file mode 100644 index 0000000000..dc6bcdfebd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyCertificate. + public partial class TestProxyCertificate : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal TestProxyCertificate() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support writing '{format}' format."); + } + writer.WritePropertyName("PemValue"u8); + writer.WriteStringValue(PemValue); + writer.WritePropertyName("PemKey"u8); + writer.WriteStringValue(PemKey); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TestProxyCertificate IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TestProxyCertificate JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTestProxyCertificate(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TestProxyCertificate DeserializeTestProxyCertificate(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string pemValue = default; + string pemKey = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("PemValue"u8)) + { + pemValue = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("PemKey"u8)) + { + pemKey = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TestProxyCertificate(pemValue, pemKey, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TestProxyCertificate IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TestProxyCertificate PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTestProxyCertificate(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs new file mode 100644 index 0000000000..2dec7b39ee --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyCertificate. + public partial class TestProxyCertificate + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// or is null. + public TestProxyCertificate(string pemValue, string pemKey) + { + Argument.AssertNotNull(pemValue, nameof(pemValue)); + Argument.AssertNotNull(pemKey, nameof(pemKey)); + + PemValue = pemValue; + PemKey = pemKey; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal TestProxyCertificate(string pemValue, string pemKey, IDictionary additionalBinaryDataProperties) + { + PemValue = pemValue; + PemKey = pemKey; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the PemValue. + public string PemValue { get; } + + /// Gets the PemKey. + public string PemKey { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs new file mode 100644 index 0000000000..4620bfdbfc --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyStartInformation. + public partial class TestProxyStartInformation : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal TestProxyStartInformation() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support writing '{format}' format."); + } + writer.WritePropertyName("x-recording-file"u8); + writer.WriteStringValue(XRecordingFile); + if (Optional.IsDefined(XRecordingAssetsFile)) + { + writer.WritePropertyName("x-recording-assets-file"u8); + writer.WriteStringValue(XRecordingAssetsFile); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TestProxyStartInformation IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TestProxyStartInformation JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTestProxyStartInformation(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TestProxyStartInformation DeserializeTestProxyStartInformation(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string xRecordingFile = default; + string xRecordingAssetsFile = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("x-recording-file"u8)) + { + xRecordingFile = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("x-recording-assets-file"u8)) + { + xRecordingAssetsFile = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TestProxyStartInformation(xRecordingFile, xRecordingAssetsFile, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TestProxyStartInformation IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TestProxyStartInformation PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTestProxyStartInformation(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(TestProxyStartInformation testProxyStartInformation) + { + if (testProxyStartInformation == null) + { + return null; + } + return BinaryContent.Create(testProxyStartInformation, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs new file mode 100644 index 0000000000..a62fc2f32e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models; + +/// The TestProxyStartInformation. +public partial class TestProxyStartInformation +{ + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public TestProxyStartInformation(string xRecordingFile) + { + Argument.AssertNotNull(xRecordingFile, nameof(xRecordingFile)); + + XRecordingFile = xRecordingFile; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal TestProxyStartInformation(string xRecordingFile, string xRecordingAssetsFile, IDictionary additionalBinaryDataProperties) + { + XRecordingFile = xRecordingFile; + XRecordingAssetsFile = xRecordingAssetsFile; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the XRecordingFile. + public string XRecordingFile { get; } + + /// Gets or sets the XRecordingAssetsFile. + public string XRecordingAssetsFile { get; set; } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs new file mode 100644 index 0000000000..491ff0c35e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TransportCustomizations. + public partial class TransportCustomizations : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(AllowAutoRedirect)) + { + writer.WritePropertyName("AllowAutoRedirect"u8); + writer.WriteBooleanValue(AllowAutoRedirect.Value); + } + if (Optional.IsDefined(TLSValidationCert)) + { + writer.WritePropertyName("TLSValidationCert"u8); + writer.WriteStringValue(TLSValidationCert); + } + if (Optional.IsDefined(TLSValidationCertHost)) + { + writer.WritePropertyName("TLSValidationCertHost"u8); + writer.WriteStringValue(TLSValidationCertHost); + } + if (Optional.IsCollectionDefined(Certificates)) + { + writer.WritePropertyName("Certificates"u8); + writer.WriteStartArray(); + foreach (TestProxyCertificate item in Certificates) + { + writer.WriteObjectValue(item, options); + } + writer.WriteEndArray(); + } + if (Optional.IsDefined(PlaybackResponseTime)) + { + writer.WritePropertyName("PlaybackResponseTime"u8); + writer.WriteNumberValue(PlaybackResponseTime.Value); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TransportCustomizations IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TransportCustomizations JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTransportCustomizations(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TransportCustomizations DeserializeTransportCustomizations(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? allowAutoRedirect = default; + string tlsValidationCert = default; + string tlsValidationCertHost = default; + IList certificates = default; + int? playbackResponseTime = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("AllowAutoRedirect"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + allowAutoRedirect = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("TLSValidationCert"u8)) + { + tlsValidationCert = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("TLSValidationCertHost"u8)) + { + tlsValidationCertHost = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("Certificates"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + array.Add(TestProxyCertificate.DeserializeTestProxyCertificate(item, options)); + } + certificates = array; + continue; + } + if (prop.NameEquals("PlaybackResponseTime"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + playbackResponseTime = prop.Value.GetInt32(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TransportCustomizations( + allowAutoRedirect, + tlsValidationCert, + tlsValidationCertHost, + certificates ?? new ChangeTrackingList(), + playbackResponseTime, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TransportCustomizations IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TransportCustomizations PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTransportCustomizations(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs new file mode 100644 index 0000000000..c2db2212ff --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TransportCustomizations. + public partial class TransportCustomizations + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public TransportCustomizations() + { + Certificates = new ChangeTrackingList(); + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal TransportCustomizations(bool? allowAutoRedirect, string tlsValidationCert, string tlsValidationCertHost, IList certificates, int? playbackResponseTime, IDictionary additionalBinaryDataProperties) + { + AllowAutoRedirect = allowAutoRedirect; + TLSValidationCert = tlsValidationCert; + TLSValidationCertHost = tlsValidationCertHost; + Certificates = certificates; + PlaybackResponseTime = playbackResponseTime; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the AllowAutoRedirect. + public bool? AllowAutoRedirect { get; set; } + + /// Gets or sets the TLSValidationCert. + public string TLSValidationCert { get; set; } + + /// Gets or sets the TLSValidationCertHost. + public string TLSValidationCertHost { get; set; } + + /// Gets the Certificates. + public IList Certificates { get; } + + /// Gets or sets the PlaybackResponseTime. + public int? PlaybackResponseTime { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs new file mode 100644 index 0000000000..e2781b2f6c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal partial class UnknownSanitizerAddition : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UnknownSanitizerAddition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerAddition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerAddition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UnknownSanitizerAddition DeserializeUnknownSanitizerAddition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UnknownSanitizerAddition(name, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerAddition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerAddition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs new file mode 100644 index 0000000000..976bba8a65 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal partial class UnknownSanitizerAddition : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal UnknownSanitizerAddition(SanitizerType name, IDictionary additionalBinaryDataProperties) : base(name, additionalBinaryDataProperties) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..07ba61637e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizer. + public partial class UriRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriRegexSanitizer DeserializeUriRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriRegexSanitizerBody.DeserializeUriRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs new file mode 100644 index 0000000000..ec41088cd5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizer. + public partial class UriRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriRegexSanitizer(UriRegexSanitizerBody body) : base(SanitizerType.UriRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..352d1f72be --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizerBody. + public partial class UriRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriRegexSanitizerBody DeserializeUriRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs new file mode 100644 index 0000000000..9b6a1b88fe --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizerBody. + public partial class UriRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public UriRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..e01ea7ea1f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizer. + public partial class UriStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriStringSanitizer DeserializeUriStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriStringSanitizerBody.DeserializeUriStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs new file mode 100644 index 0000000000..a0d50fb404 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizer. + public partial class UriStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriStringSanitizer(UriStringSanitizerBody body) : base(SanitizerType.UriStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..b53b93c5b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizerBody. + public partial class UriStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriStringSanitizerBody DeserializeUriStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs new file mode 100644 index 0000000000..1c2d8d632e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizerBody. + public partial class UriStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public UriStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs new file mode 100644 index 0000000000..07e7e1ad4d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizer. + public partial class UriSubscriptionIdSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriSubscriptionIdSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriSubscriptionIdSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriSubscriptionIdSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriSubscriptionIdSanitizer DeserializeUriSubscriptionIdSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriSubscriptionIdSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriSubscriptionIdSanitizerBody.DeserializeUriSubscriptionIdSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriSubscriptionIdSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriSubscriptionIdSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriSubscriptionIdSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs new file mode 100644 index 0000000000..c60ae7b86d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizer. + public partial class UriSubscriptionIdSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriSubscriptionIdSanitizer(UriSubscriptionIdSanitizerBody body) : base(SanitizerType.UriSubscriptionIdSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriSubscriptionIdSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriSubscriptionIdSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriSubscriptionIdSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..8a3c05a093 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizerBody. + public partial class UriSubscriptionIdSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriSubscriptionIdSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriSubscriptionIdSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriSubscriptionIdSanitizerBody DeserializeUriSubscriptionIdSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriSubscriptionIdSanitizerBody(value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriSubscriptionIdSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriSubscriptionIdSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs new file mode 100644 index 0000000000..b56d3ae97d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizerBody. + public partial class UriSubscriptionIdSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public UriSubscriptionIdSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriSubscriptionIdSanitizerBody(string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md new file mode 100644 index 0000000000..8dc00fb3a7 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md @@ -0,0 +1,31 @@ +# Regenerating this code + +- Add this file to the repo under `eng/emitter-package.json` + ```json + { + "main": "dist/src/index.js", + "dependencies": { + "@typespec/http-client-csharp": "latest" + }, + "devDependencies": { + "@azure-tools/typespec-autorest": "0.60.0", + "@azure-tools/typespec-azure-core": "0.60.0", + "@azure-tools/typespec-azure-resource-manager": "0.60.0", + "@azure-tools/typespec-azure-rulesets": "0.60.0", + "@azure-tools/typespec-client-generator-core": "0.60.0", + "@azure-tools/typespec-liftr-base": "0.8.0", + "@typespec/compiler": "1.4.0", + "@typespec/events": "0.74.0", + "@typespec/http": "1.4.0", + "@typespec/openapi": "1.4.0", + "@typespec/rest": "0.74.0", + "@typespec/sse": "0.74.0", + "@typespec/streams": "0.74.0", + "@typespec/versioning": "0.74.0", + "@typespec/xml": "0.74.0" + } + } + ``` +- Then install `tsp-client` to your NODE installation globally: `npm install -g @azure-tools/typespec-client-generator-cli` +- `tsp-client update` from within the directory containing the `tsp-location.yaml` file. +- This will regenerate the C# client code that resides in this directory. diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs new file mode 100644 index 0000000000..4a53599c7e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// + public partial class TestProxyAdminClient + { + private static PipelineMessageClassifier _pipelineMessageClassifier200; + + private static PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 = PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal PipelineMessage CreateSetMatcherRequest(string matcherType, BinaryContent content, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/SetMatcher", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-abstraction-identifier", matcherType); + if ("application/json" != null) + { + request.Headers.Set("Content-Type", "application/json"); + } + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateAddSanitizersRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/AddSanitizers", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateRemoveSanitizersRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/RemoveSanitizers", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateSetRecordingOptionsRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/SetRecordingOptions", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs new file mode 100644 index 0000000000..94a9664069 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Mcp.Tests.Generated.Internal; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated +{ + /// The TestProxyAdminClient sub-client. + public partial class TestProxyAdminClient + { + private readonly Uri _endpoint; + + /// Initializes a new instance of TestProxyAdminClient for mocking. + protected TestProxyAdminClient() + { + } + + /// Initializes a new instance of TestProxyAdminClient. + /// The HTTP pipeline for sending and receiving REST requests and responses. + /// Service endpoint. + internal TestProxyAdminClient(ClientPipeline pipeline, Uri endpoint) + { + _endpoint = endpoint; + Pipeline = pipeline; + } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public ClientPipeline Pipeline { get; } + + /// + /// [Protocol Method] Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The type of matcher to set. + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult SetMatcher(string matcherType, BinaryContent content = null, RequestOptions options = null) + { + using PipelineMessage message = CreateSetMatcherRequest(matcherType, content, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The type of matcher to set. + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task SetMatcherAsync(string matcherType, BinaryContent content = null, RequestOptions options = null) + { + using PipelineMessage message = CreateSetMatcherRequest(matcherType, content, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// The type of matcher to set. + /// The matcher configuration. Only required if matcherType is CustomDefaultMatcher. + /// The cancellation token that can be used to cancel the operation. + /// Service returned a non-success status code. + public virtual ClientResult SetMatcher(MatcherType matcherType, CustomDefaultMatcher matcher = default, CancellationToken cancellationToken = default) + { + return SetMatcher(matcherType.ToSerialString(), matcher, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// The type of matcher to set. + /// The matcher configuration. Only required if matcherType is CustomDefaultMatcher. + /// The cancellation token that can be used to cancel the operation. + /// Service returned a non-success status code. + public virtual async Task SetMatcherAsync(MatcherType matcherType, CustomDefaultMatcher matcher = default, CancellationToken cancellationToken = default) + { + return await SetMatcherAsync(matcherType.ToSerialString(), matcher, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult AddSanitizers(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateAddSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task AddSanitizersAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateAddSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult AddSanitizers(IEnumerable sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + using BinaryContent content = BinaryContentHelper.FromEnumerable(sanitizers); + return AddSanitizers(content, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task AddSanitizersAsync(IEnumerable sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + using BinaryContent content = BinaryContentHelper.FromEnumerable(sanitizers); + return await AddSanitizersAsync(content, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult RemoveSanitizers(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateRemoveSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task RemoveSanitizersAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateRemoveSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult RemoveSanitizers(SanitizerList sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + ClientResult result = RemoveSanitizers(sanitizers, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + return ClientResult.FromValue((RemovedSanitizers)result, result.GetRawResponse()); + } + + /// + /// Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task> RemoveSanitizersAsync(SanitizerList sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + ClientResult result = await RemoveSanitizersAsync(sanitizers, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + return ClientResult.FromValue((RemovedSanitizers)result, result.GetRawResponse()); + } + + /// + /// [Protocol Method] Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult SetRecordingOptions(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateSetRecordingOptionsRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task SetRecordingOptionsAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateSetRecordingOptionsRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult SetRecordingOptions(RecordingOptions body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return SetRecordingOptions(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task SetRecordingOptionsAsync(RecordingOptions body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return await SetRecordingOptionsAsync(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs new file mode 100644 index 0000000000..bb3c317573 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// + public partial class TestProxyClient + { + private static PipelineMessageClassifier _pipelineMessageClassifier200; + + private static PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 = PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal PipelineMessage CreateStartRecordRequest(BinaryContent content, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Record/Start", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStopRecordRequest(string recordingId, BinaryContent content, string recordingSkip, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Record/Stop", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-recording-id", recordingId); + if (recordingSkip != null) + { + request.Headers.Set("x-recording-skip", recordingSkip); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStartPlaybackRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Playback/Start", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStopPlaybackRequest(string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Playback/Stop", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-recording-id", recordingId); + request.Headers.Set("Accept", "application/json"); + message.Apply(options); + return message; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs new file mode 100644 index 0000000000..f24cf4334a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Mcp.Tests.Generated.Internal; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated +{ + /// The TestProxyClient. + public partial class TestProxyClient + { + private readonly Uri _endpoint; + private TestProxyAdminClient _cachedTestProxyAdminClient; + + /// Initializes a new instance of TestProxyClient. + public TestProxyClient() : this(new Uri("https://localhost:5001/"), new TestProxyClientOptions()) + { + } + + /// Initializes a new instance of TestProxyClient. + /// Service endpoint. + /// The options for configuring the client. + /// is null. + public TestProxyClient(Uri endpoint, TestProxyClientOptions options) + { + Argument.AssertNotNull(endpoint, nameof(endpoint)); + + options ??= new TestProxyClientOptions(); + + _endpoint = endpoint; + Pipeline = ClientPipeline.Create(options, Array.Empty(), Array.Empty(), Array.Empty()); + } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public ClientPipeline Pipeline { get; } + + /// + /// [Protocol Method] Start recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StartRecord(BinaryContent content, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartRecordRequest(content, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Start recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StartRecordAsync(BinaryContent content, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartRecordRequest(content, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Start recording for a test. + /// File location of the recording. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult StartRecord(TestProxyStartInformation body, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return StartRecord(body, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Start recording for a test. + /// File location of the recording. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task StartRecordAsync(TestProxyStartInformation body, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return await StartRecordAsync(body, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Stop recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The content to send as the body of the request. + /// Optional header that can be set to request-response to skip recording this session. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StopRecord(string recordingId, BinaryContent content, string recordingSkip = default, RequestOptions options = null) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStopRecordRequest(recordingId, content, recordingSkip, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Stop recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The content to send as the body of the request. + /// Optional header that can be set to request-response to skip recording this session. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StopRecordAsync(string recordingId, BinaryContent content, string recordingSkip = default, RequestOptions options = null) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStopRecordRequest(recordingId, content, recordingSkip, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Stop recording for a test. + /// The recording ID. + /// A set of variables for the recording session. + /// Optional header that can be set to request-response to skip recording this session. + /// The cancellation token that can be used to cancel the operation. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual ClientResult StopRecord(string recordingId, IDictionary variables, string recordingSkip = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(variables, nameof(variables)); + + using BinaryContent content = BinaryContentHelper.FromDictionary(variables); + return StopRecord(recordingId, content, recordingSkip, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Stop recording for a test. + /// The recording ID. + /// A set of variables for the recording session. + /// Optional header that can be set to request-response to skip recording this session. + /// The cancellation token that can be used to cancel the operation. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual async Task StopRecordAsync(string recordingId, IDictionary variables, string recordingSkip = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(variables, nameof(variables)); + + using BinaryContent content = BinaryContentHelper.FromDictionary(variables); + return await StopRecordAsync(recordingId, content, recordingSkip, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Start playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StartPlayback(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartPlaybackRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Start playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StartPlaybackAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartPlaybackRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Start playback for a test recording. + /// File location of the recording. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult> StartPlayback(TestProxyStartInformation body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + ClientResult result = StartPlayback(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + return ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson>(), result.GetRawResponse()); + } + + /// Start playback for a test recording. + /// File location of the recording. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task>> StartPlaybackAsync(TestProxyStartInformation body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + ClientResult result = await StartPlaybackAsync(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + return ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson>(), result.GetRawResponse()); + } + + /// + /// [Protocol Method] Stop playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StopPlayback(string recordingId, RequestOptions options) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + using PipelineMessage message = CreateStopPlaybackRequest(recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Stop playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StopPlaybackAsync(string recordingId, RequestOptions options) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + using PipelineMessage message = CreateStopPlaybackRequest(recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Stop playback for a test recording. + /// The recording ID. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual ClientResult StopPlayback(string recordingId, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + return StopPlayback(recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Stop playback for a test recording. + /// The recording ID. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual async Task StopPlaybackAsync(string recordingId, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + return await StopPlaybackAsync(recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// Initializes a new instance of TestProxyAdminClient. + public virtual TestProxyAdminClient GetTestProxyAdminClient() + { + return Volatile.Read(ref _cachedTestProxyAdminClient) ?? Interlocked.CompareExchange(ref _cachedTestProxyAdminClient, new TestProxyAdminClient(Pipeline, _endpoint), null) ?? _cachedTestProxyAdminClient; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs new file mode 100644 index 0000000000..98e58d3aec --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Azure.Mcp.Tests.Generated +{ + /// Client options for . + public partial class TestProxyClientOptions : ClientPipelineOptions + { + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs new file mode 100644 index 0000000000..fea9b0ab2d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using Xunit.v3; + +namespace Azure.Mcp.Tests.Helpers +{ + /// + /// Xunit attribute to clear known environment variables before each test is run. + /// Live tests should not use this attribute, as they may need environment variables to configure authentication and proxy. + /// + public class ClearEnvironmentVariablesBeforeTestAttribute : BeforeAfterTestAttribute + { + // These are all the known environment variables that our server may use. + // Proper test initialization should clear all of these, then set only the ones needed for the test. + private static readonly List _variablesToClear = [ + "ALL_PROXY", + "ALLOW_INSECURE_EXTERNAL_BINDING", + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "ASPNETCORE_URLS", + "AZURE_CLIENT_ID", + "AZURE_CREDENTIALS", + "AZURE_MCP_AUTHENTICATION_RECORD", + "AZURE_MCP_BROWSER_AUTH_TIMEOUT_SECONDS", + "AZURE_MCP_CLIENT_ID", + "AZURE_MCP_COLLECT_TELEMETRY", + "AZURE_MCP_ENABLE_OTLP_EXPORTER", + "AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TOKEN_CREDENTIALS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + ]; + + public override void Before(MethodInfo methodUnderTest, IXunitTest test) + { + foreach (var envVar in _variablesToClear) + { + Environment.SetEnvironmentVariable(envVar, null); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ResourceGroupTestHelpers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ResourceGroupTestHelpers.cs index 9547f8939a..971b3eaf76 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ResourceGroupTestHelpers.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ResourceGroupTestHelpers.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Mcp.Core.Models.ResourceGroup; +using Azure.Mcp.Core.Models.ResourceGroup; namespace Azure.Mcp.Tests.Helpers; diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs new file mode 100644 index 0000000000..c2007df92f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs @@ -0,0 +1,19 @@ +namespace Azure.Mcp.Tests.Helpers +{ + + /// + /// Execution mode for tests integrating with the test proxy. + /// + public enum TestMode + { + Live, + Record, + Playback + } + + public static class TestEnvironment + { + public static bool IsRunningInCi => + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TF_BUILD")); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs new file mode 100644 index 0000000000..9874fea643 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Tests.Helpers; + +/// +/// Provides a test helper for creating a pre-configured with HTTP client services. +/// This is intended for use in unit and integration tests that require HTTP client dependencies. +/// +public static class TestHttpClientFactoryProvider +{ + /// + /// Creates a new instance with HTTP client services configured for testing. + /// + /// + /// A containing the configured HTTP client services for use in tests. + /// + public static ServiceProvider Create() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddHttpClient(); + return services.BuildServiceProvider(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/README.md b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/README.md new file mode 100644 index 0000000000..33c67fa295 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/README.md @@ -0,0 +1,4 @@ + + +Code coverage diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs new file mode 100644 index 0000000000..8879d76a88 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Xunit; + +namespace Azure.Mcp.Tests; + +public static class TestExtensions +{ + public const string RunningFromDotnetTestReason = + "Test skipped when running from dotnet test. This test requires interactive environment."; + + public static bool IsRunningFromDotnetTest() + { + bool isVsCode = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSCODE_CLI")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSCODE_PID")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSCODE_CWD")); + + if (isVsCode) + { + return false; + } + + // Check for environment variables that indicate we're running from dotnet test + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSTEST_HOST_DEBUG")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH")); + } + + public static JsonElement AssertProperty(this JsonElement? element, string propertyName) + { + Assert.NotNull(element); + return element.Value.AssertProperty(propertyName); + } + + public static JsonElement AssertProperty(this JsonElement element, string propertyName) + { + Assert.True(element.TryGetProperty(propertyName, out var property), $"Property '{propertyName}' not found. Full element: '{JsonSerializer.Serialize(element)}'"); + return property; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml new file mode 100644 index 0000000000..1b43817039 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml @@ -0,0 +1,3 @@ +repo: Azure/azure-sdk-tools +commit: 2360f015aba8be6941f31c7ca936d40677638116 +directory: tools/test-proxy/typespec diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.ci.json b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.ci.json new file mode 100644 index 0000000000..d9561cf2a5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.ci.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "longRunningTestSeconds": 60, + "showLiveOutput": true, + "parallelizeTestCollections": false +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.json b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.json new file mode 100644 index 0000000000..d1ba478f18 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "longRunningTestSeconds": 60, + "showLiveOutput": true +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/tests/test-resources-post.ps1 b/core/Azure.Mcp.Core/tests/test-resources-post.ps1 index c3d539b0a0..b02b0a155c 100644 --- a/core/Azure.Mcp.Core/tests/test-resources-post.ps1 +++ b/core/Azure.Mcp.Core/tests/test-resources-post.ps1 @@ -3,8 +3,7 @@ param( [string] $TestApplicationId, [string] $ResourceGroupName, [string] $BaseName, - [hashtable] $DeploymentOutputs, - [hashtable] $AdditionalParameters + [hashtable] $DeploymentOutputs ) $ErrorActionPreference = "Stop"