diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 84d3c04cf4..b0d09617aa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -281,6 +281,12 @@ # ServiceLabel: %tools-ManagedLustre # ServiceOwners: @rebecca-makar @wolfgang-desalvador +# PRLabel: %tools-ManagedCleanroom +/tools/Azure.Mcp.Tools.ManagedCleanroom/ @ashank @yavohra @vaidmishra @microsoft/azure-mcp + +# ServiceLabel: %tools-ManagedCleanroom +# ServiceOwners: @ashank @yavohra @vaidmishra @microsoft/azure-mcp + # PRLabel: %tools-Marketplace /tools/Azure.Mcp.Tools.Marketplace/ @meirloichter @shaharsandak @obit91 @microsoft/azure-mcp diff --git a/Directory.Packages.props b/Directory.Packages.props index 136351699a..41830766c8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,8 @@ + + @@ -31,6 +33,8 @@ + + diff --git a/Microsoft.Mcp.slnx b/Microsoft.Mcp.slnx index 63ed2d9daa..01dd02aead 100644 --- a/Microsoft.Mcp.slnx +++ b/Microsoft.Mcp.slnx @@ -309,6 +309,13 @@ + + + + + + + diff --git a/eng/tools/ToolMetadataExporter/src/Program.cs b/eng/tools/ToolMetadataExporter/src/Program.cs index 83272812f2..9c57ac4c15 100644 --- a/eng/tools/ToolMetadataExporter/src/Program.cs +++ b/eng/tools/ToolMetadataExporter/src/Program.cs @@ -84,7 +84,7 @@ private static void ConfigureAzureServices(IServiceCollection services) services.AddScoped(sp => { var credential = new ChainedTokenCredential( - new ManagedIdentityCredential(), + new ManagedIdentityCredential(new ManagedIdentityCredentialOptions()), new DefaultAzureCredential() ); diff --git a/nuget.config b/nuget.config index c545d21270..5f37b98237 100644 --- a/nuget.config +++ b/nuget.config @@ -5,6 +5,8 @@ + + diff --git a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx index 25ae7510c2..0355b48ee9 100644 --- a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx +++ b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx @@ -263,6 +263,13 @@ + + + + + + + diff --git a/servers/Azure.Mcp.Server/changelog-entries/managedcleanroom-toolset.yaml b/servers/Azure.Mcp.Server/changelog-entries/managedcleanroom-toolset.yaml new file mode 100644 index 0000000000..bbc800dd8d --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/managedcleanroom-toolset.yaml @@ -0,0 +1,3 @@ +changes: + - section: "New Features" + description: "Added Azure Managed Cleanroom toolset with two commands for interacting with Azure Cleanroom: `managedcleanroom collaborations list` and `managedcleanroom collaboration create`." diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 29391c76b9..bb90e29167 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -213,6 +213,7 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.KeyVault.KeyVaultSetup(), new Azure.Mcp.Tools.Kusto.KustoSetup(), new Azure.Mcp.Tools.LoadTesting.LoadTestingSetup(), + new Azure.Mcp.Tools.ManagedCleanroom.ManagedCleanroomSetup(), new Azure.Mcp.Tools.Marketplace.MarketplaceSetup(), new Azure.Mcp.Tools.Quota.QuotaSetup(), new Azure.Mcp.Tools.Monitor.MonitorSetup(), diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index f4459ebecf..c88dd8c606 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -4298,6 +4298,72 @@ "sreagent_scheduledtasks_delete", "sreagent_threads_delete" ] + }, + { + "name": "list_azure_managed_cleanroom_collaborations", + "description": "List Azure Managed Cleanroom collaborations the calling user participates in via the Cleanroom Analytics Frontend service.", + "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": [ + "managedcleanroom_collaborations_list" + ] + }, + { + "name": "create_azure_managed_cleanroom_collaboration", + "description": "Create an Azure Managed Cleanroom collaboration ARM resource in a resource group and region.", + "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": [ + "managedcleanroom_collaborationarm_create" + ] } ] } diff --git a/tools/Azure.Mcp.Tools.Advisor/tests/Azure.Mcp.Tools.Advisor.Tests/Recommendation/RecommendationListCommandTests.cs b/tools/Azure.Mcp.Tools.Advisor/tests/Azure.Mcp.Tools.Advisor.Tests/Recommendation/RecommendationListCommandTests.cs index c37242d698..71a6041854 100644 --- a/tools/Azure.Mcp.Tools.Advisor/tests/Azure.Mcp.Tools.Advisor.Tests/Recommendation/RecommendationListCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Advisor/tests/Azure.Mcp.Tools.Advisor.Tests/Recommendation/RecommendationListCommandTests.cs @@ -8,6 +8,7 @@ using Azure.Mcp.Tools.Advisor.Services; using Microsoft.Mcp.Core.Options; using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Helpers; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; @@ -31,6 +32,11 @@ public void Constructor_InitializesCommandCorrectly() [InlineData("", false)] // Missing all required options public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) { + if (!shouldSucceed && string.IsNullOrWhiteSpace(args)) + { + TestEnvironment.SkipIfDefaultSubscriptionConfigured(); + } + // Arrange if (shouldSucceed) { diff --git a/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.Tests/AzqrCommandTests.cs b/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.Tests/AzqrCommandTests.cs index 8744b5c479..f78a70b246 100644 --- a/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.Tests/AzqrCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Extension/tests/Azure.Mcp.Tools.Extension.Tests/AzqrCommandTests.cs @@ -10,6 +10,7 @@ using Microsoft.Mcp.Core.Services.ProcessExecution; using Microsoft.Mcp.Core.Services.Time; using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Helpers; using NSubstitute; using Xunit; @@ -83,6 +84,11 @@ await Service.Received().ExecuteAsync( } finally { + if (field is not null) + { + field.SetValue(null, originalAzqrPath); + } + // Cleanup if (File.Exists(xlsxReportFilePath)) { @@ -102,6 +108,8 @@ await Service.Received().ExecuteAsync( [Fact] public async Task ExecuteAsync_ReturnsBadRequest_WhenMissingSubscriptionArgument() { + TestEnvironment.SkipIfDefaultSubscriptionConfigured(); + // Arrange & Act var response = await ExecuteCommandAsync(""); diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/architecture.md b/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/architecture.md new file mode 100644 index 0000000000..26fe9f3a46 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/architecture.md @@ -0,0 +1,101 @@ +# Azure Managed Cleanroom MCP Toolset - Architecture + +## Overview + +`Azure.Mcp.Tools.ManagedCleanroom` provides comprehensive operations for interacting with Azure Managed Cleanroom services. Commands are organized into logical groups for managing collaborations, analytics workloads, OIDC configuration, datasets, queries, consent documents, and audit events. + +Commands interact with: +- **Data Plane APIs**: Cleanroom Analytics Frontend for read operations (list collaborations, queries, datasets) +- **Control Plane APIs**: Azure Resource Manager (ARM) for write operations (create collaboration, manage resources) + +--- + +## Command Surfaces + +- **Management Plane Commands**: + +| Command Group | Command | Status | +| --- | --- | --- | +| Collaboration | `collaboration create` | Completed | +| Collaboration | `collaboration get` | Pending | +| Collaboration | `collaboration add-collaborator` | Pending | +| Collaboration | `collaboration enable-workload` | Pending | +| Collaboration | `collaboration get-readonly-kubeconfig` | Pending | + +- **Data Plane Commands**: + +| Command Group | Command | Status | +| --- | --- | --- | +| Collaborations | `collaborations list` | Completed | +| Collaborations | `collaborations get` | Pending | +| Analytics | `analytics get` | Pending | +| Analytics | `analytics skr-policy` | Pending | +| OIDC | `oidc issuer-info` | Pending | +| OIDC | `oidc keys` | Pending | +| OIDC | `oidc set-issuer-url` | Pending | +| Invitations | `invitations list` | Pending | +| Invitations | `invitations accept` | Pending | +| Datasets | `datasets publish` | Pending | +| Datasets | `datasets get` | Pending | +| Datasets | `datasets list` | Pending | +| Consent | `consent put` | Pending | +| Queries | `queries publish` | Pending | +| Queries | `queries get` | Pending | +| Queries | `queries list` | Pending | +| Queries | `queries vote` | Pending | +| Queries | `queries run` | Pending | +| Queries | `queries runs` | Pending | +| Runs | `runs get` | Pending | +| Audit Events | `auditevents list` | Pending | + +--- + +## Project Structure + +``` +Azure.Mcp.Tools.ManagedCleanroom/ +├── src/ +│ ├── ManagedCleanroomSetup.cs # DI registration & command tree +│ ├── Commands/ +│ │ ├── ManagedCleanroomJsonContext.cs # AOT-safe JSON serialization +│ │ ├── Collaboration/ +│ │ │ ├── CollaborationCreateCommand.cs +│ │ │ └── [Other collaboration commands ] +│ │ ├── Collaborations/ +│ │ │ ├── CollaborationsListCommand.cs +│ │ │ └── [Other collaboration commands ] +│ │ ├── Analytics/ # Analytics operations +│ │ ├── Oidc/ # OIDC configuration +│ │ ├── Invitations/ # Invitation management +│ │ ├── Datasets/ # Dataset operations +│ │ ├── Consent/ # Consent documents +│ │ ├── Queries/ # Query operations +│ │ ├── Runs/ # Query run tracking +│ │ └── AuditEvents/ # Audit event listing +│ ├── Options/ +│ │ ├── ManagedCleanroomOptionDefinitions.cs +│ │ ├── Collaboration/ +│ │ │ └── [Options classes - mixed status] +│ │ └── [Options for all command groups] +│ └── Services/ +│ ├── IManagedCleanroomService.cs +│ └── ManagedCleanroomService.cs +└── tests/ + └── Azure.Mcp.Tools.ManagedCleanroom.Tests/ + ├── Collaboration/ + │ ├── CollaborationCreateCommandTests.cs + │ └── [Other tests - ⏳] + ├── Collaborations/ + │ ├── CollaborationsListCommandTests.cs + │ └── [Other tests - ⏳] + └── [Tests for remaining command groups - ⏳] +``` + +--- + +## Implementation Notes + +- **Completed**: `collaborations list`, `collaboration create` +- **Pending**: 25 additional commands across 9 command groups +- Commands span both data plane and control plane operations + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/skills.md b/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/skills.md new file mode 100644 index 0000000000..2fd9f95700 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/docs/skills.md @@ -0,0 +1 @@ +TBD \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/AssemblyInfo.cs new file mode 100644 index 0000000000..625e9ea5a4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.ManagedCleanroom.Tests")] diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Azure.Mcp.Tools.ManagedCleanroom.csproj b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Azure.Mcp.Tools.ManagedCleanroom.csproj new file mode 100644 index 0000000000..2b63d27347 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Azure.Mcp.Tools.ManagedCleanroom.csproj @@ -0,0 +1,20 @@ + + + true + AzureCloud + + + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/CollaborationArm/CollaborationCreateCommand.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/CollaborationArm/CollaborationCreateCommand.cs new file mode 100644 index 0000000000..509592772f --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/CollaborationArm/CollaborationCreateCommand.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.ManagedCleanroom.Options.CollaborationArm; +using Azure.Mcp.Tools.ManagedCleanroom.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Commands.CollaborationArm; + +[CommandMetadata( + Id = "e247b9e0-2d87-43a7-8e5d-57eea22237a3", + Name = "create", + Title = "Create Cleanroom Collaboration", + Description = """ + Creates an Azure Cleanroom collaboration ARM resource in the specified resource group and location. + Returns immediately once the request is accepted by ARM. Provisioning runs in the background and typically takes ~25 minutes. + You can check the status by asking to get the collaboration by name once the request is accepted. + Required options: + - --name: unique collaboration name within the resource group + - --location: Azure region for the ARM resource (e.g., 'eastus') + - --resource-group: resource group to create the collaboration in + - --subscription: Azure subscription + """, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class CollaborationCreateCommand( + ILogger logger, + IManagedCleanroomServiceControlPlane service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly IManagedCleanroomServiceControlPlane _service = service; + + public override async Task ExecuteAsync( + CommandContext context, CollaborationCreateOptions options, CancellationToken cancellationToken) + { + try + { + var result = await _service.CreateCollaborationArmResourceAsync( + options.Name, + options.ResourceGroup, + options.Subscription!, + options.Location, + options.ResourceLocation, + options.Collaborators, + options.Tenant, + options.RetryPolicy, + cancellationToken).ConfigureAwait(false); + + context.Response.Message = result.Message; + context.Response.Results = ResponseResult.Create( + result.Properties, + ManagedCleanroomJsonContext.Default.JsonElement); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating cleanroom collaboration. Name: {Name}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Name, options.ResourceGroup, options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "A collaboration with this name already exists in the resource group.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed creating the collaboration. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + HttpStatusCode.Conflict, + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + HttpStatusCode.Forbidden, + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + HttpStatusCode.NotFound, + RequestFailedException reqEx => (HttpStatusCode)reqEx.Status, + _ => base.GetStatusCode(ex) + }; + + public record CollaborationCreateCommandResult; +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/Collaborations/CollaborationsListCommand.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/Collaborations/CollaborationsListCommand.cs new file mode 100644 index 0000000000..4edd0747e0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/Collaborations/CollaborationsListCommand.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.ManagedCleanroom.Options.Collaborations; +using Azure.Mcp.Tools.ManagedCleanroom.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Commands.Collaborations; + +[CommandMetadata( + Id = "0d6a0a0e-7a3a-4a7c-8e3f-2c0d2cfb91a1", + Name = "list", + Title = "List Cleanroom Collaborations", + Description = "Lists Azure Cleanroom collaborations the calling user participates in via the Cleanroom Analytics Frontend service. Returns the full collaboration details from the service.", + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class CollaborationsListCommand(ILogger logger, IManagedCleanroomServiceDataPlane service) + : AuthenticatedCommand +{ + private readonly ILogger _logger = logger; + private readonly IManagedCleanroomServiceDataPlane _service = service; + + public override async Task ExecuteAsync( + CommandContext context, CollaborationsListOptions options, CancellationToken cancellationToken) + { + try + { + var result = await _service.ListCollaborationsAsync( + options.Endpoint, + options.ActiveOnly, + options.TokenScope, + options.Tenant, + options.RetryPolicy, + cancellationToken).ConfigureAwait(false); + + context.Response.Results = ResponseResult.Create( + result, + ManagedCleanroomJsonContext.Default.JsonElement); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error listing cleanroom collaborations. Endpoint: {Endpoint} ActiveOnly: {ActiveOnly}", + options.Endpoint, options.ActiveOnly); + HandleException(context, ex); + } + + return context.Response; + } + + public record CollaborationsListCommandResult; +} + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/ManagedCleanroomJsonContext.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/ManagedCleanroomJsonContext.cs new file mode 100644 index 0000000000..b62514f9bc --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Commands/ManagedCleanroomJsonContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Commands; + +// Only JsonElement is registered here because the current Managed Cleanroom +// commands pass through raw JSON payloads from HTTP/ARM responses directly to +// ResponseResult.Create without wrapping them in typed result models. +[JsonSerializable(typeof(JsonElement))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class ManagedCleanroomJsonContext : JsonSerializerContext; + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/ManagedCleanroomSetup.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/ManagedCleanroomSetup.cs new file mode 100644 index 0000000000..60e73c7b9e --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/ManagedCleanroomSetup.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.ManagedCleanroom.Commands.CollaborationArm; +using Azure.Mcp.Tools.ManagedCleanroom.Commands.Collaborations; +using Azure.Mcp.Tools.ManagedCleanroom.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Mcp.Core.Areas; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.ManagedCleanroom; + +public class ManagedCleanroomSetup : IAreaSetup +{ + internal const string DefaultHttpClientName = "ManagedCleanroom.Default"; + internal const string UnsafeHttpClientName = "ManagedCleanroom.Unsafe"; + + public string Name => "managedcleanroom"; + + public string Title => "Azure Managed Cleanroom"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddHttpClient(DefaultHttpClientName); + services.AddHttpClient(UnsafeHttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + var root = new CommandGroup(Name, + "Azure Managed Cleanroom operations - Commands for interacting with the Azure Cleanroom Analytics Frontend, including listing and inspecting collaborations and analytics workloads.", Title); + + var collaborations = new CommandGroup("collaborations", "Cleanroom collaboration operations - Commands for listing and inspecting cleanroom collaborations."); + root.AddSubGroup(collaborations); + + collaborations.AddCommand(serviceProvider); + + var collaborationArm = new CommandGroup("collaborationarm", "Cleanroom ARM management operations - Commands for creating and managing Azure Cleanroom collaboration ARM resources."); + root.AddSubGroup(collaborationArm); + + collaborationArm.AddCommand(serviceProvider); + + return root; + } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/CollaborationArm/CollaborationCreateOptions.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/CollaborationArm/CollaborationCreateOptions.cs new file mode 100644 index 0000000000..83da486f34 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/CollaborationArm/CollaborationCreateOptions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Models; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Options.CollaborationArm; + +public class CollaborationCreateOptions : ISubscriptionOption +{ + [Option("The name of the Azure Cleanroom collaboration resource to create. Must be unique within the resource group.")] + public required string Name { get; set; } + + [Option("The Azure region where the collaboration ARM resource will be created (e.g., 'eastus', 'westus2'). This is the RP location.")] + public required string Location { get; set; } + + [Option("The Azure region where the cleanroom workload resources (AKS cluster, CACI instances) will be deployed. Defaults to the same as --location if not specified.")] + public string? ResourceLocation { get; set; } + + [Option(Name = "collaborator", Description = "The email address (userIdentifier) of the collaborator to add at creation time. Can be specified multiple times to add multiple collaborators.")] + public string[]? Collaborators { get; set; } + + [Option(OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [Option(Name = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/BaseManagedCleanroomDataPlaneOptions.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/BaseManagedCleanroomDataPlaneOptions.cs new file mode 100644 index 0000000000..d3854437bf --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/BaseManagedCleanroomDataPlaneOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Options.Collaborations; + +/// +/// Base options for all data-plane Managed Cleanroom commands. +/// Contains shared authentication, transport, and retry configuration. +/// +public class BaseManagedCleanroomDataPlaneOptions +{ + [Option(ManagedCleanroomOptionDescriptions.Endpoint)] + public required string Endpoint { get; set; } + + [Option(ManagedCleanroomOptionDescriptions.TokenScope)] + public string? TokenScope { get; set; } + + [Option(OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [Option(Name = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/CollaborationsListOptions.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/CollaborationsListOptions.cs new file mode 100644 index 0000000000..b5bb6c2f72 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/Collaborations/CollaborationsListOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Options.Collaborations; + +public class CollaborationsListOptions : BaseManagedCleanroomDataPlaneOptions +{ + [Option("When true, returns only active collaborations (email-only lookup). When omitted, returns all collaborations.")] + public bool? ActiveOnly { get; set; } +} + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/ManagedCleanroomOptionDescriptions.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/ManagedCleanroomOptionDescriptions.cs new file mode 100644 index 0000000000..10be5f5e61 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Options/ManagedCleanroomOptionDescriptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ManagedCleanroom.Options; + +/// +/// Description constants for Azure Managed Cleanroom options shared across commands. +/// Options are defined inline via on each options class. +/// +public static class ManagedCleanroomOptionDescriptions +{ + public const string Endpoint = + "The Azure Cleanroom Analytics Frontend service endpoint URL (e.g., 'https://my-cleanroom.cloudapp.azure.net')."; + + public const string CollaborationId = + "The unique identifier (UUID) of the cleanroom collaboration."; + + public const string TokenScope = + "Optional Microsoft Entra token scope for the cleanroom frontend API (for example, 'https://my-cleanroom.cloudapp.azure.net/.default'). Defaults to '/.default'."; + + public const string DocumentId = + "The unique identifier (UUID) of the dataset document to publish."; +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/IManagedCleanroomService.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/IManagedCleanroomService.cs new file mode 100644 index 0000000000..cb304db55f --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/IManagedCleanroomService.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Services; + +/// +/// Data-plane operations against the Cleanroom Analytics Frontend service. +/// Authentication uses a bearer token scoped to the frontend endpoint. +/// +public interface IManagedCleanroomServiceDataPlane +{ + Task ListCollaborationsAsync( + string endpoint, + bool? activeOnly = null, + string? tokenScope = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); +} + +/// +/// Control-plane (ARM) operations for managing Cleanroom collaboration resources. +/// Authentication uses the standard Azure Resource Manager credential. +/// +public interface IManagedCleanroomServiceControlPlane +{ + Task CreateCollaborationArmResourceAsync( + string name, + string resourceGroup, + string subscription, + string location, + string? resourceLocation = null, + string[]? collaborators = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); +} + +/// Result returned by . +/// ARM resource properties as a raw . +/// Human-readable summary of the provisioning outcome including elapsed time. +public sealed record CollaborationCreateResult(System.Text.Json.JsonElement Properties, string Message); diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/ManagedCleanroomService.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/ManagedCleanroomService.cs new file mode 100644 index 0000000000..55d9153ec8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/src/Services/ManagedCleanroomService.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using System.Text.Json; +using AnalyticsFrontendAPI; +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.ManagedCleanroom.Commands; +using Azure.ResourceManager; +using Azure.ResourceManager.CleanRoom; +using Azure.ResourceManager.CleanRoom.Models; +using Azure.ResourceManager.Resources; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Services; + +public class ManagedCleanroomService(ISubscriptionService subscriptionService, ITenantService tenantService, IHttpClientFactory httpClientFactory) + : BaseAzureResourceService(subscriptionService, tenantService), IManagedCleanroomServiceDataPlane, IManagedCleanroomServiceControlPlane +{ + private readonly ISubscriptionService _subscriptionService = subscriptionService; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + private static readonly TimeSpan ProvisioningPollInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ProvisioningTimeout = TimeSpan.FromMinutes(40); + + // Note: These constants are retained for future use in a dedicated status-check command. + + public async Task ListCollaborationsAsync( + string endpoint, + bool? activeOnly = null, + string? tokenScope = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var client = await BuildClientAsync(endpoint, tokenScope, tenant, cancellationToken) + .ConfigureAwait(false); + + var requestContext = new RequestContext { CancellationToken = cancellationToken }; + Response response = await client.GetGetsAsync(activeOnly, requestContext).ConfigureAwait(false); + + return ParseResponse(response); + } + + public async Task CreateCollaborationArmResourceAsync( + string name, + string resourceGroup, + string subscription, + string location, + string? resourceLocation = null, + string[]? collaborators = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(name), name), + (nameof(resourceGroup), resourceGroup), + (nameof(subscription), subscription), + (nameof(location), location)); + + var armClient = await CreateArmClientAsync(tenant, retryPolicy, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + var subscriptionResource = await _subscriptionService + .GetSubscription(subscription, tenant, retryPolicy, cancellationToken) + .ConfigureAwait(false); + + var resourceGroupId = ResourceGroupResource.CreateResourceIdentifier( + subscriptionResource.Id.SubscriptionId!, + resourceGroup); + var resourceGroupResource = armClient.GetResourceGroupResource(resourceGroupId); + + var collaborationData = new CollaborationData(new Azure.Core.AzureLocation(location)) + { + ResourceLocation = new Azure.Core.AzureLocation(resourceLocation ?? location) + }; + + foreach (var collaborator in collaborators ?? []) + { + collaborationData.Collaborators.Add(new Collaborator + { + UserIdentifier = collaborator + }); + } + + // Fire the ARM PUT and return immediately — provisioning takes ~25 minutes in the background. + await resourceGroupResource.GetCollaborations() + .CreateOrUpdateAsync( + WaitUntil.Started, + name, + collaborationData, + cancellationToken) + .ConfigureAwait(false); + + var message = $"Collaboration '{name}' creation request accepted. " + + "Provisioning is running in the background and typically takes ~25 minutes to complete. " + + $"You can check the status by asking to get the collaboration '{name}' in resource group '{resourceGroup}'."; + + return new CollaborationCreateResult(default, message); + } + + private async Task BuildClientAsync( + string endpoint, + string? tokenScope, + string? tenant, + CancellationToken cancellationToken) + { + ValidateRequiredParameters((nameof(endpoint), endpoint)); + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri)) + { + throw new ArgumentException($"Endpoint '{endpoint}' is not a valid absolute URI.", nameof(endpoint)); + } + + if (endpointUri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("Endpoint must use HTTPS.", nameof(endpoint)); + } + + var credential = await GetCredential(tenant, cancellationToken).ConfigureAwait(false); + var options = new CollaborationClientOptions(); + var scope = ResolveTokenScope(endpointUri, tokenScope); + options.AddPolicy( + new BearerTokenAuthenticationPolicy(credential, scope), + HttpPipelinePosition.PerCall); + + var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL"); + if (!string.IsNullOrWhiteSpace(testProxyUrl)) + { + options.Transport = new HttpClientTransport(_httpClientFactory.CreateClient()); + } + else + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + options.Transport = new HttpClientTransport(handler); + } + + return new CollaborationClient(endpointUri, options); + } + + internal static string ResolveTokenScope(Uri endpointUri, string? tokenScope) + { + if (!string.IsNullOrWhiteSpace(tokenScope)) + { + return tokenScope; + } + + return $"{endpointUri.GetLeftPart(UriPartial.Authority)}/.default"; + } + + internal static Uri BuildCollaborationsListUri(Uri endpointUri, bool? activeOnly) + { + var builder = new UriBuilder(endpointUri); + var path = builder.Path; + if (string.IsNullOrEmpty(path) || path == "/") + { + builder.Path = "/gets"; + } + else + { + builder.Path = path.TrimEnd('/') + "/gets"; + } + + if (activeOnly.HasValue) + { + var activeOnlyQuery = $"activeOnly={(activeOnly.Value ? "true" : "false")}"; + builder.Query = string.IsNullOrEmpty(builder.Query) + ? activeOnlyQuery + : builder.Query.TrimStart('?') + "&" + activeOnlyQuery; + } + + return builder.Uri; + } + + private static JsonElement ParseResponse(Response response) + { + if (response.Content is null) + { + return default; + } + + return JsonSerializer.Deserialize( + response.Content.ToMemory().Span, + ManagedCleanroomJsonContext.Default.JsonElement); + } + + private static JsonElement SerializeCollaborationData(CollaborationData data) + { + // Create a minimal JSON representation with the key properties + // to avoid AOT-incompatible serialization + using var ms = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + writer.WriteStartObject(); + writer.WriteString("provisioningState", data.ProvisioningState?.ToString()); + writer.WriteString("resourceLocation", data.ResourceLocation?.Name); + writer.WritePropertyName("collaborators"); + writer.WriteStartArray(); + foreach (var collaborator in data.Collaborators) + { + writer.WriteStartObject(); + writer.WriteString("userIdentifier", collaborator.UserIdentifier); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + ms.Position = 0; + using var doc = JsonDocument.Parse(ms); + return doc.RootElement.Clone(); + } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/AssemblyAttributes.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/AssemblyAttributes.cs new file mode 100644 index 0000000000..92cc1acc9f --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/AssemblyAttributes.cs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +[assembly: Microsoft.Mcp.Tests.Helpers.ClearEnvironmentVariablesBeforeTest] +[assembly: Xunit.CollectionBehavior(Xunit.CollectionBehavior.CollectionPerAssembly)] diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Azure.Mcp.Tools.ManagedCleanroom.Tests.csproj b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Azure.Mcp.Tools.ManagedCleanroom.Tests.csproj new file mode 100644 index 0000000000..0643708b62 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Azure.Mcp.Tools.ManagedCleanroom.Tests.csproj @@ -0,0 +1,20 @@ + + + true + Exe + true + true + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/CollaborationArm/CollaborationCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/CollaborationArm/CollaborationCreateCommandTests.cs new file mode 100644 index 0000000000..28d7836afb --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/CollaborationArm/CollaborationCreateCommandTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.ManagedCleanroom.Commands; +using Azure.Mcp.Tools.ManagedCleanroom.Commands.CollaborationArm; +using Azure.Mcp.Tools.ManagedCleanroom.Services; +using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Tests.CollaborationArm; + +public sealed class CollaborationCreateCommandTests + : SubscriptionCommandUnitTestsBase +{ + private const string TestName = "my-collab"; + private const string TestLocation = "eastus"; + private const string TestResourceGroup = "my-rg"; + private const string TestSubscription = "test-sub"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--name my-collab --location eastus --resource-group my-rg --subscription test-sub", true)] + [InlineData("--location eastus --resource-group my-rg --subscription test-sub", false)] + [InlineData("--name my-collab --resource-group my-rg --subscription test-sub", false)] + [InlineData("--name my-collab --location eastus --subscription test-sub", false)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.CreateCollaborationArmResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new CollaborationCreateResult(default, string.Empty)); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (!shouldSucceed) + { + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + var expected = JsonDocument.Parse("""{"name":"my-collab","properties":{"provisioningState":"Succeeded"}}""").RootElement; + Service.CreateCollaborationArmResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new CollaborationCreateResult(expected, "Collaboration 'my-collab' creation request accepted. Provisioning is running in the background and typically takes ~25 minutes to complete.")); + + var response = await ExecuteCommandAsync( + "--name", TestName, "--location", TestLocation, + "--resource-group", TestResourceGroup, "--subscription", TestSubscription); + + var result = ValidateAndDeserializeResponse(response, ManagedCleanroomJsonContext.Default.JsonElement); + Assert.Equal(JsonValueKind.Object, result.ValueKind); + result.AssertProperty("name"); + } + + [Fact] + public async Task ExecuteAsync_ReturnsServiceResponse() + { + Service.CreateCollaborationArmResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new CollaborationCreateResult(default, "Collaboration 'my-collab' creation request accepted. Provisioning is running in the background and typically takes ~25 minutes to complete.")); + + var response = await ExecuteCommandAsync( + "--name", TestName, "--location", TestLocation, + "--resource-group", TestResourceGroup, "--subscription", TestSubscription); + + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.Contains("accepted", response.Message, StringComparison.OrdinalIgnoreCase); + await Service.Received(1).CreateCollaborationArmResourceAsync( + TestName, TestResourceGroup, TestSubscription, TestLocation, + null, null, null, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.CreateCollaborationArmResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync( + "--name", TestName, "--location", TestLocation, + "--resource-group", TestResourceGroup, "--subscription", TestSubscription); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + Assert.Contains("troubleshooting", response.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Collaborations/CollaborationsListCommandTests.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Collaborations/CollaborationsListCommandTests.cs new file mode 100644 index 0000000000..9f4e80bb06 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Collaborations/CollaborationsListCommandTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Tools.ManagedCleanroom.Commands; +using Azure.Mcp.Tools.ManagedCleanroom.Commands.Collaborations; +using Azure.Mcp.Tools.ManagedCleanroom.Services; +using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Tests.Collaborations; + +public sealed class CollaborationsListCommandTests : CommandUnitTestsBase +{ + private const string TestEndpoint = "https://my-cleanroom.cloudapp.azure.net"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("list", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--endpoint https://my-cleanroom.cloudapp.azure.net", true)] + [InlineData("--endpoint https://my-cleanroom.cloudapp.azure.net --active-only true", true)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(default(JsonElement)); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (!shouldSucceed) + { + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + var expected = JsonDocument.Parse("""{"collaborations":[{"collaborationId":"c1","collaborationName":"test","userStatus":"Active"}]}""").RootElement; + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expected); + + var response = await ExecuteCommandAsync("--endpoint", TestEndpoint); + + var result = ValidateAndDeserializeResponse(response, ManagedCleanroomJsonContext.Default.JsonElement); + Assert.Equal(JsonValueKind.Object, result.ValueKind); + result.AssertProperty("collaborations"); + } + + [Fact] + public async Task ExecuteAsync_ReturnsServiceResponse() + { + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(default(JsonElement)); + + var response = await ExecuteCommandAsync("--endpoint", TestEndpoint); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).ListCollaborationsAsync( + TestEndpoint, null, null, null, null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithActiveOnly_PassesFlagThrough() + { + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(default(JsonElement)); + + var response = await ExecuteCommandAsync("--endpoint", TestEndpoint, "--active-only", "true"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).ListCollaborationsAsync( + TestEndpoint, true, null, null, null, Arg.Any()); + + } + + [Fact] + public async Task ExecuteAsync_WithTokenScope_PassesScopeThrough() + { + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(default(JsonElement)); + + var scope = "https://my-cleanroom.cloudapp.azure.net/.default"; + var response = await ExecuteCommandAsync("--endpoint", TestEndpoint, "--token-scope", scope); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).ListCollaborationsAsync( + TestEndpoint, null, scope, null, null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.ListCollaborationsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync("--endpoint", TestEndpoint); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + } +} + diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Services/ManagedCleanroomServiceUriTests.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Services/ManagedCleanroomServiceUriTests.cs new file mode 100644 index 0000000000..ac6c9327b3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Services/ManagedCleanroomServiceUriTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.ManagedCleanroom.Services; + +namespace Azure.Mcp.Tools.ManagedCleanroom.Tests.Services; + +public sealed class ManagedCleanroomServiceUriTests +{ + [Fact] + public void BuildCollaborationsListUri_WithRootEndpoint_BuildsGetsPath() + { + var endpoint = new Uri("https://cleanroom.contoso.net"); + + var result = ManagedCleanroomService.BuildCollaborationsListUri(endpoint, null); + + Assert.Equal("https://cleanroom.contoso.net/gets", result.ToString()); + } + + [Fact] + public void BuildCollaborationsListUri_WithNonRootPath_AppendsGetsPath() + { + var endpoint = new Uri("https://cleanroom.contoso.net/api/v1"); + + var result = ManagedCleanroomService.BuildCollaborationsListUri(endpoint, true); + + Assert.Equal("https://cleanroom.contoso.net/api/v1/gets?activeOnly=true", result.ToString()); + } + + [Fact] + public void BuildCollaborationsListUri_WithExistingQuery_PreservesAndAppendsActiveOnly() + { + var endpoint = new Uri("https://cleanroom.contoso.net/api?foo=bar"); + + var result = ManagedCleanroomService.BuildCollaborationsListUri(endpoint, false); + + Assert.Equal("https://cleanroom.contoso.net/api/gets?foo=bar&activeOnly=false", result.ToString()); + } + + [Fact] + public void ResolveTokenScope_WithExplicitScope_ReturnsScope() + { + var endpoint = new Uri("https://cleanroom.contoso.net"); + const string explicitScope = "api://cleanroom-api/.default"; + + var result = ManagedCleanroomService.ResolveTokenScope(endpoint, explicitScope); + + Assert.Equal(explicitScope, result); + } + + [Fact] + public void ResolveTokenScope_WithoutScope_UsesEndpointOriginDefaultScope() + { + var endpoint = new Uri("https://cleanroom.contoso.net/api"); + + var result = ManagedCleanroomService.ResolveTokenScope(endpoint, null); + + Assert.Equal("https://cleanroom.contoso.net/.default", result); + } +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Usings.cs b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Usings.cs new file mode 100644 index 0000000000..8c07c6cf4c --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/Usings.cs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using Xunit; diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/assets.json b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/assets.json new file mode 100644 index 0000000000..4e47452c15 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/Azure.Mcp.Tools.ManagedCleanroom.Tests/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "", + "TagPrefix": "Azure.Mcp.Tools.ManagedCleanroom.Tests", + "Tag": "" +} diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources-post.ps1 new file mode 100644 index 0000000000..403b5525b5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources-post.ps1 @@ -0,0 +1,48 @@ +param( + [string] $TenantId, + [string] $TestApplicationId, + [string] $ResourceGroupName, + [string] $BaseName, + [hashtable] $DeploymentOutputs, + [hashtable] $AdditionalParameters +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/../../../eng/common/scripts/common.ps1" +. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" + +$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot + +function Get-DeploymentOutputValue { + param( + [hashtable] $Outputs, + [string] $Name + ) + + $output = $Outputs[$Name] + + if ($null -eq $output) { + return $null + } + + if ($output -is [hashtable] -and $output.ContainsKey('value')) { + return [string] $output['value'] + } + + if ($output.PSObject.Properties['value']) { + return [string] $output.value + } + + return [string] $output +} + +$cleanroomEndpoint = Get-DeploymentOutputValue -Outputs $DeploymentOutputs -Name 'CLEANROOM_ENDPOINT' + +if ([string]::IsNullOrWhiteSpace($cleanroomEndpoint)) { + Write-Warning "CLEANROOM_ENDPOINT was not set. Live tests will be skipped until a Cleanroom Analytics Frontend endpoint is provisioned and provided." +} else { + Write-Host "Cleanroom Analytics Frontend endpoint: $cleanroomEndpoint" -ForegroundColor Gray +} + +Write-Host "Managed Cleanroom test settings saved to: $PSScriptRoot\.testsettings.json" -ForegroundColor Green diff --git a/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources.bicep new file mode 100644 index 0000000000..daf518de6e --- /dev/null +++ b/tools/Azure.Mcp.Tools.ManagedCleanroom/tests/test-resources.bicep @@ -0,0 +1,45 @@ +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(24) +@description('The base resource name. Must be between 3 and 24 characters.') +param baseName string + +@description('The location of the resource. By default, this is the same as the resource group.') +param location string = resourceGroup().location + +@description('The tenant ID to which the application and resources belong.') +param tenantId string + +@description('The client OID to grant access to test resources.') +param testApplicationOid string + +// NOTE: Azure Cleanroom Analytics Frontend is not a first-class ARM resource type. +// Live tests expect the CLEANROOM_ENDPOINT output to be supplied externally +// (e.g., via the post-deployment script reading an existing service endpoint). +@description('The Azure Cleanroom Analytics Frontend endpoint URL to test against.') +param cleanroomEndpoint string = '' + +@description('A known collaboration ID to use in live tests (collaborations get, analytics get, oidc issuer-info).') +param cleanroomCollaborationId string = '' + +resource readerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { + scope: subscription() + name: 'acdd72a7-3385-48ef-bd42-f606fba81ae7' +} + +resource testAppReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, testApplicationOid, readerRoleDefinition.id) + scope: resourceGroup() + properties: { + principalId: testApplicationOid + roleDefinitionId: readerRoleDefinition.id + description: 'Reader role assignment for managed cleanroom test application identity' + } +} + +output CLEANROOM_ENDPOINT string = cleanroomEndpoint +output CLEANROOM_COLLABORATION_ID string = cleanroomCollaborationId +output CLEANROOM_BASE_NAME string = baseName +output CLEANROOM_LOCATION string = location +output CLEANROOM_TENANT_ID string = tenantId diff --git a/vendor/cleanroom-nupkgs/Azure.Cleanroom.Analytics.Frontend.Client.1.0.0-beta.1.nupkg b/vendor/cleanroom-nupkgs/Azure.Cleanroom.Analytics.Frontend.Client.1.0.0-beta.1.nupkg new file mode 100644 index 0000000000..5f75977410 Binary files /dev/null and b/vendor/cleanroom-nupkgs/Azure.Cleanroom.Analytics.Frontend.Client.1.0.0-beta.1.nupkg differ diff --git a/vendor/cleanroom-nupkgs/Azure.ResourceManager.CleanRoom.1.0.0-alpha.20260603.1.nupkg b/vendor/cleanroom-nupkgs/Azure.ResourceManager.CleanRoom.1.0.0-alpha.20260603.1.nupkg new file mode 100644 index 0000000000..8f76ed140d Binary files /dev/null and b/vendor/cleanroom-nupkgs/Azure.ResourceManager.CleanRoom.1.0.0-alpha.20260603.1.nupkg differ