From 07bca29da93e7ecefde48723660df89e056ed772 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 25 Sep 2025 14:21:05 -0700 Subject: [PATCH 1/2] Proxy MCP samples and elicitation requests to root server. --- .../Commands/ToolLoading/BaseToolLoader.cs | 45 ++- .../Commands/ToolLoading/ServerToolLoader.cs | 11 - .../ToolLoading/SingleProxyToolLoader.cs | 11 - .../ToolLoading/BaseToolLoaderTests.cs | 321 ++++++++++++++++++ 4 files changed, 365 insertions(+), 23 deletions(-) create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs index c248efb9cf..61ebc74e71 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; @@ -45,7 +46,7 @@ public abstract class BaseToolLoader(ILogger logger) : IToolLoader /// /// The request context containing the tool call parameters. /// - /// The "parameters" JsonElement if it exists and is a valid JSON object; + /// The "parameters" JsonElement if it exists and is a valid JSON object; /// otherwise, returns an empty JSON object. /// protected static JsonElement GetParametersJsonElement(RequestContext request) @@ -109,4 +110,46 @@ protected virtual ValueTask DisposeAsyncCore() // Default implementation does nothing return ValueTask.CompletedTask; } + + protected McpClientOptions CreateClientOptions(IMcpServer server) + { + SamplingCapability? samplingCapability = null; + ElicitationCapability? elicitationCapability = null; + + if (server.ClientCapabilities?.Sampling != null) + { + samplingCapability = new SamplingCapability + { + SamplingHandler = (request, progress, token) => + { + ArgumentNullException.ThrowIfNull(request); + return server.SampleAsync(request, token); + } + }; + } + + if (server.ClientCapabilities?.Elicitation != null) + { + elicitationCapability = new ElicitationCapability + { + ElicitationHandler = (request, token) => + { + ArgumentNullException.ThrowIfNull(request); + return server.ElicitAsync(request, token); + } + }; + } + + var clientOptions = new McpClientOptions + { + ClientInfo = server.ClientInfo, + Capabilities = new ClientCapabilities + { + Sampling = samplingCapability, + Elicitation = elicitationCapability, + } + }; + + return clientOptions; + } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs index e1c1f07278..d9ec42092b 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs @@ -493,17 +493,6 @@ await request.Server.NotifyProgressAsync(progressToken.Value, return (null, new Dictionary()); } - private McpClientOptions CreateClientOptions(IMcpServer server) - { - var clientOptions = new McpClientOptions - { - ClientInfo = server.ClientInfo, - Capabilities = new ClientCapabilities(), - }; - - return clientOptions; - } - /// /// Disposes resources owned by this tool loader. /// Clears the cached tool lists dictionary. diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs index ef82ff2010..6f31f913ec 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs @@ -459,17 +459,6 @@ await request.Server.NotifyProgressAsync(progressToken.Value, return (null, new Dictionary()); } - private McpClientOptions CreateClientOptions(IMcpServer server) - { - var clientOptions = new McpClientOptions - { - ClientInfo = server.ClientInfo, - Capabilities = new ClientCapabilities(), - }; - - return clientOptions; - } - /// /// Disposes resources owned by this tool loader. /// Clears the cached tool lists and root tools dictionaries. diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs new file mode 100644 index 0000000000..93b0c2946b --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public class BaseToolLoaderTests +{ + [Fact] + public void CreateClientOptions_WithNoCapabilities_ReturnsOptionsWithNoCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Capabilities); + Assert.Null(options.Capabilities.Sampling); + Assert.Null(options.Capabilities.Elicitation); + } + + [Fact] + public void CreateClientOptions_WithEmptyCapabilities_ReturnsOptionsWithNoCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Capabilities); + Assert.Null(options.Capabilities.Sampling); + Assert.Null(options.Capabilities.Elicitation); + } + + [Fact] + public void CreateClientOptions_WithSamplingCapability_ReturnsOptionsWithSamplingOnly() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Capabilities); + Assert.NotNull(options.Capabilities.Sampling); + Assert.Null(options.Capabilities.Elicitation); + } + + [Fact] + public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElicitationOnly() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Capabilities); + Assert.Null(options.Capabilities.Sampling); + Assert.NotNull(options.Capabilities.Elicitation); + } + + [Fact] + public void CreateClientOptions_WithBothCapabilities_ReturnsOptionsWithBothCapabilities() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Capabilities); + Assert.NotNull(options.Capabilities.Sampling); + Assert.NotNull(options.Capabilities.Elicitation); + } + + [Fact] + public void CreateClientOptions_WithServerClientInfo_CopiesClientInfoToOptions() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var clientInfo = new Implementation + { + Name = "test-client", + Version = "1.0.0" + }; + mockServer.ClientInfo.Returns(clientInfo); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.Equal(clientInfo, options.ClientInfo); + } + + [Fact] + public void CreateClientOptions_WithNullServerClientInfo_HandlesGracefully() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + mockServer.ClientInfo.Returns((Implementation?)null); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + + // Assert + Assert.NotNull(options); + Assert.Null(options.ClientInfo); + } + + [Fact] + public async Task CreateClientOptions_SamplingHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Capabilities?.Sampling?.SamplingHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Capabilities.Sampling.SamplingHandler(null!, default!, CancellationToken.None)); + } + + [Fact] + public async Task CreateClientOptions_SamplingHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = "Test message" } + } + ] + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new CreateMessageResult + { + Role = Role.Assistant, + Content = new TextContentBlock { Text = "Mock response" }, + Model = "test-model" + }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Capabilities?.Sampling?.SamplingHandler); + + await options.Capabilities.Sampling.SamplingHandler(samplingRequest, default!, CancellationToken.None); + + // Assert - verify SendRequestAsync was called with sampling method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "sampling/createMessage"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var elicitationRequest = new ElicitRequestParams + { + Message = "Please enter your password:" + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Capabilities?.Elicitation?.ElicitationHandler); + + await options.Capabilities.Elicitation.ElicitationHandler(elicitationRequest, CancellationToken.None); + + // Assert - verify SendRequestAsync was called with elicitation method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "elicitation/create"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new TestableBaseToolLoader(NullLogger.Instance); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = loader.CreateClientOptionsPublic(mockServer); + Assert.NotNull(options.Capabilities?.Elicitation?.ElicitationHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Capabilities.Elicitation.ElicitationHandler(null!, CancellationToken.None)); + } + + internal sealed class TestableBaseToolLoader : BaseToolLoader + { + public TestableBaseToolLoader(ILogger logger) + : base(logger) + { + } + + public McpClientOptions CreateClientOptionsPublic(IMcpServer server) + { + return CreateClientOptions(server); + } + + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + var result = new ListToolsResult + { + Tools = [] + }; + return ValueTask.FromResult(result); + } + + public override ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + var result = new CallToolResult + { + Content = [], + IsError = false + }; + return ValueTask.FromResult(result); + } + } +} From 8e4434ddee356e1fa2d195bb4148981b94df1e5f Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 25 Sep 2025 14:31:21 -0700 Subject: [PATCH 2/2] Updates changelog --- servers/Azure.Mcp.Server/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 721e7fa8fb..f347cdf549 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -2,6 +2,12 @@ The Azure MCP Server updates automatically by default whenever a new release comes out 🚀. We ship updates twice a week on Tuesdays and Thursdays 😊 +## 0.8.3 (unreleased) + +### Features Added + +- Adds support to proxy MCP capabilities when child servers leverage sampling or elicitation. [[#581](https://github.com/microsoft/mcp/pull/581)] + ## 0.8.2 (2025-09-25) ### Bugs Fixed