diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 0bc0b21aa..10996882c 100644 --- a/docs/concepts/completions/completions.md +++ b/docs/concepts/completions/completions.md @@ -81,6 +81,42 @@ builder.Services.AddMcpServer() }); ``` +### Automatic completions with AllowedValuesAttribute + +For parameters with a known set of valid values, you can use `System.ComponentModel.DataAnnotations.AllowedValuesAttribute` on `string` parameters of prompts or resource templates. The server will automatically surface those values as completions without needing a custom completion handler. + +#### Prompt parameters + +```csharp +[McpServerPromptType] +public class MyPrompts +{ + [McpServerPrompt, Description("Generates a code review prompt")] + public static ChatMessage CodeReview( + [Description("The programming language")] + [AllowedValues("csharp", "python", "javascript", "typescript", "go", "rust")] + string language, + [Description("The code to review")] string code) + => new(ChatRole.User, $"Please review the following {language} code:\n\n```{language}\n{code}\n```"); +} +``` + +#### Resource template parameters + +```csharp +[McpServerResourceType] +public class MyResources +{ + [McpServerResource("config://settings/{section}"), Description("Reads a configuration section")] + public static string ReadConfig( + [AllowedValues("general", "network", "security", "logging")] + string section) + => GetConfig(section); +} +``` + +With these attributes in place, when a client sends a `completion/complete` request for the `language` or `section` argument, the server will automatically filter and return matching values based on what the user has typed so far. This approach can be combined with a custom completion handler registered via `WithCompleteHandler`; the handler's results are returned first, followed by any matching `AllowedValues`. + ### Requesting completions on the client Clients request completions using . Provide a reference to the prompt or resource template, the argument name, and the current partial value: diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 39feae5d6..6a36f9539 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -254,12 +254,62 @@ private void ConfigureCompletion(McpServerOptions options) var completeHandler = options.Handlers.CompleteHandler; var completionsCapability = options.Capabilities?.Completions; - if (completeHandler is null && completionsCapability is null) + // Build completion value lookups from prompt/resource collections' [AllowedValues]-attributed parameters. + Dictionary>? promptCompletions = BuildAllowedValueCompletions(options.PromptCollection); + Dictionary>? resourceCompletions = BuildAllowedValueCompletions(options.ResourceCollection); + bool hasCollectionCompletions = promptCompletions is not null || resourceCompletions is not null; + + if (completeHandler is null && completionsCapability is null && !hasCollectionCompletions) { return; } completeHandler ??= (static async (_, __) => new CompleteResult()); + + // Augment the completion handler with allowed values from prompt/resource collections. + if (hasCollectionCompletions) + { + var originalCompleteHandler = completeHandler; + completeHandler = async (request, cancellationToken) => + { + CompleteResult result = await originalCompleteHandler(request, cancellationToken).ConfigureAwait(false); + + string[]? allowedValues = null; + switch (request.Params?.Ref) + { + case PromptReference pr when promptCompletions is not null: + if (promptCompletions.TryGetValue(pr.Name, out var promptParams)) + { + promptParams.TryGetValue(request.Params.Argument.Name, out allowedValues); + } + break; + + case ResourceTemplateReference rtr when resourceCompletions is not null: + if (rtr.Uri is not null && resourceCompletions.TryGetValue(rtr.Uri, out var resourceParams)) + { + resourceParams.TryGetValue(request.Params.Argument.Name, out allowedValues); + } + break; + } + + if (allowedValues is not null) + { + string partialValue = request.Params!.Argument.Value; + foreach (var v in allowedValues) + { + if (v.StartsWith(partialValue, StringComparison.OrdinalIgnoreCase)) + { + result.Completion.Values.Add(v); + } + } + + result.Completion.Total = result.Completion.Values.Count; + } + + return result; + }; + } + completeHandler = BuildFilterPipeline(completeHandler, options.Filters.Request.CompleteFilters); ServerCapabilities.Completions = new(); @@ -271,6 +321,76 @@ private void ConfigureCompletion(McpServerOptions options) McpJsonUtilities.JsonContext.Default.CompleteResult); } + /// + /// Builds a lookup of primitive name/URI → (parameter name → allowed values) from the enum values + /// in the JSON schemas of AIFunction-based prompts or resources. + /// + private static Dictionary>? BuildAllowedValueCompletions( + McpServerPrimitiveCollection? primitives) where T : class, IMcpServerPrimitive + { + if (primitives is null) + { + return null; + } + + Dictionary>? result = null; + foreach (var primitive in primitives) + { + JsonElement schema; + string id; + if (primitive is AIFunctionMcpServerPrompt aiPrompt) + { + schema = aiPrompt.AIFunction.JsonSchema; + id = aiPrompt.ProtocolPrompt.Name; + } + else if (primitive is AIFunctionMcpServerResource aiResource && aiResource.IsTemplated) + { + schema = aiResource.AIFunction.JsonSchema; + id = aiResource.ProtocolResourceTemplate.UriTemplate; + } + else + { + continue; + } + + if (schema.TryGetProperty("properties", out JsonElement properties) && + properties.ValueKind is JsonValueKind.Object) + { + Dictionary? paramValues = null; + foreach (var param in properties.EnumerateObject()) + { + if (param.Value.TryGetProperty("enum", out JsonElement enumValues) && + enumValues.ValueKind is JsonValueKind.Array) + { + List? values = null; + foreach (var item in enumValues.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.String && item.GetString() is { } str) + { + values ??= []; + values.Add(str); + } + } + + if (values is not null) + { + paramValues ??= new(StringComparer.Ordinal); + paramValues[param.Name] = [.. values]; + } + } + } + + if (paramValues is not null) + { + result ??= new(StringComparer.Ordinal); + result[id] = paramValues; + } + } + } + + return result; + } + private void ConfigureExperimental(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs index 35fd2b80a..333dbdf15 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrompt.cs @@ -116,6 +116,11 @@ namespace ModelContextProtocol.Server; /// /// Other returned types will result in an being thrown. /// +/// +/// Parameters of type that are decorated with AllowedValuesAttribute +/// will automatically have their allowed values surfaced as completions in response to completion/complete requests from clients, +/// without requiring a custom to be configured. +/// /// public abstract class McpServerPrompt : IMcpServerPrimitive { diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs index 3b5e061ed..c2e95dd9a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptAttribute.cs @@ -103,6 +103,11 @@ namespace ModelContextProtocol.Server; /// /// Other returned types will result in an being thrown. /// +/// +/// Parameters of type that are decorated with AllowedValuesAttribute +/// will automatically have their allowed values surfaced as completions in response to completion/complete requests from clients, +/// without requiring a custom to be configured. +/// /// [AttributeUsage(AttributeTargets.Method)] public sealed class McpServerPromptAttribute : Attribute diff --git a/src/ModelContextProtocol.Core/Server/McpServerResource.cs b/src/ModelContextProtocol.Core/Server/McpServerResource.cs index a7f4780d4..b1f21076d 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResource.cs @@ -121,6 +121,11 @@ namespace ModelContextProtocol.Server; /// /// Other returned types will result in an being thrown. /// +/// +/// Parameters of type that are decorated with AllowedValuesAttribute +/// will automatically have their allowed values surfaced as completions in response to completion/complete requests from clients, +/// without requiring a custom to be configured. +/// /// public abstract class McpServerResource : IMcpServerPrimitive { diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs index 7d1054507..6ed59485c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceAttribute.cs @@ -105,6 +105,11 @@ namespace ModelContextProtocol.Server; /// /// Other returned types will result in an being thrown. /// +/// +/// Parameters of type that are decorated with AllowedValuesAttribute +/// will automatically have their allowed values surfaced as completions in response to completion/complete requests from clients, +/// without requiring a custom to be configured. +/// /// [AttributeUsage(AttributeTargets.Method)] public sealed class McpServerResourceAttribute : Attribute diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 53fd50685..9f7dcb099 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -322,6 +322,244 @@ await Can_Handle_Requests( }); } +#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(); + 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(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(); + 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(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(); + 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(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(); + 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(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(); + 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(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() {