Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, ModelContextProtocol.Protocol.ElicitRequestParams.PrimitiveSchemaDefinition>(),
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
Comment thread
g2vinay marked this conversation as resolved.
{
Content = [new TextContentBlock { Text = $"Operation cancelled by user ({elicitationResponse.Action.ToString().ToLower()})." }],
Content = [new TextContentBlock { Text = $"Operation cancelled by user ({protocolResponse.Action})." }],
IsError = true
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,194 @@ await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken));
}

[Fact]
public async Task HandleSecretElicitation_WhenElicitationDisabled_ProceedsWithoutConsent()
{
// Arrange
var mockServer = Substitute.For<McpServer>();
var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// 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<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains("elicitation is disabled")),
null,
Arg.Any<Func<object, Exception?, string>>());
}

[Fact]
public async Task HandleSecretElicitation_WhenClientDoesNotSupportElicitation_RejectsOperation()
{
// Arrange
var mockServer = Substitute.For<McpServer>();
mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); // No elicitation support
var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// 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", ((TextContentBlock)result.Content[0]).Text);
}

[Fact]
public async Task HandleSecretElicitation_WhenUserAccepts_ProceedsWithOperation()
{
// Arrange
var mockServer = Substitute.For<McpServer>();
mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } });
var mockResponse = new JsonRpcResponse
{
Id = new RequestId(1),
Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" })
};
mockServer.SendRequestAsync(Arg.Any<JsonRpcRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(mockResponse));

var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// 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<JsonRpcRequest>(req => req.Method == "elicitation/create"),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task HandleSecretElicitation_WhenUserDeclines_RejectsOperation()
{
// Arrange
var mockServer = Substitute.For<McpServer>();
mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } });
var mockResponse = new JsonRpcResponse
{
Id = new RequestId(1),
Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "decline" })
};
mockServer.SendRequestAsync(Arg.Any<JsonRpcRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(mockResponse));

var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// 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", ((TextContentBlock)result.Content[0]).Text);
}

[Fact]
public async Task HandleSecretElicitation_UsesEmptySchemaWithNoProperties()
{
// Arrange
var mockServer = Substitute.For<McpServer>();
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<JsonRpcRequest>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
capturedRequest = callInfo.Arg<JsonRpcRequest>();
return Task.FromResult(mockResponse);
});

var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// Act
await TestableBaseToolLoader.HandleSecretElicitationAsyncPublic(
request, "test-tool", dangerouslyDisableElicitation: false, logger, CancellationToken.None);

// Assert - verify the schema has no properties (empty dictionary)
Assert.NotNull(capturedRequest);
Assert.NotNull(capturedRequest.Params);
var elicitParams = JsonSerializer.Deserialize<ElicitRequestParams>(capturedRequest.Params.ToJsonString());
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<McpServer>();
mockServer.ClientCapabilities.Returns(new ClientCapabilities { Elicitation = new ElicitationCapability() { Form = new() } });
mockServer.SendRequestAsync(Arg.Any<JsonRpcRequest>(), Arg.Any<CancellationToken>())
.Returns<JsonRpcResponse>(_ => throw new InvalidOperationException("Elicitation failed"));

var jsonRpcRequest = new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "test-tool" })
};
var request = new RequestContext<CallToolRequestParams>(mockServer, jsonRpcRequest);
var logger = Substitute.For<ILogger>();

// 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", ((TextContentBlock)result.Content[0]).Text);
}

internal sealed class TestableBaseToolLoader : BaseToolLoader
{
public TestableBaseToolLoader(ILogger logger)
Expand All @@ -315,6 +503,16 @@ public McpClientOptions CreateClientOptionsPublic(McpServer server)
return CreateClientOptions(server);
}

public static Task<CallToolResult?> HandleSecretElicitationAsyncPublic(
RequestContext<CallToolRequestParams> request,
string toolName,
bool dangerouslyDisableElicitation,
ILogger logger,
CancellationToken cancellationToken)
{
return HandleSecretElicitationAsync(request, toolName, dangerouslyDisableElicitation, logger, cancellationToken);
}

public override ValueTask<ListToolsResult> ListToolsHandler(RequestContext<ListToolsRequestParams> request, CancellationToken cancellationToken)
{
var result = new ListToolsResult
Expand Down
Original file line number Diff line number Diff line change
@@ -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"