From b35be3833efd5740f80121aa2df6bca510b72602 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Thu, 20 Aug 2026 14:39:07 -0700 Subject: [PATCH 1/6] Ensure that Run tests (http) is actually testing http --- eng/pipelines/templates/jobs/live-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/eng/pipelines/templates/jobs/live-test.yml b/eng/pipelines/templates/jobs/live-test.yml index fcb67e0e50..4918113f5a 100644 --- a/eng/pipelines/templates/jobs/live-test.yml +++ b/eng/pipelines/templates/jobs/live-test.yml @@ -96,6 +96,7 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) AZURE_MCP_COLLECT_TELEMETRY: 'false' + MCP_TEST_TRANSPORT: 'http' inputs: azureSubscription: azure-sdk-tests-public azurePowerShellVersion: 'LatestVersion' From bab62d345a17af83bcd3d6ebaf5cfd38129c5c10 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Mon, 24 Aug 2026 12:22:24 -0700 Subject: [PATCH 2/6] Update ClientToolTests to support both http and stdio --- .../Azure.Mcp.Core.Tests/ClientToolTests.cs | 72 +++++++++++-------- eng/pipelines/templates/jobs/live-test.yml | 1 - 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/ClientToolTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/ClientToolTests.cs index 1d3c95797b..bc34708c50 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/ClientToolTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/ClientToolTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net; using System.Text.Json; using Microsoft.Mcp.Tests; using Microsoft.Mcp.Tests.Client; @@ -62,49 +63,49 @@ public async Task Client_Should_Ping_Server_Successfully() // The `ping` method was removed in the MCP 2026-07-28 protocol revision. The client // negotiates the modern protocol, so the server rejects ping as unavailable. // (Method name is retained so the recorded playback session continues to match.) - var ex = await Assert.ThrowsAsync(async () => - await Client.PingAsync(cancellationToken: TestContext.Current.CancellationToken)); - Assert.Contains("ping", ex.Message, StringComparison.OrdinalIgnoreCase); + await AssertMethodNotFoundAsync( + async () => await Client.PingAsync(cancellationToken: TestContext.Current.CancellationToken), + "ping"); } [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); + await AssertMethodNotFoundAsync( + async () => await Client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken), + "resources/list"); } [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); + await AssertMethodNotFoundAsync( + async () => await Client.ReadResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken), + "resources/read"); } [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); + await AssertMethodNotFoundAsync( + async () => await Client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken), + "resources/templates/list"); } [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); + await AssertMethodNotFoundAsync( + () => Client.SubscribeToResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken), + "resources/subscribe"); } [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); + await AssertMethodNotFoundAsync( + () => Client.UnsubscribeFromResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken), + "resources/unsubscribe"); } [Fact] @@ -114,27 +115,42 @@ public async Task Should_Not_Hang_On_Logging_SetLevel_Not_Supported() // The method is no longer supported; per-request log level is now set via // _meta/io.modelcontextprotocol/logLevel. The call should throw rather than hang. #pragma warning disable MCP9005 // Type or member is obsolete - var ex = await Assert.ThrowsAsync( - async () => await Client.SetLoggingLevelAsync(LoggingLevel.Info, - cancellationToken: TestContext.Current.CancellationToken)); + await AssertMethodNotFoundAsync( + () => Client.SetLoggingLevelAsync(LoggingLevel.Info, + cancellationToken: TestContext.Current.CancellationToken), + "logging/setLevel"); #pragma warning restore MCP9005 // Type or member is obsolete - Assert.Contains("logging/setLevel", ex.Message, StringComparison.OrdinalIgnoreCase); } [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); + await AssertMethodNotFoundAsync( + async () => await Client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken), + "prompts/list"); } [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); + await AssertMethodNotFoundAsync( + async () => await Client.GetPromptAsync("unsupported_prompt", cancellationToken: TestContext.Current.CancellationToken), + "prompts/get"); + } + + private static async Task AssertMethodNotFoundAsync(Func action, string method) + { + if (string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + { + var exception = await Assert.ThrowsAsync(action); + Assert.Equal(HttpStatusCode.NotFound, exception.StatusCode); + Assert.Contains(method, exception.Message, StringComparison.OrdinalIgnoreCase); + return; + } + + var protocolException = await Assert.ThrowsAsync(action); + Assert.Equal(McpErrorCode.MethodNotFound, protocolException.ErrorCode); + Assert.Contains(method, protocolException.Message, StringComparison.OrdinalIgnoreCase); } public override List BodyRegexSanitizers => diff --git a/eng/pipelines/templates/jobs/live-test.yml b/eng/pipelines/templates/jobs/live-test.yml index 4918113f5a..e4f65a5594 100644 --- a/eng/pipelines/templates/jobs/live-test.yml +++ b/eng/pipelines/templates/jobs/live-test.yml @@ -88,7 +88,6 @@ jobs: ServiceConnection: azure-sdk-tests-public PersistOidcToken: true TestResourcesDirectory: $(Build.SourcesDirectory)/$(TestResourcesPath) - AdditionalParameters: "@{ UseHttpTransport = true }" SkipEnvironmentSetup: true # environment setup was performed in the first deployment - task: AzurePowershell@5 From 7c70042f404743433cb62b64dca8ac5b51f9748b Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Mon, 24 Aug 2026 17:15:01 -0700 Subject: [PATCH 3/6] Update readiness cancelation --- .../Client/Helpers/McpTestUtilities.cs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/core/Microsoft.Mcp.Core/tests/Microsoft.Mcp.Tests/Client/Helpers/McpTestUtilities.cs b/core/Microsoft.Mcp.Core/tests/Microsoft.Mcp.Tests/Client/Helpers/McpTestUtilities.cs index c59f5d1175..db18c27ce2 100644 --- a/core/Microsoft.Mcp.Core/tests/Microsoft.Mcp.Tests/Client/Helpers/McpTestUtilities.cs +++ b/core/Microsoft.Mcp.Core/tests/Microsoft.Mcp.Tests/Client/Helpers/McpTestUtilities.cs @@ -242,8 +242,33 @@ public static async Task StartHttpServerProcessAndWaitForReadinessAsync output, disableAuthentication); - // Invert: disableAuthentication=false means authenticationEnabled=true - await WaitForServerReadinessAsync(serverUrl, timeoutSeconds, pollIntervalMs, authenticationEnabled: !disableAuthentication); + using var readinessCancellation = new CancellationTokenSource(); + var readinessTask = WaitForServerReadinessAsync( + serverUrl, + timeoutSeconds, + pollIntervalMs, + authenticationEnabled: !disableAuthentication, + readinessCancellation.Token); + var processExitTask = process.WaitForExitAsync(); + + if (await Task.WhenAny(readinessTask, processExitTask) == processExitTask) + { + await readinessCancellation.CancelAsync(); + try + { + await readinessTask; + } + catch (OperationCanceledException) + { + } + + throw new ClientTransportClosedException(new ClientCompletionDetails + { + Exception = new InvalidOperationException($"HTTP server process exited with code {process.ExitCode} before becoming ready.") + }); + } + + await readinessTask; return process; } @@ -259,7 +284,8 @@ public static async Task WaitForServerReadinessAsync( string serverUrl, int timeoutSeconds = 30, int pollIntervalMs = 500, - bool authenticationEnabled = false) + bool authenticationEnabled = false, + CancellationToken cancellationToken = default) { using var httpClient = new HttpClient(); var timeout = TimeSpan.FromSeconds(timeoutSeconds); @@ -284,7 +310,7 @@ public static async Task WaitForServerReadinessAsync( }; requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - using var resp = await httpClient.SendAsync(requestMessage); + using var resp = await httpClient.SendAsync(requestMessage, cancellationToken); // If authentication is enabled, 401 Unauthorized means server is ready // If authentication is disabled, we need a success status code @@ -298,7 +324,7 @@ public static async Task WaitForServerReadinessAsync( { // Server not yet available, continue polling } - await Task.Delay(pollIntervalMs); + await Task.Delay(pollIntervalMs, cancellationToken); } throw new TimeoutException($"Server at {serverUrl} did not become ready within {timeoutSeconds} seconds"); From f192faa65d6c92fcdefa6a7f1aab5e2687eccbe2 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Mon, 24 Aug 2026 21:31:37 -0700 Subject: [PATCH 4/6] Update AzureMigrateCommandTests --- .../AzureMigrateCommandTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs b/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs index 2b09dc7756..7f444ecd40 100644 --- a/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs @@ -31,6 +31,11 @@ public class AzureMigrateCommandTests(ITestOutputHelper output, TestProxyFixture [Fact] public async Task Should_check_platform_landing_zone_exists() { + if (await AssertLocalToolIsUnavailableInHttpMode()) + { + return; + } + var result = await CallToolAsync( "azuremigrate_platformlandingzone_request", new() @@ -54,6 +59,11 @@ public async Task Should_check_platform_landing_zone_exists() [Fact] public async Task Should_update_platform_landing_zone_parameters() { + if (await AssertLocalToolIsUnavailableInHttpMode()) + { + return; + } + var result = await CallToolAsync( "azuremigrate_platformlandingzone_request", new() @@ -84,6 +94,11 @@ public async Task Should_update_platform_landing_zone_parameters() [Fact] public async Task Should_get_parameter_status() { + if (await AssertLocalToolIsUnavailableInHttpMode()) + { + return; + } + var result = await CallToolAsync( "azuremigrate_platformlandingzone_request", new() @@ -104,6 +119,11 @@ public async Task Should_get_parameter_status() [Fact] public async Task Should_handle_invalid_action() { + if (await AssertLocalToolIsUnavailableInHttpMode()) + { + return; + } + try { await CallToolAsync( @@ -123,4 +143,17 @@ await CallToolAsync( Assert.Contains("Invalid action", ex.Message, StringComparison.OrdinalIgnoreCase); } } + + private async Task AssertLocalToolIsUnavailableInHttpMode() + { + if (!string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var result = await Client.CallToolAsync("azuremigrate_platformlandingzone_request", new Dictionary()); + Assert.True(result.IsError); + Assert.Contains("not found", McpTestUtilities.GetFirstText(result.Content), StringComparison.OrdinalIgnoreCase); + return true; + } } From b64e951e1c7025f35243f4cf82b3ab95e3b3ccd6 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Tue, 25 Aug 2026 13:19:49 -0700 Subject: [PATCH 5/6] Update KeyVaultCommandTests and StorageCommandTests --- .../KeyVaultCommandTests.cs | 11 +++++++++++ .../StorageCommandTests.cs | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs index 8ba5c322fd..097b5a3389 100644 --- a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs +++ b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs @@ -217,6 +217,17 @@ public async Task Should_create_certificate() [Fact] public async Task Should_import_certificate() { + if (string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + { + var unavailableResult = await Client.CallToolAsync( + "keyvault_certificate_import", + new Dictionary(), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(unavailableResult.IsError); + Assert.Contains("not found", McpTestUtilities.GetFirstText(unavailableResult.Content), StringComparison.OrdinalIgnoreCase); + return; + } + var fakePassword = _importCertificateAssets.Password; var tempPath = _importCertificateAssets.CreateTempCopy(); diff --git a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs index 02406cac4e..5e9ec83889 100644 --- a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs @@ -264,6 +264,17 @@ public async Task Should_get_blob_details() [Fact] public async Task Should_upload_blob() { + if (string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + { + var unavailableResult = await Client.CallToolAsync( + "storage_blob_upload", + new Dictionary(), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(unavailableResult.IsError); + Assert.Contains("not found", McpTestUtilities.GetFirstText(unavailableResult.Content), StringComparison.OrdinalIgnoreCase); + return; + } + // Create a temporary file to upload var tempFileName = RegisterOrRetrieveVariable("blobName", $"test-upload-{DateTime.UtcNow.Ticks}.txt"); var tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName); From 1abdd2b422378dea6c4b12f04875cb78abe63dc2 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu Date: Wed, 26 Aug 2026 10:29:45 -0700 Subject: [PATCH 6/6] Add shared recorded test validation for local-only tools --- .github/agents/onboarding.agent.md | 2 ++ .github/copilot-instructions.md | 1 + .github/skills/add-azure-mcp-tools/SKILL.md | 13 ++++++++++++ AGENTS.md | 2 ++ CONTRIBUTING.md | 2 ++ .../Client/RecordedCommandTestsBase.cs | 21 +++++++++++++++++++ docs/recorded-tests.md | 7 ++++--- servers/Azure.Mcp.Server/docs/new-command.md | 11 ++++++++++ .../AzureMigrateCommandTests.cs | 20 ++++-------------- .../KeyVaultCommandTests.cs | 8 +------ .../StorageCommandTests.cs | 8 +------ 11 files changed, 62 insertions(+), 33 deletions(-) diff --git a/.github/agents/onboarding.agent.md b/.github/agents/onboarding.agent.md index a1d26632d0..2b4e034539 100644 --- a/.github/agents/onboarding.agent.md +++ b/.github/agents/onboarding.agent.md @@ -238,6 +238,8 @@ eng/common/TestResources/New-TestResources.ps1 ` Azure resource commands **require recorded live tests**. See `docs/recorded-tests.md` for the record/playback workflow. +For tools marked `LocalRequired = true`, every applicable test in a class extending `RecordedCommandTestsBase` must call `AssertLocalToolIsUnavailableInHttpMode(toolName)` and return early when it returns `true`. This verifies that remote HTTP mode excludes the local-only tool. + ### Testing Your Local Build Point your `mcp.json` at the freshly built binary: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 57b025b3d8..24f317e9e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,7 @@ - Always convert `tool` services to inject `IHttpClientFactory` into its clients and use `IHttpClientFactory.CreateClient` method to instantiate the `HttpClient` for usage in the tool classes' methods. - If `IHttpClientFactory` is already injected into the client, ensure that `IHttpClientFactory.CreateClient` is used to instantiate the `HttpClient`. If this is done, then no further action is needed. - Always re-parent test classes parented by `CommandTestsBase` to `RecordedCommandTestsBase`. This will require minor fixture adjustments. +- In test classes extending `RecordedCommandTestsBase`, every test for a `LocalRequired = true` tool must call `AssertLocalToolIsUnavailableInHttpMode(toolName)` and return early when it returns `true`. This verifies that remote HTTP mode does not expose the local-only tool. - 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 { diff --git a/.github/skills/add-azure-mcp-tools/SKILL.md b/.github/skills/add-azure-mcp-tools/SKILL.md index d5ca07a403..3a77e5e41f 100644 --- a/.github/skills/add-azure-mcp-tools/SKILL.md +++ b/.github/skills/add-azure-mcp-tools/SKILL.md @@ -786,6 +786,19 @@ public class {Toolset}CommandTests(ITestOutputHelper output, TestProxyFixture fi } ``` +#### `LocalRequired` tools + +Remote HTTP mode intentionally excludes tools marked `LocalRequired = true`. Every recorded test for such a tool must verify that exclusion and return before exercising local-only behavior: + +```csharp +if (await AssertLocalToolIsUnavailableInHttpMode("{toolset}_{resource}_{operation}")) +{ + return; +} +``` + +Use the inherited helper in every applicable test in a class extending `RecordedCommandTestsBase`; do not duplicate the transport check or unavailable-tool assertions. + ### 3c. Record and Verify #### Create assets.json diff --git a/AGENTS.md b/AGENTS.md index 65d1f619b6..f26851c7a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,8 @@ Command unit tests should extend `SubscriptionCommandUnitTestsBase null; + /// + /// In HTTP mode, verifies that a local-only tool is unavailable and indicates that the test should return early. + /// + /// The fully qualified MCP tool name. + /// when running in HTTP mode; otherwise, . + protected async Task AssertLocalToolIsUnavailableInHttpMode(string toolName) + { + if (!string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var result = await Client.CallToolAsync( + toolName, + new Dictionary(), + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.IsError); + Assert.Contains("not found", McpTestUtilities.GetFirstText(result.Content), StringComparison.OrdinalIgnoreCase); + return true; + } + protected override async ValueTask LoadSettingsAsync() { await base.LoadSettingsAsync(); diff --git a/docs/recorded-tests.md b/docs/recorded-tests.md index c29cad1cf1..fe3c9a3c66 100644 --- a/docs/recorded-tests.md +++ b/docs/recorded-tests.md @@ -44,7 +44,8 @@ The `.proxy` directory is recreated whenever a recorded test run needs the Test 1. **Rebase on latest** – Ensure your branch includes the current recorded-test infrastructure. 2. **Re-parent the test class** – Update live tests to inherit from `RecordedCommandTestsBase` instead of `CommandTestsBase`. 3. **Ensure proxy-aware HTTP usage** – Commands must obtain `HttpClient` instances via `IHttpClientFactory.CreateClient()` to benefit from playback redirection. -4. **Add `assets.json`** – If the toolset doesn’t have one, create `tools//tests//assets.json`: +4. **Handle local-only tools** – For every test of a tool marked `LocalRequired = true`, call `AssertLocalToolIsUnavailableInHttpMode(toolName)` at the start of the test and return early when it returns `true`. The inherited helper verifies that remote HTTP mode does not expose the tool; do not duplicate this transport-specific assertion in individual test classes. +5. **Add `assets.json`** – If the toolset doesn’t have one, create `tools//tests//assets.json`: ```json { "AssetsRepo": "Azure/azure-sdk-assets", @@ -54,8 +55,8 @@ The `.proxy` directory is recreated whenever a recorded test run needs the Test } ``` If using `copilot` for initial migration, ensure that it indeed created this file. -5. **Record and push** – Follow the workflow above to generate recordings and push them to the assets repo. -6. **Document sanitizers** – Leave brief comments explaining why custom sanitizers exist to help future maintainers. +6. **Record and push** – Follow the workflow above to generate recordings and push them to the assets repo. +7. **Document sanitizers** – Leave brief comments explaining why custom sanitizers exist to help future maintainers. Example Migrations: - [Azure.Mcp.Tools.KeyVault](https://github.com/microsoft/mcp/pull/1080) diff --git a/servers/Azure.Mcp.Server/docs/new-command.md b/servers/Azure.Mcp.Server/docs/new-command.md index f76013d6f1..691af74708 100644 --- a/servers/Azure.Mcp.Server/docs/new-command.md +++ b/servers/Azure.Mcp.Server/docs/new-command.md @@ -1566,6 +1566,17 @@ Azure service commands requiring test resource deployment must add a bicep templ All live tests **must** be recorded for playback using `RecordedCommandTestsBase`. See [`/docs/recorded-tests.md`](https://github.com/microsoft/mcp/blob/main/docs/recorded-tests.md) for the full recording workflow, sanitizer configuration, and migration guide. +Tools marked `LocalRequired = true` are not exposed by the remote HTTP server. In every test for such a tool in a class extending `RecordedCommandTestsBase`, call the inherited helper before exercising the tool and return early when it reports HTTP mode: + +```csharp +if (await AssertLocalToolIsUnavailableInHttpMode("{toolset}_{resource}_{operation}")) +{ + return; +} +``` + +The helper asserts that the tool is unavailable in HTTP mode. Use it instead of repeating environment detection and unavailable-tool assertions in each toolset. + #### Live Test Resource Infrastructure **1. Create Toolset Bicep Template (`/tools/Azure.Mcp.Tools.{Toolset}/tests/test-resources.bicep`)** diff --git a/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs b/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs index 7f444ecd40..705a3ecb6a 100644 --- a/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.AzureMigrate/tests/Azure.Mcp.Tools.AzureMigrate.Tests/AzureMigrateCommandTests.cs @@ -31,7 +31,7 @@ public class AzureMigrateCommandTests(ITestOutputHelper output, TestProxyFixture [Fact] public async Task Should_check_platform_landing_zone_exists() { - if (await AssertLocalToolIsUnavailableInHttpMode()) + if (await AssertLocalToolIsUnavailableInHttpMode("azuremigrate_platformlandingzone_request")) { return; } @@ -59,7 +59,7 @@ public async Task Should_check_platform_landing_zone_exists() [Fact] public async Task Should_update_platform_landing_zone_parameters() { - if (await AssertLocalToolIsUnavailableInHttpMode()) + if (await AssertLocalToolIsUnavailableInHttpMode("azuremigrate_platformlandingzone_request")) { return; } @@ -94,7 +94,7 @@ public async Task Should_update_platform_landing_zone_parameters() [Fact] public async Task Should_get_parameter_status() { - if (await AssertLocalToolIsUnavailableInHttpMode()) + if (await AssertLocalToolIsUnavailableInHttpMode("azuremigrate_platformlandingzone_request")) { return; } @@ -119,7 +119,7 @@ public async Task Should_get_parameter_status() [Fact] public async Task Should_handle_invalid_action() { - if (await AssertLocalToolIsUnavailableInHttpMode()) + if (await AssertLocalToolIsUnavailableInHttpMode("azuremigrate_platformlandingzone_request")) { return; } @@ -144,16 +144,4 @@ await CallToolAsync( } } - private async Task AssertLocalToolIsUnavailableInHttpMode() - { - if (!string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var result = await Client.CallToolAsync("azuremigrate_platformlandingzone_request", new Dictionary()); - Assert.True(result.IsError); - Assert.Contains("not found", McpTestUtilities.GetFirstText(result.Content), StringComparison.OrdinalIgnoreCase); - return true; - } } diff --git a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs index 097b5a3389..d27b4a4114 100644 --- a/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs +++ b/tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.Tests/KeyVaultCommandTests.cs @@ -217,14 +217,8 @@ public async Task Should_create_certificate() [Fact] public async Task Should_import_certificate() { - if (string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + if (await AssertLocalToolIsUnavailableInHttpMode("keyvault_certificate_import")) { - var unavailableResult = await Client.CallToolAsync( - "keyvault_certificate_import", - new Dictionary(), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(unavailableResult.IsError); - Assert.Contains("not found", McpTestUtilities.GetFirstText(unavailableResult.Content), StringComparison.OrdinalIgnoreCase); return; } diff --git a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs index 5e9ec83889..aece07bb35 100644 --- a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.Tests/StorageCommandTests.cs @@ -264,14 +264,8 @@ public async Task Should_get_blob_details() [Fact] public async Task Should_upload_blob() { - if (string.Equals(Environment.GetEnvironmentVariable("MCP_TEST_TRANSPORT"), "http", StringComparison.OrdinalIgnoreCase)) + if (await AssertLocalToolIsUnavailableInHttpMode("storage_blob_upload")) { - var unavailableResult = await Client.CallToolAsync( - "storage_blob_upload", - new Dictionary(), - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(unavailableResult.IsError); - Assert.Contains("not found", McpTestUtilities.GetFirstText(unavailableResult.Content), StringComparison.OrdinalIgnoreCase); return; }