From 039d1e52eb99d81959c7685c9c104e500bc3694e Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Tue, 9 Jun 2026 09:54:04 +0100 Subject: [PATCH 1/3] Add MCP connection-readiness gating to ListToolServersAsync - Parse per-server and aggregate connection metadata (allConnectionsUrl, missingConnectionsUrl, connectivityStatus) from the tooling gateway response, supporting both the legacy bare-array and wrapped {mcpServers, ...} shapes. - Carry aggregate metadata in an internal McpDiscoveryResult. - Gate ListToolServersAsync: throw McpConnectionsRequiredException when the aggregate connectivity status is present and not "Ready". Dev-manifest and legacy responses are never gated. - Add public McpConnectionsRequiredException (MissingConnectionsUrl, ConnectivityStatus, ServerNames). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...figurationService_ConnectionGatingTests.cs | 259 ++++++++++++++++++ .../McpConnectionsRequiredException.cs | 51 ++++ src/Tooling/Core/Models/MCPServerConfig.cs | 20 ++ src/Tooling/Core/Models/McpDiscoveryResult.cs | 47 ++++ .../IMcpToolServerConfigurationService.cs | 2 + .../McpToolServerConfigurationService.cs | 180 ++++++++++-- 6 files changed, 541 insertions(+), 18 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs create mode 100644 src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs create mode 100644 src/Tooling/Core/Models/McpDiscoveryResult.cs diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs new file mode 100644 index 00000000..5aa30b68 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Core.Tests; + +/// +/// Tests for MCP connection gating: gateway response parsing (wrapped + legacy), aggregate/per-server +/// connection metadata, and the raised by +/// ListToolServersAsync when configured servers are not connection-ready. +/// +public class McpToolServerConfigurationService_ConnectionGatingTests +{ + private const string WrappedPendingJson = """ + { + "mcpServers": [ + { + "mcpServerName": "mcp_Salesforce", + "id": "id-1", + "url": "https://s/mcp_Salesforce", + "scope": "McpServers.Salesforce.All", + "audience": "", + "allConnectionsUrl": "https://all/sf", + "missingConnectionsUrl": "https://missing/sf", + "connectivityStatus": "Pending" + } + ], + "allConnectionsUrl": "https://all", + "missingConnectionsUrl": "https://missing", + "connectivityStatus": "Pending" + } + """; + + private const string WrappedReadyJson = """ + { + "mcpServers": [ + { + "mcpServerName": "s", + "id": "id-1", + "url": "https://s", + "allConnectionsUrl": "https://all", + "missingConnectionsUrl": "https://missing", + "connectivityStatus": "Ready" + } + ], + "allConnectionsUrl": "https://all", + "missingConnectionsUrl": "https://missing", + "connectivityStatus": "Ready" + } + """; + + private const string LegacyArrayJson = """ + [ { "mcpServerName": "s", "id": "id-1", "url": "https://s" } ] + """; + + // ─── ParseGatewayResponse ──────────────────────────────────────────────── + + [Fact] + public void ParseGatewayResponse_Wrapped_ParsesServersAndAggregateMetadata() + { + var result = ParseGateway(WrappedPendingJson); + + var server = result.Servers.Should().ContainSingle().Subject; + server.mcpServerName.Should().Be("mcp_Salesforce"); + server.allConnectionsUrl.Should().Be("https://all/sf"); + server.missingConnectionsUrl.Should().Be("https://missing/sf"); + server.connectivityStatus.Should().Be("Pending"); + + result.AllConnectionsUrl.Should().Be("https://all"); + result.MissingConnectionsUrl.Should().Be("https://missing"); + result.ConnectivityStatus.Should().Be("Pending"); + } + + [Fact] + public void ParseGatewayResponse_Ready_NullsMissingConnectionsUrl() + { + var result = ParseGateway(WrappedReadyJson); + + var server = result.Servers.Should().ContainSingle().Subject; + server.connectivityStatus.Should().Be("Ready"); + server.missingConnectionsUrl.Should().BeNull(); + + result.ConnectivityStatus.Should().Be("Ready"); + result.MissingConnectionsUrl.Should().BeNull(); + result.AllConnectionsUrl.Should().Be("https://all"); + } + + [Fact] + public void ParseGatewayResponse_LegacyArray_ReturnsServersWithoutAggregate() + { + var result = ParseGateway(LegacyArrayJson); + + result.Servers.Should().ContainSingle(); + result.ConnectivityStatus.Should().BeNull(); + result.AllConnectionsUrl.Should().BeNull(); + result.MissingConnectionsUrl.Should().BeNull(); + } + + [Fact] + public void ParseGatewayResponse_EmptyMcpServers_ReturnsEmptyWithAggregate() + { + var result = ParseGateway("""{ "mcpServers": [], "connectivityStatus": "Ready" }"""); + + result.Servers.Should().BeEmpty(); + result.ConnectivityStatus.Should().Be("Ready"); + } + + [Fact] + public void ParseGatewayResponse_UnexpectedStructure_Throws() + { + Action act = () => ParseGateway("""{ "foo": 1 }"""); + + act.Should().Throw().WithMessage("*Unexpected JSON structure*"); + } + + // ─── EnforceConnectionReadiness ────────────────────────────────────────── + + [Fact] + public void EnforceConnectionReadiness_Pending_ThrowsWithDetails() + { + var service = CreateService(); + var discovery = new McpDiscoveryResult( + new List + { + new() { mcpServerName = "pending-srv", id = "id-1", url = "http://p", connectivityStatus = "Pending" }, + new() { mcpServerName = "ready-srv", id = "id-2", url = "http://r", connectivityStatus = "Ready" }, + }, + allConnectionsUrl: "https://all", + missingConnectionsUrl: "https://missing", + connectivityStatus: "Pending"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + var ex = act.Should().Throw().Which; + ex.ConnectivityStatus.Should().Be("Pending"); + ex.MissingConnectionsUrl.Should().Be("https://missing"); + ex.ServerNames.Should().ContainSingle().Which.Should().Be("pending-srv"); + } + + [Fact] + public void EnforceConnectionReadiness_Ready_DoesNotThrow() + { + var service = CreateService(); + var discovery = new McpDiscoveryResult(new List(), connectivityStatus: "Ready"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().NotThrow(); + } + + [Fact] + public void EnforceConnectionReadiness_NullStatus_DoesNotThrow() + { + var service = CreateService(); + var discovery = new McpDiscoveryResult(new List(), connectivityStatus: null); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().NotThrow(); + } + + [Fact] + public void EnforceConnectionReadiness_UnknownStatus_Throws() + { + var service = CreateService(); + var discovery = new McpDiscoveryResult(new List(), connectivityStatus: "Frozen"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().Throw() + .Which.ConnectivityStatus.Should().Be("Frozen"); + } + + // ─── ListToolServersAsync (end-to-end via gateway) ─────────────────────── + + [Fact] + public async Task ListToolServersAsync_PendingGateway_ThrowsConnectionsRequired() + { + var service = CreateService(Respond(WrappedPendingJson)); + + var ex = await Assert.ThrowsAsync( + () => service.ListToolServersAsync("agent-123", "tok", new ToolOptions())); + + ex.ConnectivityStatus.Should().Be("Pending"); + ex.MissingConnectionsUrl.Should().Be("https://missing"); + ex.ServerNames.Should().Contain("mcp_Salesforce"); + } + + [Fact] + public async Task ListToolServersAsync_ReadyGateway_ReturnsServers() + { + var service = CreateService(Respond(WrappedReadyJson)); + + var servers = await service.ListToolServersAsync("agent-123", "tok", new ToolOptions()); + + var server = servers.Should().ContainSingle().Subject; + server.connectivityStatus.Should().Be("Ready"); + server.missingConnectionsUrl.Should().BeNull(); + server.allConnectionsUrl.Should().Be("https://all"); + } + + [Fact] + public async Task ListToolServersAsync_LegacyArrayGateway_ReturnsServersWithoutGating() + { + var service = CreateService(Respond(LegacyArrayJson)); + + var servers = await service.ListToolServersAsync("agent-123", "tok", new ToolOptions()); + + servers.Should().ContainSingle(); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static McpDiscoveryResult ParseGateway(string json) + { + using var doc = JsonDocument.Parse(json); + return McpToolServerConfigurationService.ParseGatewayResponse(doc.RootElement); + } + + private static FakeHttpMessageHandler Respond(string json) => + new(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) }); + + private static McpToolServerConfigurationService CreateService(FakeHttpMessageHandler? handler = null) + { + var config = new Mock(); + config.Setup(c => c["MCP_PLATFORM_ENDPOINT"]).Returns("https://test.endpoint"); + + handler ??= Respond("[]"); + var httpClient = new HttpClient(handler); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + return new McpToolServerConfigurationService( + new Mock>().Object, + config.Object, + new Mock().Object, + factory.Object); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + + public FakeHttpMessageHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_responder(request)); + } +} diff --git a/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs b/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs new file mode 100644 index 00000000..afda4ad8 --- /dev/null +++ b/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling +{ + using System; + using System.Collections.Generic; + + /// + /// Thrown when one or more configured MCP servers are not yet connection-ready. + /// The tooling gateway reports an aggregate connectivity status other than Ready + /// (for example, Pending) when the agent's MCP servers have downstream connections that the + /// user has not yet established. Callers should surface to the + /// user to complete setup, then retry on a later turn once the connections are in place. + /// + public class McpConnectionsRequiredException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// URL the user can visit to set up the missing connections, when provided by the gateway. + /// The aggregate connectivity status reported by the gateway (for example, Pending). + /// The names of the MCP servers that are not yet connection-ready. + public McpConnectionsRequiredException( + string? missingConnectionsUrl, + string? connectivityStatus, + IReadOnlyList serverNames) + : base(BuildMessage(missingConnectionsUrl, connectivityStatus, serverNames)) + { + this.MissingConnectionsUrl = missingConnectionsUrl; + this.ConnectivityStatus = connectivityStatus; + this.ServerNames = serverNames; + } + + /// Gets the URL the user can visit to set up the missing connections, or null when not provided. + public string? MissingConnectionsUrl { get; } + + /// Gets the aggregate connectivity status reported by the gateway, or null when not provided. + public string? ConnectivityStatus { get; } + + /// Gets the names of the MCP servers that are not yet connection-ready. + public IReadOnlyList ServerNames { get; } + + private static string BuildMessage(string? missingConnectionsUrl, string? connectivityStatus, IReadOnlyList serverNames) + { + string serversText = serverNames is { Count: > 0 } ? string.Join(", ", serverNames) : "(unknown)"; + return $"MCP servers [{serversText}] require connection setup (connectivityStatus={connectivityStatus}). " + + $"Set up missing connections at: {missingConnectionsUrl}"; + } + } +} diff --git a/src/Tooling/Core/Models/MCPServerConfig.cs b/src/Tooling/Core/Models/MCPServerConfig.cs index eb65c727..2d499e4d 100644 --- a/src/Tooling/Core/Models/MCPServerConfig.cs +++ b/src/Tooling/Core/Models/MCPServerConfig.cs @@ -42,6 +42,26 @@ public class MCPServerConfig /// public string? publisher { get; set; } + /// + /// Gets or sets the URL containing the deduped union of all connectors required for this MCP + /// server to operate. Populated by the discovery endpoint (discoverMCPServers); null + /// when not supplied (for example, on the basic server-list path). + /// + public string? allConnectionsUrl { get; set; } + + /// + /// Gets or sets the URL containing the deduped union of only the connectors required for this + /// MCP server that the connected user has not yet set up. Null when connectivityStatus + /// is Ready (no setup needed) or when not supplied. + /// + public string? missingConnectionsUrl { get; set; } + + /// + /// Gets or sets the connectivity status for this MCP server (for example, Ready when all + /// required connectors are already connected). Compare case-insensitively. Null when not supplied. + /// + public string? connectivityStatus { get; set; } + /// /// Gets or sets per-server HTTP headers, including the Authorization header populated /// by AttachPerAudienceTokensAsync before tool connections are established. diff --git a/src/Tooling/Core/Models/McpDiscoveryResult.cs b/src/Tooling/Core/Models/McpDiscoveryResult.cs new file mode 100644 index 00000000..1c63f2cc --- /dev/null +++ b/src/Tooling/Core/Models/McpDiscoveryResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Tooling.Models +{ + using System.Collections.Generic; + + /// + /// Internal result of MCP server discovery from the tooling gateway. Carries the parsed server + /// list plus the response-level (aggregate) connection metadata used for connection gating. + /// Sources that predate the connection fields (legacy bare-array gateway responses, dev-mode + /// manifests) leave the aggregate fields null. + /// + internal sealed class McpDiscoveryResult + { + /// + /// Initializes a new instance of the class. + /// + /// The parsed MCP server configurations. + /// Aggregate URL for all required connectors, or null. + /// Aggregate URL for missing connectors, or null. + /// Aggregate connectivity status, or null when not supplied. + public McpDiscoveryResult( + List servers, + string? allConnectionsUrl = null, + string? missingConnectionsUrl = null, + string? connectivityStatus = null) + { + this.Servers = servers; + this.AllConnectionsUrl = allConnectionsUrl; + this.MissingConnectionsUrl = missingConnectionsUrl; + this.ConnectivityStatus = connectivityStatus; + } + + /// Gets the parsed MCP server configurations. + public List Servers { get; } + + /// Gets the aggregate URL covering all connectors required across all servers, or null. + public string? AllConnectionsUrl { get; } + + /// Gets the aggregate URL covering only the connectors still missing across all servers, or null. + public string? MissingConnectionsUrl { get; } + + /// Gets the aggregate connectivity status across all servers, or null when not supplied. + public string? ConnectivityStatus { get; } + } +} diff --git a/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs b/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs index 9a7f2515..7c024077 100644 --- a/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs @@ -19,6 +19,7 @@ public interface IMcpToolServerConfigurationService /// Agent instance Id for the agent. /// Auth token to access the MCP servers /// Returns the list of MCP Servers that are configured. + /// Thrown when configured MCP servers report missing downstream connections (aggregate connectivity status other than "Ready"). Task> ListToolServersAsync(string agentInstanceId, string authToken); /// @@ -28,6 +29,7 @@ public interface IMcpToolServerConfigurationService /// Auth token to access the MCP servers /// Tool options for listing servers. /// Returns the list of MCP Servers that are configured. + /// Thrown when configured MCP servers report missing downstream connections (aggregate connectivity status other than "Ready"). Task> ListToolServersAsync(string agentInstanceId, string authToken, ToolOptions toolOptions); /// diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index f5179dab..bf343f11 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -57,7 +57,19 @@ public virtual async Task> ListToolServersAsync(string age /// public virtual async Task> ListToolServersAsync(string agentInstanceId, string authToken, ToolOptions toolOptions) { - return IsDevScenario() ? GetMCPServersFromManifest() : await GetMCPServerFromToolingGatewayAsync(agentInstanceId, authToken, toolOptions); + if (IsDevScenario()) + { + // Dev manifests carry no connection metadata, so connection gating never applies. + return GetMCPServersFromManifest(); + } + + McpDiscoveryResult discovery = await GetMCPServerFromToolingGatewayAsync(agentInstanceId, authToken, toolOptions); + + // Gate execution when configured MCP servers are not connection-ready. Runs before token + // attachment because readiness is independent of tokens. + EnforceConnectionReadiness(discovery); + + return discovery.Servers; } /// @@ -177,7 +189,7 @@ public async Task SendChatHistoryAsync(ITurnContext turnContext } } - private async Task> GetMCPServerFromToolingGatewayAsync( + private async Task GetMCPServerFromToolingGatewayAsync( string agentInstanceId, string authToken, ToolOptions toolOptions) { string configEndpoint = Utility.GetToolingGatewayForDigitalWorker(agentInstanceId, this._configuration); @@ -201,23 +213,9 @@ private async Task> GetMCPServerFromToolingGatewayAsync( PropertyNameCaseInsensitive = true }; - // Single parse approach var jsonDoc = JsonSerializer.Deserialize(response, options); - IEnumerable serverElements = jsonDoc.ValueKind switch - { - JsonValueKind.Array => jsonDoc.EnumerateArray(), - JsonValueKind.Object when jsonDoc.TryGetProperty("mcpServers", out var servers) - && servers.ValueKind == JsonValueKind.Array - => servers.EnumerateArray(), - _ => throw new InvalidOperationException( - $"Unexpected JSON structure. Expected array or object with 'mcpServers' property, got {jsonDoc.ValueKind}") - }; - - return serverElements - .Select(ParseServerConfig) - .Where(config => config != null) - .ToList()!; + return ParseGatewayResponse(jsonDoc); } catch (HttpRequestException httpEx) { @@ -285,6 +283,14 @@ JsonValueKind.Object when jsonDoc.TryGetProperty("mcpServers", out var servers) publisher = publisherElement.GetString(); } + string? allConnectionsUrl = GetStringPropertyCaseInsensitive(serverElement, "allConnectionsUrl"); + string? missingConnectionsUrl = GetStringPropertyCaseInsensitive(serverElement, "missingConnectionsUrl"); + string? connectivityStatus = GetStringPropertyCaseInsensitive(serverElement, "connectivityStatus"); + if (IsReadyStatus(connectivityStatus)) + { + missingConnectionsUrl = null; + } + // Both Name and Endpoint are required if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(endpoint)) { @@ -298,7 +304,10 @@ JsonValueKind.Object when jsonDoc.TryGetProperty("mcpServers", out var servers) id = id ?? string.Empty, scope = scope, audience = audience, - publisher = publisher + publisher = publisher, + allConnectionsUrl = allConnectionsUrl, + missingConnectionsUrl = missingConnectionsUrl, + connectivityStatus = connectivityStatus }; } catch (Exception) @@ -308,6 +317,141 @@ JsonValueKind.Object when jsonDoc.TryGetProperty("mcpServers", out var servers) } } + /// + /// Raises when the aggregate connectivity status + /// indicates that one or more required downstream connections are missing. + /// + /// The discovery result carrying aggregate and per-server status. + /// + /// Blocks only when the response-level connectivity status is present and not Ready + /// (for example, Pending). Absent status (legacy raw-array gateway responses and + /// dev-mode manifests) is always treated as ready, so those paths are never gated. The + /// non-Ready check is intentionally defensive against any unexpected future status value. + /// + internal void EnforceConnectionReadiness(McpDiscoveryResult discovery) + { + string? status = discovery.ConnectivityStatus; + if (string.IsNullOrWhiteSpace(status) || IsReadyStatus(status)) + { + return; + } + + List serverNames = discovery.Servers + .Where(s => !string.IsNullOrWhiteSpace(s.connectivityStatus) && !IsReadyStatus(s.connectivityStatus)) + .Select(s => string.IsNullOrEmpty(s.mcpServerName) ? s.id : s.mcpServerName) + .ToList(); + + this._logger.LogInformation( + "MCP connection gate blocking turn: connectivityStatus={ConnectivityStatus}, servers={ServerNames}", + status, + string.Join(", ", serverNames)); + + throw new McpConnectionsRequiredException(discovery.MissingConnectionsUrl, status, serverNames); + } + + /// + /// Parses a tooling gateway response into an . Supports both the + /// legacy bare-array shape (servers only, no connection metadata) and the wrapped object shape + /// { "mcpServers": [...], allConnectionsUrl, missingConnectionsUrl, connectivityStatus }. + /// + /// The root JSON element of the gateway response. + /// The parsed discovery result with servers and aggregate connection metadata. + internal static McpDiscoveryResult ParseGatewayResponse(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Array) + { + // Legacy bare-array: servers only, no aggregate connection metadata. + List legacyServers = root.EnumerateArray() + .Select(ParseServerConfig) + .Where(config => config != null) + .ToList()!; + return new McpDiscoveryResult(legacyServers); + } + + if (root.ValueKind == JsonValueKind.Object && + TryGetPropertyCaseInsensitive(root, "mcpServers", out var serversElement) && + serversElement.ValueKind == JsonValueKind.Array) + { + List servers = serversElement.EnumerateArray() + .Select(ParseServerConfig) + .Where(config => config != null) + .ToList()!; + + string? connectivityStatus = GetStringPropertyCaseInsensitive(root, "connectivityStatus"); + string? allConnectionsUrl = GetStringPropertyCaseInsensitive(root, "allConnectionsUrl"); + string? missingConnectionsUrl = GetStringPropertyCaseInsensitive(root, "missingConnectionsUrl"); + if (IsReadyStatus(connectivityStatus)) + { + missingConnectionsUrl = null; + } + + return new McpDiscoveryResult(servers, allConnectionsUrl, missingConnectionsUrl, connectivityStatus); + } + + throw new InvalidOperationException( + $"Unexpected JSON structure. Expected array or object with 'mcpServers' property, got {root.ValueKind}"); + } + + /// + /// The connectivity status value indicating that all required connectors are already connected. + /// + private const string ReadyConnectivityStatus = "Ready"; + + /// + /// Determines whether a connectivity status value represents the ready state (case-insensitive). + /// + /// The connectivity status value to evaluate. + /// true when the value equals Ready ignoring case and surrounding whitespace. + private static bool IsReadyStatus(string? connectivityStatus) => + !string.IsNullOrWhiteSpace(connectivityStatus) && + connectivityStatus!.Trim().Equals(ReadyConnectivityStatus, StringComparison.OrdinalIgnoreCase); + + /// + /// Attempts to read a property from a JSON object using a case-insensitive name match. The + /// property name is trimmed to tolerate stray whitespace in the source schema. + /// + /// The JSON object to read from. + /// The property name to match (case-insensitive). + /// The matched property value, or default when not found. + /// true when a matching property is found; otherwise false. + private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement value) + { + value = default; + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name.Trim(), propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + return false; + } + + /// + /// Reads a string property from a JSON object using a case-insensitive name match, or null when + /// the property is absent or not a string. + /// + /// The JSON object to read from. + /// The property name to match (case-insensitive). + /// The string value, or null when not present. + private static string? GetStringPropertyCaseInsensitive(JsonElement element, string propertyName) + { + if (TryGetPropertyCaseInsensitive(element, propertyName, out var value) && + value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + + return null; + } + /// /// Parses a JSON element into an MCPServerConfig object from manifest, constructing full URL. /// From cfa3a122f18c75a9084deef0c87d98ededd9daac Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Tue, 9 Jun 2026 16:44:00 +0100 Subject: [PATCH 2/3] Propagate McpConnectionsRequiredException through tool enumeration EnumerateToolsFromServersAsync wrapped the server-listing call in a broad catch that swallowed all exceptions and returned an empty result. This hid the connection-readiness gating exception from callers, so the agent never received it and could not surface the connection-setup URL. - Re-throw McpConnectionsRequiredException in both EnumerateToolsFromServersAsync overloads before the broad catch, so it reaches the agent's turn handler. - Generic (non-gating) listing failures still return an empty result (unchanged). - Add tests covering propagation with and without a token provider, plus a regression guard that generic failures still return empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...figurationService_ConnectionGatingTests.cs | 55 +++++++++++++++++++ ...verConfigurationService.ToolEnumeration.cs | 12 ++++ 2 files changed, 67 insertions(+) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs index 5aa30b68..00cdfddf 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs @@ -7,6 +7,7 @@ using FluentAssertions; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Moq; @@ -219,6 +220,51 @@ public async Task ListToolServersAsync_LegacyArrayGateway_ReturnsServersWithoutG servers.Should().ContainSingle(); } + // ─── Exception propagation through enumeration ─────────────────────────── + + [Fact] + public async Task EnumerateToolsFromServersAsync_NoTokenProvider_PropagatesConnectionsRequired() + { + var mockService = CreatePartialMock(); + mockService + .Setup(x => x.ListToolServersAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new McpConnectionsRequiredException("https://missing", "Pending", new[] { "srv" })); + + await Assert.ThrowsAsync(() => + mockService.Object.EnumerateToolsFromServersAsync( + "agent-id", "auth-token", new Mock().Object, new ToolOptions())); + } + + [Fact] + public async Task EnumerateToolsFromServersAsync_WithTokenProvider_PropagatesConnectionsRequired() + { + var mockService = CreatePartialMock(); + mockService + .Setup(x => x.ListToolServersAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new McpConnectionsRequiredException("https://missing", "Pending", new[] { "srv" })); + + await Assert.ThrowsAsync(() => + mockService.Object.EnumerateToolsFromServersAsync( + "agent-id", "auth-token", new Mock().Object, + new Mock().Object, new ToolOptions())); + } + + [Fact] + public async Task EnumerateToolsFromServersAsync_GenericFailure_StillReturnsEmpty() + { + // Regression guard: non-gating failures remain swallowed (return empty) — unchanged behavior. + var mockService = CreatePartialMock(); + mockService + .Setup(x => x.ListToolServersAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("network")); + + var (servers, toolsByServer) = await mockService.Object.EnumerateToolsFromServersAsync( + "agent-id", "auth-token", new Mock().Object, new ToolOptions()); + + servers.Should().BeEmpty(); + toolsByServer.Should().BeEmpty(); + } + // ─── Helpers ───────────────────────────────────────────────────────────── private static McpDiscoveryResult ParseGateway(string json) @@ -227,6 +273,15 @@ private static McpDiscoveryResult ParseGateway(string json) return McpToolServerConfigurationService.ParseGatewayResponse(doc.RootElement); } + private static Mock CreatePartialMock() => + new Mock( + MockBehavior.Default, + new Mock>().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object) + { CallBase = true }; + private static FakeHttpMessageHandler Respond(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) }); diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.ToolEnumeration.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.ToolEnumeration.cs index bc6e7044..ce579254 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.ToolEnumeration.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.ToolEnumeration.cs @@ -38,6 +38,12 @@ public partial class McpToolServerConfigurationService tokenProvider, toolOptions).ConfigureAwait(false); } + catch (McpConnectionsRequiredException) + { + // Connection-readiness gating must reach the agent's turn handler so it can prompt + // the user with the setup URL. Never swallow it as a generic listing failure. + throw; + } catch (Exception ex) { _logger.LogError(ex, "Failed to list MCP tool servers for AgentInstanceId={AgentInstanceId}", agentInstanceId); @@ -131,6 +137,12 @@ ex is TaskCanceledException || authToken, toolOptions).ConfigureAwait(false); } + catch (McpConnectionsRequiredException) + { + // Connection-readiness gating must reach the agent's turn handler so it can prompt + // the user with the setup URL. Never swallow it as a generic listing failure. + throw; + } catch (Exception ex) { _logger.LogError(ex, "Failed to list MCP tool servers for AgentInstanceId={AgentInstanceId}", agentInstanceId); From 91b9d5df71ca6ca9f558922c7e16026b98df866c Mon Sep 17 00:00:00 2001 From: Dheeraj Pannala Date: Wed, 10 Jun 2026 13:13:33 +0100 Subject: [PATCH 3/3] Address review feedback on MCP connection-readiness gating - Drop Trim() on JSON property names in TryGetPropertyCaseInsensitive (ordinal case-insensitive match) - Remove dead JsonSerializerOptions on JsonElement deserialize - Move ReadyConnectivityStatus const with fields; drop redundant null-forgiving in IsReadyStatus - Raise connection-gate log to Warning so it surfaces until handlers are universal - Document McpConnectionsRequiredException on the enumeration APIs and note ServerNames may be empty - Add edge-case gating tests (aggregate-only trust, status variants, empty names, null URL) - Record the change in Tooling and extension changelogs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 ++ ...figurationService_ConnectionGatingTests.cs | 68 +++++++++++++++++++ .../McpConnectionsRequiredException.cs | 5 +- .../IMcpToolServerConfigurationService.cs | 3 + .../McpToolServerConfigurationService.cs | 29 ++++---- .../Extensions/AgentFramework/CHANGELOG.md | 2 + .../Extensions/AzureAIFoundry/CHANGELOG.md | 2 + .../Extensions/SemanticKernel/CHANGELOG.md | 2 + 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c883638..8e957a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,11 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. ## [Unreleased] ### Added +- **Microsoft.Agents.A365.Tooling** - MCP connection-readiness gating + - Parses per-server and aggregate connection metadata (`allConnectionsUrl`, `missingConnectionsUrl`, `connectivityStatus`) from the tooling gateway discovery response, supporting both the legacy bare-array and wrapped `{ mcpServers, ... }` shapes + - `ListToolServersAsync` now throws the new public `McpConnectionsRequiredException` (exposing `MissingConnectionsUrl`, `ConnectivityStatus`, and `ServerNames`) when the aggregate connectivity status is present and not `Ready`; legacy responses and dev manifests are never gated + - The exception propagates through `EnumerateToolsFromServersAsync` / `EnumerateAllToolsAsync` and the framework extensions so callers can surface the setup URL to the user + - `MCPServerConfig` extended with `allConnectionsUrl`, `missingConnectionsUrl`, and `connectivityStatus` - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields - `IMcpTokenProvider` interface for pluggable OAuth token acquisition diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs index 00cdfddf..0990e4f4 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/McpToolServerConfigurationService_ConnectionGatingTests.cs @@ -182,6 +182,74 @@ public void EnforceConnectionReadiness_UnknownStatus_Throws() .Which.ConnectivityStatus.Should().Be("Frozen"); } + [Fact] + public void EnforceConnectionReadiness_AggregateReadyButPerServerPending_DoesNotThrow() + { + // Gating is aggregate-only by design: a per-server Pending does not block the turn when the + // gateway reports the aggregate connectivity status as Ready. + var service = CreateService(); + var discovery = new McpDiscoveryResult( + new List + { + new() { mcpServerName = "srv", id = "id-1", url = "http://s", connectivityStatus = "Pending" }, + }, + connectivityStatus: "Ready"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().NotThrow(); + } + + [Theory] + [InlineData("READY")] + [InlineData(" ready ")] + public void EnforceConnectionReadiness_ReadyStatusVariants_DoNotThrow(string status) + { + // Pins IsReadyStatus: comparison is case-insensitive and whitespace-tolerant. + var service = CreateService(); + var discovery = new McpDiscoveryResult(new List(), connectivityStatus: status); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().NotThrow(); + } + + [Fact] + public void EnforceConnectionReadiness_PendingWithoutPerServerStatus_ThrowsWithEmptyServerNames() + { + var service = CreateService(); + var discovery = new McpDiscoveryResult( + new List + { + new() { mcpServerName = "srv", id = "id-1", url = "http://s" }, + }, + connectivityStatus: "Pending"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + var ex = act.Should().Throw().Which; + ex.ServerNames.Should().BeEmpty(); + ex.Message.Should().Contain("(unknown)"); + } + + [Fact] + public void EnforceConnectionReadiness_PendingWithoutMissingUrl_ThrowsWithNullUrl() + { + // Every consumer's handler must survive a null MissingConnectionsUrl. + var service = CreateService(); + var discovery = new McpDiscoveryResult( + new List + { + new() { mcpServerName = "srv", id = "id-1", url = "http://s", connectivityStatus = "Pending" }, + }, + connectivityStatus: "Pending"); + + var act = () => service.EnforceConnectionReadiness(discovery); + + act.Should().Throw() + .Which.MissingConnectionsUrl.Should().BeNull(); + } + // ─── ListToolServersAsync (end-to-end via gateway) ─────────────────────── [Fact] diff --git a/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs b/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs index afda4ad8..637ab58d 100644 --- a/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs +++ b/src/Tooling/Core/Exceptions/McpConnectionsRequiredException.cs @@ -38,7 +38,10 @@ public McpConnectionsRequiredException( /// Gets the aggregate connectivity status reported by the gateway, or null when not provided. public string? ConnectivityStatus { get; } - /// Gets the names of the MCP servers that are not yet connection-ready. + /// + /// Gets the names of the MCP servers that are not yet connection-ready. May be empty when the + /// gateway reports only an aggregate connectivity status without per-server detail. + /// public IReadOnlyList ServerNames { get; } private static string BuildMessage(string? missingConnectionsUrl, string? connectivityStatus, IReadOnlyList serverNames) diff --git a/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs b/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs index 7c024077..87b3d444 100644 --- a/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/IMcpToolServerConfigurationService.cs @@ -84,6 +84,7 @@ public interface IMcpToolServerConfigurationService /// Turn context for the current request. /// Tool options including user agent configuration. /// A tuple containing server configurations and a dictionary mapping server names to their available tools. + /// Thrown when configured MCP servers report missing downstream connections (aggregate connectivity status other than "Ready"). Task<(List Servers, Dictionary> ToolsByServer)> EnumerateToolsFromServersAsync(string agentInstanceId, string authToken, ITurnContext turnContext, ToolOptions toolOptions); /// @@ -97,6 +98,7 @@ public interface IMcpToolServerConfigurationService /// Turn context for the current request. /// Tool options including user agent configuration. /// A tuple containing server configurations and a dictionary mapping server names to their available tools. + /// Thrown when configured MCP servers report missing downstream connections (aggregate connectivity status other than "Ready"). Task<(List Servers, Dictionary> ToolsByServer)> EnumerateToolsFromServersAsync(string agentInstanceId, string authToken, IMcpTokenProvider tokenProvider, ITurnContext turnContext, ToolOptions toolOptions); /// @@ -107,6 +109,7 @@ public interface IMcpToolServerConfigurationService /// Turn context for the current request. /// Tool options including user agent configuration. /// A flat list of all MCP tools from all configured servers. + /// Thrown when configured MCP servers report missing downstream connections (aggregate connectivity status other than "Ready"). Task> EnumerateAllToolsAsync(string agentInstanceId, string authToken, ITurnContext turnContext, ToolOptions toolOptions); } } diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index bf343f11..5e7b2821 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -28,6 +28,11 @@ namespace Microsoft.Agents.A365.Tooling.Services /// public partial class McpToolServerConfigurationService : IMcpToolServerConfigurationService { + /// + /// The connectivity status value indicating that all required connectors are already connected. + /// + private const string ReadyConnectivityStatus = "Ready"; + private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly ILoggerFactory? _loggerFactory; @@ -208,12 +213,9 @@ private async Task GetMCPServerFromToolingGatewayAsync( var response = await httpClient.GetStringAsync(configEndpoint); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var jsonDoc = JsonSerializer.Deserialize(response, options); + // PropertyNameCaseInsensitive does not affect JsonElement materialization, so parse + // into a JsonElement directly; case-insensitive lookups are handled in ParseGatewayResponse. + var jsonDoc = JsonSerializer.Deserialize(response); return ParseGatewayResponse(jsonDoc); } @@ -341,7 +343,7 @@ internal void EnforceConnectionReadiness(McpDiscoveryResult discovery) .Select(s => string.IsNullOrEmpty(s.mcpServerName) ? s.id : s.mcpServerName) .ToList(); - this._logger.LogInformation( + this._logger.LogWarning( "MCP connection gate blocking turn: connectivityStatus={ConnectivityStatus}, servers={ServerNames}", status, string.Join(", ", serverNames)); @@ -392,11 +394,6 @@ internal static McpDiscoveryResult ParseGatewayResponse(JsonElement root) $"Unexpected JSON structure. Expected array or object with 'mcpServers' property, got {root.ValueKind}"); } - /// - /// The connectivity status value indicating that all required connectors are already connected. - /// - private const string ReadyConnectivityStatus = "Ready"; - /// /// Determines whether a connectivity status value represents the ready state (case-insensitive). /// @@ -404,11 +401,11 @@ internal static McpDiscoveryResult ParseGatewayResponse(JsonElement root) /// true when the value equals Ready ignoring case and surrounding whitespace. private static bool IsReadyStatus(string? connectivityStatus) => !string.IsNullOrWhiteSpace(connectivityStatus) && - connectivityStatus!.Trim().Equals(ReadyConnectivityStatus, StringComparison.OrdinalIgnoreCase); + connectivityStatus.Trim().Equals(ReadyConnectivityStatus, StringComparison.OrdinalIgnoreCase); /// - /// Attempts to read a property from a JSON object using a case-insensitive name match. The - /// property name is trimmed to tolerate stray whitespace in the source schema. + /// Attempts to read a property from a JSON object using a case-insensitive (ordinal) name match, + /// matching JsonSerializerOptions.PropertyNameCaseInsensitive semantics. /// /// The JSON object to read from. /// The property name to match (case-insensitive). @@ -424,7 +421,7 @@ private static bool TryGetPropertyCaseInsensitive(JsonElement element, string pr foreach (var property in element.EnumerateObject()) { - if (string.Equals(property.Name.Trim(), propertyName, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; diff --git a/src/Tooling/Extensions/AgentFramework/CHANGELOG.md b/src/Tooling/Extensions/AgentFramework/CHANGELOG.md index 7381f08f..82d13392 100644 --- a/src/Tooling/Extensions/AgentFramework/CHANGELOG.md +++ b/src/Tooling/Extensions/AgentFramework/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Microsoft.Agents.A365.Tooling.Extensions.AgentFramework** - V2 per-audience token support - `McpToolRegistrationService.AddToolServersToAgent` and `GetMcpToolsAsync` now instantiate `AgenticMcpTokenProvider` and use the V2-aware `EnumerateToolsFromServersAsync` overload, so each V2 MCP server receives its own audience-scoped Bearer token instead of the shared ATG token +- **Microsoft.Agents.A365.Tooling.Extensions.AgentFramework** - MCP connection-readiness gating now propagates + - `AddToolServersToAgent` and `GetMcpToolsAsync` allow `McpConnectionsRequiredException` to propagate when configured MCP servers are not connection-ready, so callers can surface the setup URL to the user instead of receiving an empty tool list ## [1.0.0] - 2025-01-16 diff --git a/src/Tooling/Extensions/AzureAIFoundry/CHANGELOG.md b/src/Tooling/Extensions/AzureAIFoundry/CHANGELOG.md index c75038ed..72b15ee8 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/CHANGELOG.md +++ b/src/Tooling/Extensions/AzureAIFoundry/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry** - V2 per-audience token support - `McpToolRegistrationService.AddToolServersToAgentAsync` now instantiates `AgenticMcpTokenProvider` and uses the V2-aware `GetMcpToolDefinitionsAndResourcesAsync` overload; each V2 MCP server's `MCPToolResource` is populated with its audience-scoped Bearer token instead of the shared ATG token +- **Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry** - MCP connection-readiness gating now propagates + - `AddToolServersToAgentAsync` allows `McpConnectionsRequiredException` to propagate when configured MCP servers are not connection-ready, so callers can surface the setup URL to the user instead of receiving an empty tool list ### Added - **Microsoft.Agents.A365.Tooling.AzureFoundry** - Azure Foundry integration tooling for MCP server management diff --git a/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md b/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md index 852970b3..cfdf0bfc 100644 --- a/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md +++ b/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - V2 per-audience token support - `McpToolRegistrationService.AddToolServersToAgentAsync` now instantiates `AgenticMcpTokenProvider` and uses the V2-aware `EnumerateToolsFromServersAsync` overload, so each V2 MCP server receives its own audience-scoped Bearer token instead of the shared ATG token - OBO token acquisition is deferred until after the dev-mode check; in `Development` environments the `DevMcpTokenProvider` supplies tokens from environment variables (`BEARER_TOKEN_` / `BEARER_TOKEN`) without requiring a working auth setup +- **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - MCP connection-readiness gating now propagates + - `AddToolServersToAgentAsync` allows `McpConnectionsRequiredException` to propagate when configured MCP servers are not connection-ready, so callers can surface the setup URL to the user instead of receiving an empty tool list ### Added - **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - Semantic Kernel integration tooling for MCP server management