Auto-populate completion handlers from AllowedValuesAttribute on prompt/resource parameters (#1380)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
Copilot 2026-03-01 13:41:00 -05:00 • committed by GitHub
commit 4af2596019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -411,6 +411,244 @@ public class McpServerTests : LoggedTest
});
}
#if NET
[Fact]
public async Task Completion_AutoPopulated_FromPromptAllowedValues()
{
await using var transport = new TestServerTransport();
var options = CreateOptions();
options.PromptCollection = [McpServerPrompt.Create(
(
[System.ComponentModel.DataAnnotations.AllowedValues("dog", "cat", "fish")] string animal
) => animal,
new McpServerPromptCreateOptions { Name = "test-prompt" })];
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
var receivedMessage = new TaskCompletionSource<JsonRpcResponse>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id.ToString() == "55")
receivedMessage.SetResult(response);
};
await transport.SendMessageAsync(new JsonRpcRequest
{
Method = RequestMethods.CompletionComplete,
Id = new RequestId(55),
Params = JsonSerializer.SerializeToNode(new CompleteRequestParams
{
Ref = new PromptReference { Name = "test-prompt" },
Argument = new Argument { Name = "animal", Value = "c" }
}, McpJsonUtilities.DefaultOptions)
}, TestContext.Current.CancellationToken);
var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Completion);
Assert.Equal(["cat"], result.Completion.Values);
Assert.Equal(1, result.Completion.Total);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task Completion_AutoPopulated_FromPromptAllowedValues_NoMatch()
{
await using var transport = new TestServerTransport();
var options = CreateOptions();
options.PromptCollection = [McpServerPrompt.Create(
(
[System.ComponentModel.DataAnnotations.AllowedValues("dog", "cat")] string animal
) => animal,
new McpServerPromptCreateOptions { Name = "test-prompt" })];
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
var receivedMessage = new TaskCompletionSource<JsonRpcResponse>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id.ToString() == "55")
receivedMessage.SetResult(response);
};
await transport.SendMessageAsync(new JsonRpcRequest
{
Method = RequestMethods.CompletionComplete,
Id = new RequestId(55),
Params = JsonSerializer.SerializeToNode(new CompleteRequestParams
{
Ref = new PromptReference { Name = "test-prompt" },
Argument = new Argument { Name = "animal", Value = "z" }
}, McpJsonUtilities.DefaultOptions)
}, TestContext.Current.CancellationToken);
var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Completion);
Assert.Empty(result.Completion.Values);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task Completion_AutoPopulated_FromResourceAllowedValues()
{
await using var transport = new TestServerTransport();
var options = CreateOptions();
options.ResourceCollection =
[
McpServerResource.Create(
(
[System.ComponentModel.DataAnnotations.AllowedValues("us-east-1", "us-west-2", "eu-west-1")] string region
) => $"Resource for {region}",
new McpServerResourceCreateOptions
{
UriTemplate = "resource://regions/{region}",
Name = "regions"
})
];
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
var receivedMessage = new TaskCompletionSource<JsonRpcResponse>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id.ToString() == "55")
receivedMessage.SetResult(response);
};
await transport.SendMessageAsync(new JsonRpcRequest
{
Method = RequestMethods.CompletionComplete,
Id = new RequestId(55),
Params = JsonSerializer.SerializeToNode(new CompleteRequestParams
{
Ref = new ResourceTemplateReference { Uri = "resource://regions/{region}" },
Argument = new Argument { Name = "region", Value = "us" }
}, McpJsonUtilities.DefaultOptions)
}, TestContext.Current.CancellationToken);
var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Completion);
Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values);
Assert.Equal(2, result.Completion.Total);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task Completion_AutoPopulated_CombinedWithCustomHandler()
{
await using var transport = new TestServerTransport();
var options = CreateOptions();
options.PromptCollection = [McpServerPrompt.Create(
(
[System.ComponentModel.DataAnnotations.AllowedValues("dog", "cat")] string animal
) => animal,
new McpServerPromptCreateOptions { Name = "test-prompt" })];
// Add a custom handler that provides additional completions
options.Handlers.CompleteHandler = async (request, ct) =>
new CompleteResult
{
Completion = new()
{
Values = ["custom-value"],
Total = 1,
HasMore = false
}
};
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
var receivedMessage = new TaskCompletionSource<JsonRpcResponse>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id.ToString() == "55")
receivedMessage.SetResult(response);
};
await transport.SendMessageAsync(new JsonRpcRequest
{
Method = RequestMethods.CompletionComplete,
Id = new RequestId(55),
Params = JsonSerializer.SerializeToNode(new CompleteRequestParams
{
Ref = new PromptReference { Name = "test-prompt" },
Argument = new Argument { Name = "animal", Value = "" }
}, McpJsonUtilities.DefaultOptions)
}, TestContext.Current.CancellationToken);
var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Completion);
// Custom handler values + auto-populated values should be combined
Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values);
Assert.Equal(3, result.Completion.Total);
await transport.DisposeAsync();
await runTask;
}
[Fact]
public async Task Completion_AutoPopulated_EnablesCompletionsCapabilityAutomatically()
{
// When prompts with AllowedValues are registered but no explicit Completions capability is set,
// the server should still handle completion requests (i.e., the capability is auto-enabled).
// This is verified by the fact that sending a completion request succeeds rather than failing.
await using var transport = new TestServerTransport();
var options = CreateOptions();
options.PromptCollection = [McpServerPrompt.Create(
(
[System.ComponentModel.DataAnnotations.AllowedValues("a", "b")] string param
) => param,
new McpServerPromptCreateOptions { Name = "test-prompt" })];
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
var receivedMessage = new TaskCompletionSource<JsonRpcResponse>();
transport.OnMessageSent = (message) =>
{
if (message is JsonRpcResponse response && response.Id.ToString() == "55")
receivedMessage.SetResult(response);
};
await transport.SendMessageAsync(new JsonRpcRequest
{
Method = RequestMethods.CompletionComplete,
Id = new RequestId(55),
Params = JsonSerializer.SerializeToNode(new CompleteRequestParams
{
Ref = new PromptReference { Name = "test-prompt" },
Argument = new Argument { Name = "param", Value = "" }
}, McpJsonUtilities.DefaultOptions)
}, TestContext.Current.CancellationToken);
var response = await receivedMessage.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Completion);
Assert.Equal(["a", "b"], result.Completion.Values);
await transport.DisposeAsync();
await runTask;
}
#endif
[Fact]
public async Task Can_Handle_ResourceTemplates_List_Requests()
{