Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,20 @@
## Invoking Livetests

Copilot submitted PRs are not trustworthy by default. Users with `write` access to the repo need to validate the contents of this PR before leaving a comment with the text `/azp run mcp - pullrequest - live`. This will trigger the necessary livetest workflows to complete required validation.
```
```

## Transitioning Live Tests to Recorded Tests

- Always convert `tool` services to inject `IHttpClientService` into its clients and use `HttpClientService.CreateClient` method to instantiate the `HttpClient` that is used for all requests.
- If this is already prseent in the tool service, skip this step.
- Always reparent test classes parented from `CommandTestsBase` to `RecordedCommandTestsBase`, fixture changes should be made accordingly.
- Always generate a new `assets.json` file alongside the livetest csproj file if one does not exist. This file should contain the following content:
```jsonc
{
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "",
"TagPrefix": "<LiveTestCSProjFileNameWithoutExtension>", // e.g., "Azure.Mcp.Tools.KeyVault.LiveTests"
"Tag": ""
}
```
- Copilot should follow the `The sanitization/playback loop` loop section within the [recorded test documentation](../docs/recorded-tests.md) for more details on how to convert and validate recorded tests.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Azure.Mcp.Core.Options;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Core.Services.Azure.Tenant;
using Azure.Mcp.Core.Services.Http;
using Azure.ResourceManager;
using Azure.ResourceManager.ResourceGraph;
using Azure.ResourceManager.ResourceGraph.Models;
Expand All @@ -20,8 +21,9 @@ namespace Azure.Mcp.Core.Services.Azure;
/// </summary>
public abstract class BaseAzureResourceService(
ISubscriptionService subscriptionService,
ITenantService tenantService)
: BaseAzureService(tenantService)
ITenantService tenantService,
IHttpClientService? httpClientService = null)
: BaseAzureService(tenantService, httpClientService)
{
private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService));

Expand Down
31 changes: 29 additions & 2 deletions core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
using System.Reflection;
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.Mcp.Core.Services.Http;
using Azure.ResourceManager;

namespace Azure.Mcp.Core.Services.Azure;
Expand All @@ -16,6 +18,7 @@ public abstract class BaseAzureService
public static readonly string DefaultUserAgent;

private readonly ITenantService? _tenantServiceDoNotUseDirectly;
private IHttpClientService? _httpClientService;

static BaseAzureService()
{
Expand All @@ -34,10 +37,11 @@ static BaseAzureService()
/// <param name="tenantService">
/// An <see cref="ITenantService"/> used for Azure API calls.
/// </param>
protected BaseAzureService(ITenantService tenantService)
protected BaseAzureService(ITenantService tenantService, IHttpClientService? httpClientService = null)
{
ArgumentNullException.ThrowIfNull(tenantService, nameof(tenantService));
TenantService = tenantService;
_httpClientService = httpClientService;
}

/// <summary>
Expand Down Expand Up @@ -73,6 +77,15 @@ protected ITenantService TenantService
}
}

/// <summary>
/// Initializes the HTTP client service for derived classes that invoke the parameterless constructor.
/// </summary>
/// <param name="httpClientService">The HTTP client service instance to use.</param>
protected void InitializeHttpClientService(IHttpClientService httpClientService)
{
_httpClientService = httpClientService ?? throw new ArgumentNullException(nameof(httpClientService));
}

/// <summary>
/// Escapes a string value for safe use in KQL queries to prevent injection attacks.
/// </summary>
Expand Down Expand Up @@ -125,6 +138,18 @@ protected static T AddDefaultPolicies<T>(T clientOptions) where T : ClientOption
return clientOptions;
}

protected T ConfigureHttpClientTransport<T>(T clientOptions) where T : ClientOptions
{
if (_httpClientService == null || clientOptions.Transport is HttpClientTransport)
{
return clientOptions;
}

var httpClient = _httpClientService.CreateClient();
clientOptions.Transport = new HttpClientTransport(httpClient);
return clientOptions;
}

/// <summary>
/// Configures retry policy options on the provided client options
/// </summary>
Expand Down Expand Up @@ -179,7 +204,9 @@ protected async Task<ArmClient> CreateArmClientAsync(
{
TokenCredential credential = await GetCredential(tenantId, cancellationToken);
ArmClientOptions options = armClientOptions ?? new();
ConfigureRetryPolicy(AddDefaultPolicies(options), retryPolicy);
options = AddDefaultPolicies(options);
options = ConfigureRetryPolicy(options, retryPolicy);
options = ConfigureHttpClientTransport(options);

ArmClient armClient = new(credential, defaultSubscriptionId: default, options);
return armClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
using Azure.Mcp.Core.Options;
using Azure.Mcp.Core.Services.Azure.Tenant;
using Azure.Mcp.Core.Services.Caching;
using Azure.Mcp.Core.Services.Http;
using Azure.ResourceManager.Resources;

namespace Azure.Mcp.Core.Services.Azure.Subscription;

public class SubscriptionService(ICacheService cacheService, ITenantService tenantService)
: BaseAzureService(tenantService), ISubscriptionService
public class SubscriptionService(ICacheService cacheService, ITenantService tenantService, IHttpClientService? httpClientService = null)
: BaseAzureService(tenantService, httpClientService), ISubscriptionService
{
private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
private const string CacheGroup = "subscription";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Azure.Core;
using Azure.Mcp.Core.Services.Azure.Authentication;
using Azure.Mcp.Core.Services.Caching;
using Azure.Mcp.Core.Services.Http;
using Azure.ResourceManager;
using Azure.ResourceManager.Resources;

Expand All @@ -19,10 +20,12 @@ public class TenantService : BaseAzureService, ITenantService

public TenantService(
IAzureTokenCredentialProvider credentialProvider,
ICacheService cacheService)
ICacheService cacheService,
IHttpClientService httpClientService)
{
_credentialProvider = credentialProvider;
_cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
InitializeHttpClientService(httpClientService);
TenantService = this;
}

Expand All @@ -39,7 +42,7 @@ public async Task<List<TenantResource>> GetTenants()
// If not in cache, fetch from Azure
var results = new List<TenantResource>();

var options = AddDefaultPolicies(new ArmClientOptions());
var options = ConfigureHttpClientTransport(AddDefaultPolicies(new ArmClientOptions()));
var client = new ArmClient(await GetCredential(), default, options);

await foreach (var tenant in client.GetTenants())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Extensions;
using Azure.Mcp.Core.Services.Azure.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -48,6 +49,7 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s
// container to be populated with the correct authentication strategy, such as OBO for
// running as a remote HTTP MCP service.
services.AddSingleIdentityTokenCredentialProvider();
services.AddHttpClientServices();

services.TryAddSingleton<ITenantService, TenantService>();
return services;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"Entries": [],
"Variables": {
"attrKey": "attrValue"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"Entries": [],
"Variables": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"Entries": [],
"Variables": {
"attrKey": "attrValue"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"Entries": [],
"Variables": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"Entries": [],
"Variables": {}
}
35 changes: 35 additions & 0 deletions docs/recorded-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Recorded Testing in `microsoft/mcp`

## Context

This repository ships CLI tools. Specifically, multiple combinations of `tools` assembled into `mcp servers` that are effectively standalone CLI tools themselves.

This complicates the record/playback story somewhat, as the process code itself is running _separately_ during our livetesting. If your tool's livetests are based upon `CommandTestsBase` from `Azure.Mcp.Tests`, then you'll be familiar with this process:

On each live test:
- New up test class, instantiate `Settings` from a locally found `.livetestsettings.json` (created by `./eng/scripts/Deploy-TestResources.ps1`).
- Set appropriate `environment` variable settings to match needs of livetest, ensuring that environment is primed to use the logged in powershell credential automatically.
- Call an `mcp server` as a CLI command, passing it specific instructions formatted within the test.
- `mcp server` uses prepared local env for auth, completes the task, and returns output to the test
- Test asserts on output of tool

Notably, in the above process, the call of the tool is calling into a prebuilt EXE, there is no opportunity for httpclient injection or the like that would allow recordings to be made from tool calls. This was solved for this repo by the following.

- During **only** `debug` builds `HttpClientService` has been enhanced to support `redirect` automagically based upon the presence of `TEST_PROXY_URL` variable.

## Converting a `LiveTest` to a `Recorded` test


## The sanitization/playback loop

- If a `.livesettings` file exists at root of your `tool`, continue.
- Update the `TestMode` setting within the `.livesettings.json` alongside each livetests csproj to `Record`.
- Invoke the livetests.
- Use `.proxy/Azure.Sdk.Tools.TestProxy(.exe) config locate -a path/to/assets.json` to locate the recordings directory
- Review recordings for secrets.
- Temporarily rename the `livesettings.json` to a different name to force `playback` mode. I use `DISABLED.livesettings.json`.
- Invoke the livetests again. If they all pass, and secret review was clean, push.
- `.proxy/Azure.Sdk.Tools.TestPRoxy(.exe) push -a path/to/assets.json`
- If they _do not_ pass, examine each individual test error. There should be some sort of mismatch or the like in the test-proxy error.
- Commit the `assets.json` updated with the new tag.

19 changes: 18 additions & 1 deletion eng/pipelines/templates/jobs/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
-SingleFile

- task: Powershell@2
displayName: "Run tests"
displayName: "Run unit tests"
condition: and(succeeded(), eq(variables['RunUnitTests'], 'true'))
timeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }}
inputs:
Expand All @@ -88,6 +88,23 @@ jobs:
env:
AZURE_MCP_COLLECT_TELEMETRY: 'false'

- task: Powershell@2
displayName: "Run recorded tests"
condition: and(succeeded(), eq(variables['RunRecordedTests'], 'true'))
timeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }}
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Test-Code.ps1
arguments: >
-CollectCoverage:$${{ eq(parameters.OSName, 'linux') }}
-TestType 'Live'
-TestResultsPath '$(Build.ArtifactStagingDirectory)/testResults'
workingDirectory: $(Build.SourcesDirectory)
env:
AZURE_MCP_COLLECT_TELEMETRY: 'false'

# todo: publish the recorded test results separately from unit test results

- task: PublishTestResults@2
displayName: "Publish Results"
condition: and(succeededOrFailed(), eq(variables['RunUnitTests'], 'true'))
Expand Down
16 changes: 9 additions & 7 deletions eng/scripts/New-BuildInfo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function Get-LatestMarketplaceVersion {
[string]$ExtensionId,
[int]$MajorVersion
)

try {
$marketplaceUrl = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery?api-version=7.1-preview.1"
$body = @{
Expand Down Expand Up @@ -113,7 +113,7 @@ function Get-LatestMarketplaceVersion {
Patch = [int]$Matches[1]
}
}

if ($matchingVersions) {
$maxPatch = ($matchingVersions | Measure-Object -Property Patch -Maximum).Maximum
return [PSCustomObject]@{
Expand Down Expand Up @@ -281,6 +281,7 @@ function Get-PathsToTest {
$rootedTestResourcesPath = "$RepoRoot/$testResourcesPath"
$hasTestResources = Test-Path "$rootedTestResourcesPath/test-resources.bicep"
$hasLiveTests = (Get-ChildItem $rootedTestResourcesPath -Filter '*.LiveTests.csproj' -Recurse).Count -gt 0
$hasRecordedTests = $hasLiveTests -and (Get-ChildItem $rootedTestResourcesPath -Filter 'assets.json' -Recurse).Count -gt 0
$hasUnitTests = (Get-ChildItem $rootedTestResourcesPath -Filter '*.UnitTests.csproj' -Recurse).Count -gt 0

$pathsToTest += @{
Expand All @@ -289,6 +290,7 @@ function Get-PathsToTest {
testResourcesPath = $hasTestResources ? $testResourcesPath : $null
hasLiveTests = $hasLiveTests
hasUnitTests = $hasUnitTests
hasRecordedTests = $hasRecordedTests
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll want to consume this on line 548 in Get-BuildMatrices

We should start setting an additional RunRecordedTests based on any $pathsToTest having hasRecordedTests.

You'll have to start passing $pathsToTest to Get-BuildMatrices as it corrently only depends on $serverDetails

    $serverDetails = @(Get-ServerDetails)
    $pathsToTest = @(Get-PathsToTest)
    $matrices = Get-BuildMatrices -Servers $serverDetails -PathsToTest $pathsToTest

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ty for the heads up. Workin it

}
}

Expand Down Expand Up @@ -372,7 +374,7 @@ function Get-ServerDetails {
elseif ($PublishTarget -eq 'public') {
# Check if this is X.0.0-beta.Y series
$isBetaSeries = $version.Minor -eq 0 -and $version.Patch -eq 0 -and $version.PrereleaseLabel -eq 'beta'

if ($isBetaSeries) {
# Map X.0.0-beta.Y -> VSIX X.0.Y (prerelease)
$vsixVersion = "$($version.Major).$($version.Minor).$($version.PrereleaseNumber)"
Expand All @@ -382,17 +384,17 @@ function Get-ServerDetails {
# For all non-beta versions, calculate next patch version from marketplace
$vscodePath = "$RepoRoot/servers/$serverName/vscode"
$packageJsonPath = "$vscodePath/package.json"

if (Test-Path $packageJsonPath) {
$packageJson = Get-Content $packageJsonPath -Raw | ConvertFrom-Json
$publisherId = $packageJson.publisher
$extensionName = $packageJson.name

if ($publisherId -and $extensionName) {
Write-Host "Fetching latest marketplace version for $publisherId.$extensionName with major version $($version.Major)..." -ForegroundColor Cyan

$marketplaceInfo = Get-LatestMarketplaceVersion -PublisherId $publisherId -ExtensionId $extensionName -MajorVersion $version.Major

if ($marketplaceInfo) {
# Use next patch version from marketplace
$vsixVersion = "$($version.Major).0.$($marketplaceInfo.NextPatch)"
Expand Down
14 changes: 12 additions & 2 deletions tools/Azure.Mcp.Tools.AppConfig/src/Services/AppConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,30 @@
// Licensed under the MIT License.

using System.Text.Json;
using Azure.Core.Pipeline;
using Azure.Data.AppConfiguration;
using Azure.Mcp.Core.Models.Identity;
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.Core.Services.Http;
using Azure.Mcp.Tools.AppConfig.Models;
using Microsoft.Extensions.Logging;

namespace Azure.Mcp.Tools.AppConfig.Services;

using ETag = Core.Models.ETag;

public sealed class AppConfigService(ISubscriptionService subscriptionService, ITenantService tenantService, ILogger<AppConfigService> logger)
: BaseAzureResourceService(subscriptionService, tenantService), IAppConfigService
public sealed class AppConfigService(
ISubscriptionService subscriptionService,
ITenantService tenantService,
ILogger<AppConfigService> logger,
IHttpClientService httpClientService)
: BaseAzureResourceService(subscriptionService, tenantService, httpClientService), IAppConfigService
{
private readonly ILogger<AppConfigService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IHttpClientService _httpClientService = httpClientService ?? throw new ArgumentNullException(nameof(httpClientService));

public async Task<List<AppConfigurationAccount>> GetAppConfigAccounts(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -153,6 +160,9 @@ private async Task<ConfigurationClient> GetConfigurationClient(string accountNam
var options = new ConfigurationClientOptions();
AddDefaultPolicies(options);

var httpClient = _httpClientService.CreateClient(new Uri(endpoint));
options.Transport = new HttpClientTransport(httpClient);

return new ConfigurationClient(new Uri(endpoint), credential, options);
}

Expand Down
Loading