From 036d6618133bf0393d681011b5726bd83cd1a960 Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 25 May 2026 14:37:35 +0530 Subject: [PATCH 1/2] Emit AzSubscriptionGuid telemetry tag in namespace tool loader In namespace mode, McpRuntime only sees the outer {intent, command, parameters} envelope and cannot find a top-level 'subscription' argument, so the AzSubscriptionGuid Activity tag is never set. This caused per-subscription telemetry to be empty for all namespace-mode tool calls (which is the default for the Azure MCP Server). Fix: NamespaceToolLoader.InvokeChildToolAsync now extracts the inner --subscription value from the parsed parameters dictionary and emits the AzSubscriptionGuid tag, matching the behavior already implemented in McpRuntime for direct tool invocations. Adds 6 unit tests covering: string subscription, case-insensitive key match, missing parameter, empty value, null activity, and null parameters. --- .../ToolLoading/NamespaceToolLoaderTests.cs | 108 ++++++++++++++++++ .../ToolLoading/NamespaceToolLoader.cs | 37 ++++++ 2 files changed, 145 insertions(+) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index 7740464cb1..8f00582e54 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -958,4 +958,112 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + + [Fact] + public void TryEmitSubscriptionTag_AddsTag_WhenSubscriptionParameterIsString() + { + // Arrange + const string expectedSubscription = "11111111-1111-1111-1111-111111111111"; + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["subscription"] = JsonSerializer.SerializeToElement(expectedSubscription), + ["vault"] = JsonSerializer.SerializeToElement("myvault"), + }; + + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // Act + NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity); + + // Assert + var tag = activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid); + Assert.Equal(expectedSubscription, tag); + } + + [Fact] + public void TryEmitSubscriptionTag_MatchesKeyCaseInsensitively() + { + // Arrange + const string expectedSubscription = "22222222-2222-2222-2222-222222222222"; + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["Subscription"] = JsonSerializer.SerializeToElement(expectedSubscription), + }; + + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // Act + NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity); + + // Assert + var tag = activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid); + Assert.Equal(expectedSubscription, tag); + } + + [Fact] + public void TryEmitSubscriptionTag_DoesNothing_WhenNoSubscriptionParameter() + { + // Arrange + var parameters = new Dictionary + { + ["vault"] = JsonSerializer.SerializeToElement("myvault"), + }; + + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // Act + NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity); + + // Assert + Assert.Null(activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid)); + } + + [Fact] + public void TryEmitSubscriptionTag_DoesNothing_WhenSubscriptionIsEmpty() + { + // Arrange + var parameters = new Dictionary + { + ["subscription"] = JsonSerializer.SerializeToElement(string.Empty), + }; + + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // Act + NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity); + + // Assert + Assert.Null(activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid)); + } + + [Fact] + public void TryEmitSubscriptionTag_DoesNothing_WhenActivityIsNull() + { + // Arrange + var parameters = new Dictionary + { + ["subscription"] = JsonSerializer.SerializeToElement("abc"), + }; + + // Act & Assert (must not throw) + NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity: null); + } + + [Fact] + public void TryEmitSubscriptionTag_DoesNothing_WhenParametersAreNull() + { + // Arrange + using var activity = new System.Diagnostics.Activity("test-activity"); + activity.Start(); + + // Act + NamespaceToolLoader.TryEmitSubscriptionTag(parameters: null, activity); + + // Assert + Assert.Null(activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid)); + } } diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index 3a1d8c3452..ce6ae6db7d 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -13,6 +13,8 @@ using Microsoft.Mcp.Core.Helpers; using Microsoft.Mcp.Core.Models; using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; +using Microsoft.Mcp.Core.Services.Telemetry; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -439,6 +441,11 @@ private async Task InvokeChildToolAsync( .SetTag(TagName.ToolId, cmd.Id) .SetTag(TagName.IsServerCommandInvoked, true); + // In namespace mode the McpRuntime sees only the outer {intent, command, parameters} + // envelope, so it cannot emit AzSubscriptionGuid from the top-level arguments. Emit it + // here from the inner parameter dictionary so per-subscription telemetry is captured. + TryEmitSubscriptionTag(parameters, currentActivity); + var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions, cancellationToken); var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; @@ -672,6 +679,36 @@ private static bool IsRawMcpToolInputOption(Option option) string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)); } + internal static void TryEmitSubscriptionTag(IDictionary? parameters, Activity? activity) + { + if (activity == null || parameters == null || parameters.Count == 0) + { + return; + } + + var normalizedSubscriptionName = NameNormalization.NormalizeOptionName(OptionDefinitions.Common.Subscription.Name); + foreach (var kvp in parameters) + { + if (!string.Equals(kvp.Key, normalizedSubscriptionName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (kvp.Value.ValueKind != JsonValueKind.String) + { + return; + } + + var subscription = kvp.Value.GetString(); + if (!string.IsNullOrEmpty(subscription)) + { + activity.AddTag(AzureTagName.SubscriptionGuid, subscription); + } + + return; + } + } + internal static Dictionary GetParametersFromArgs(IDictionary? args) { if (args == null) From e4e91d61474bf28ac7dd0ea6f73252cf71585ed6 Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 25 May 2026 14:54:51 +0530 Subject: [PATCH 2/2] Align empty-subscription handling with McpRuntime Per review feedback on PR #2725: McpRuntime.CallToolHandler emits the AzSubscriptionGuid tag for any non-null string (including empty), but TryEmitSubscriptionTag was gating on !string.IsNullOrEmpty, which would produce different telemetry for direct-tool vs namespace-mode calls with the same input. Change to a null-only check and update the unit test (now asserts the tag IS emitted as an empty string when the parameter value is empty) to lock in parity going forward. --- .../Commands/ToolLoading/NamespaceToolLoaderTests.cs | 7 +++++-- .../Server/Commands/ToolLoading/NamespaceToolLoader.cs | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index 8f00582e54..f09f4d3491 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -1022,9 +1022,11 @@ public void TryEmitSubscriptionTag_DoesNothing_WhenNoSubscriptionParameter() } [Fact] - public void TryEmitSubscriptionTag_DoesNothing_WhenSubscriptionIsEmpty() + public void TryEmitSubscriptionTag_EmitsEmptyString_MatchingMcpRuntimeBehavior() { // Arrange + // McpRuntime.CallToolHandler emits the AzSubscriptionGuid tag for any non-null string, + // including empty. This test guards against drift between the two emission sites. var parameters = new Dictionary { ["subscription"] = JsonSerializer.SerializeToElement(string.Empty), @@ -1037,7 +1039,8 @@ public void TryEmitSubscriptionTag_DoesNothing_WhenSubscriptionIsEmpty() NamespaceToolLoader.TryEmitSubscriptionTag(parameters, activity); // Assert - Assert.Null(activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid)); + var tag = activity.GetTagItem(Microsoft.Mcp.Core.Services.Telemetry.AzureTagName.SubscriptionGuid); + Assert.Equal(string.Empty, tag); } [Fact] diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index ce6ae6db7d..d27cf39f76 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -699,8 +699,11 @@ internal static void TryEmitSubscriptionTag(IDictionary? pa return; } + // Match McpRuntime.CallToolHandler: emit the tag for any non-null string, + // including empty values, so telemetry is consistent between direct-tool and + // namespace-mode invocations. var subscription = kvp.Value.GetString(); - if (!string.IsNullOrEmpty(subscription)) + if (subscription != null) { activity.AddTag(AzureTagName.SubscriptionGuid, subscription); }