From c92619049761c4f32fe0c4db0b0cc6231987e3e8 Mon Sep 17 00:00:00 2001 From: g2vinay <5430778+g2vinay@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:48:30 -0800 Subject: [PATCH 1/2] Add schema-less elicitation for sensitive operations - Use simple accept/decline elicitation without form schema for sensitive tools - Fixes error: 'Form mode elicitation requests require a requested schema' - Simpler approach eliminates need to validate form field values - User must explicitly accept or decline the security warning - Ensures compatibility with VS Code and other MCP clients --- .../Commands/ToolLoading/BaseToolLoader.cs | 24 ++- .../ToolLoading/BaseToolLoaderTests.cs | 167 ++++++++++++++++++ .../changelog-entries/1770433280892.yaml | 3 + 3 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/1770433280892.yaml 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 3db493f740..51d96132d2 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 @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Azure.Mcp.Core.Models.Elicitation; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -193,22 +193,28 @@ protected McpClientOptions CreateClientOptions(McpServer server) { logger.LogInformation("Tool '{Tool}' handles sensitive data. Requesting user confirmation via elicitation.", toolName); - // Create the elicitation request using our custom model - var elicitationRequest = new ElicitationRequestParams + // Create the elicitation request with empty schema (required by MCP SDK 0.8.0-preview.1+) + // No form fields - pure accept/decline prompt + var protocolRequest = new ModelContextProtocol.Protocol.ElicitRequestParams { - Message = $"⚠️ SECURITY WARNING: The tool '{toolName}' may expose secrets or sensitive information.\n\nThis operation could reveal confidential data such as passwords, API keys, certificates, or other sensitive values.\n\nDo you want to continue with this potentially sensitive operation?" + Message = $"⚠️ SECURITY WARNING: The tool '{toolName}' may expose secrets or sensitive information.\n\nThis operation could reveal confidential data such as passwords, API keys, certificates, or other sensitive values.\n\nDo you want to continue with this potentially sensitive operation?", + RequestedSchema = new() + { + Properties = new Dictionary(), + Required = [] + } }; - // Use our extension method to handle the elicitation - var elicitationResponse = await request.Server.RequestElicitationAsync(elicitationRequest, cancellationToken); + // Send the elicitation request directly through the MCP server + var protocolResponse = await request.Server.ElicitAsync(protocolRequest, cancellationToken); - if (elicitationResponse.Action != ElicitationAction.Accept) + if (protocolResponse.Action != "accept") { logger.LogInformation("User {Action} the elicitation for tool '{Tool}'. Operation not executed.", - elicitationResponse.Action.ToString().ToLower(), toolName); + protocolResponse.Action, toolName); return new CallToolResult { - Content = [new TextContentBlock { Text = $"Operation cancelled by user ({elicitationResponse.Action.ToString().ToLower()})." }], + Content = [new TextContentBlock { Text = $"Operation cancelled by user ({protocolResponse.Action})." }], IsError = true }; } 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 index c2534a7759..3088bec2d1 100644 --- 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 @@ -303,6 +303,163 @@ await Assert.ThrowsAsync(async () => await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken)); } + [Fact] + public async Task HandleSecretElicitation_WhenElicitationDisabled_ProceedsWithoutConsent() + { + // Arrange + var mockServer = Substitute.For(); + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + var result = await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: true, logger, CancellationToken.None); + + // Assert + Assert.Null(result); // Should proceed + logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("elicitation is disabled")), + null, + Arg.Any>()); + } + + [Fact] + public async Task HandleSecretElicitation_WhenClientDoesNotSupportElicitation_RejectsOperation() + { + // Arrange + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); // No elicitation support + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + var result = await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("does not support elicitation", result.Content[0].Text); + } + + [Fact] + public async Task HandleSecretElicitation_WhenUserAccepts_ProceedsWithOperation() + { + // Arrange + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + 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)); + + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + var result = await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None); + + // Assert + Assert.Null(result); // Should proceed + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "elicitation/create"), + Arg.Any()); + } + + [Fact] + public async Task HandleSecretElicitation_WhenUserDeclines_RejectsOperation() + { + // Arrange + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "decline" }) + }; + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + var result = await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("cancelled by user", result.Content[0].Text); + } + + [Fact] + public async Task HandleSecretElicitation_UsesEmptySchemaWithNoProperties() + { + // Arrange + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + + JsonRpcRequest? capturedRequest = null; + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + capturedRequest = callInfo.Arg(); + return Task.FromResult(mockResponse); + }); + + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None); + + // Assert - verify the schema has no properties (empty dictionary) + Assert.NotNull(capturedRequest); + var elicitParams = JsonSerializer.Deserialize(capturedRequest.Params!.ToString()!); + Assert.NotNull(elicitParams); + Assert.NotNull(elicitParams.RequestedSchema); + Assert.NotNull(elicitParams.RequestedSchema.Properties); + Assert.Empty(elicitParams.RequestedSchema.Properties); // Key assertion: no form fields + Assert.NotNull(elicitParams.RequestedSchema.Required); + Assert.Empty(elicitParams.RequestedSchema.Required); + } + + [Fact] + public async Task HandleSecretElicitation_WhenExceptionOccurs_ReturnsErrorResult() + { + // Arrange + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("Elicitation failed")); + + var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var logger = Substitute.For(); + + // Act + var result = await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( + request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("Elicitation failed", result.Content[0].Text); + } + internal sealed class TestableBaseToolLoader : BaseToolLoader { public TestableBaseToolLoader(ILogger logger) @@ -315,6 +472,16 @@ public McpClientOptions CreateClientOptionsPublic(McpServer server) return CreateClientOptions(server); } + public static Task HandleSecretElicitationAsyncPublic( + RequestContext request, + string toolName, + bool dangerouslyDisableElicitation, + ILogger logger, + CancellationToken cancellationToken) + { + return HandleSecretElicitationAsync(request, toolName, dangerouslyDisableElicitation, logger, cancellationToken); + } + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) { var result = new ListToolsResult diff --git a/servers/Azure.Mcp.Server/changelog-entries/1770433280892.yaml b/servers/Azure.Mcp.Server/changelog-entries/1770433280892.yaml new file mode 100644 index 0000000000..5b06bfac4d --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1770433280892.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Bugs Fixed" + description: "Fixed elicitation prompts failing with 'Form mode elicitation requests require a requested schema' error by using simple accept/decline prompts instead of form-based schemas for sensitive tool confirmations" \ No newline at end of file From 64fd5c0238556e981d39ac15d5d81c32cbeb84f9 Mon Sep 17 00:00:00 2001 From: g2vinay <5430778+g2vinay@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:07:38 -0800 Subject: [PATCH 2/2] update tests: --- .../ToolLoading/BaseToolLoaderTests.cs | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) 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 index 3088bec2d1..2750fcf84d 100644 --- 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 @@ -308,7 +308,12 @@ public async Task HandleSecretElicitation_WhenElicitationDisabled_ProceedsWithou { // Arrange var mockServer = Substitute.For(); - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -331,7 +336,12 @@ public async Task HandleSecretElicitation_WhenClientDoesNotSupportElicitation_Re // Arrange var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); // No elicitation support - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -341,7 +351,7 @@ public async Task HandleSecretElicitation_WhenClientDoesNotSupportElicitation_Re // Assert Assert.NotNull(result); Assert.True(result.IsError); - Assert.Contains("does not support elicitation", result.Content[0].Text); + Assert.Contains("does not support elicitation", ((TextContentBlock)result.Content[0]).Text); } [Fact] @@ -349,7 +359,7 @@ public async Task HandleSecretElicitation_WhenUserAccepts_ProceedsWithOperation( { // Arrange var mockServer = Substitute.For(); - mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } }); var mockResponse = new JsonRpcResponse { Id = new RequestId(1), @@ -358,7 +368,12 @@ public async Task HandleSecretElicitation_WhenUserAccepts_ProceedsWithOperation( mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockResponse)); - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -377,7 +392,7 @@ public async Task HandleSecretElicitation_WhenUserDeclines_RejectsOperation() { // Arrange var mockServer = Substitute.For(); - mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } }); var mockResponse = new JsonRpcResponse { Id = new RequestId(1), @@ -386,7 +401,12 @@ public async Task HandleSecretElicitation_WhenUserDeclines_RejectsOperation() mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(mockResponse)); - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -396,7 +416,7 @@ public async Task HandleSecretElicitation_WhenUserDeclines_RejectsOperation() // Assert Assert.NotNull(result); Assert.True(result.IsError); - Assert.Contains("cancelled by user", result.Content[0].Text); + Assert.Contains("cancelled by user", ((TextContentBlock)result.Content[0]).Text); } [Fact] @@ -404,15 +424,15 @@ public async Task HandleSecretElicitation_UsesEmptySchemaWithNoProperties() { // Arrange var mockServer = Substitute.For(); - mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); - + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } }); + JsonRpcRequest? capturedRequest = null; var mockResponse = new JsonRpcResponse { Id = new RequestId(1), Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) }; - + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => { @@ -420,7 +440,12 @@ public async Task HandleSecretElicitation_UsesEmptySchemaWithNoProperties() return Task.FromResult(mockResponse); }); - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -429,7 +454,8 @@ await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic( // Assert - verify the schema has no properties (empty dictionary) Assert.NotNull(capturedRequest); - var elicitParams = JsonSerializer.Deserialize(capturedRequest.Params!.ToString()!); + Assert.NotNull(capturedRequest.Params); + var elicitParams = JsonSerializer.Deserialize(capturedRequest.Params.ToJsonString()); Assert.NotNull(elicitParams); Assert.NotNull(elicitParams.RequestedSchema); Assert.NotNull(elicitParams.RequestedSchema.Properties); @@ -443,11 +469,16 @@ public async Task HandleSecretElicitation_WhenExceptionOccurs_ReturnsErrorResult { // Arrange var mockServer = Substitute.For(); - mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() }); + mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } }); mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new InvalidOperationException("Elicitation failed")); - var request = new RequestContext(mockServer, new CallToolRequestParams { Name = "test-tool" }); + var jsonRpcRequest = new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" }) + }; + var request = new RequestContext(mockServer, jsonRpcRequest); var logger = Substitute.For(); // Act @@ -457,7 +488,7 @@ public async Task HandleSecretElicitation_WhenExceptionOccurs_ReturnsErrorResult // Assert Assert.NotNull(result); Assert.True(result.IsError); - Assert.Contains("Elicitation failed", result.Content[0].Text); + Assert.Contains("Elicitation failed", ((TextContentBlock)result.Content[0]).Text); } internal sealed class TestableBaseToolLoader : BaseToolLoader