diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 39387f50..4ed50d4e 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs @@ -44,4 +44,23 @@ public sealed class ElicitResult : Result /// [JsonPropertyName("content")] public IDictionary? Content { get; set; } +} + +/// +/// Represents the client's response to an elicitation request, with typed content payload. +/// +/// The type of the expected content payload. +public sealed class ElicitResult : Result +{ + /// + /// Gets or sets the user action in response to the elicitation. + /// + [JsonPropertyName("action")] + public string Action { get; set; } = "cancel"; + + /// + /// Gets or sets the submitted form data as a typed value. + /// + [JsonPropertyName("content")] + public T? Content { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 277ed737..74d373d5 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -4,6 +4,8 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Server; @@ -234,6 +236,100 @@ public static ValueTask ElicitAsync( cancellationToken: cancellationToken); } + /// + /// Requests additional information from the user via the client, constructing a request schema from the + /// public serializable properties of and deserializing the response into . + /// + /// The type describing the expected input shape. Only primitive members are supported (string, number, boolean, enum). + /// The server initiating the request. + /// The message to present to the user. + /// Serializer options that influence property naming and deserialization. + /// The to monitor for cancellation requests. + /// An with the user's response, if accepted. + /// + /// Elicitation uses a constrained subset of JSON Schema and only supports strings, numbers/integers, booleans and string enums. + /// Unsupported member types are ignored when constructing the schema. + /// + public static async ValueTask> ElicitAsync( + this IMcpServer server, + string message, + JsonSerializerOptions? serializerOptions = null, + CancellationToken cancellationToken = default) where T : class + { + Throw.IfNull(server); + ThrowIfElicitationUnsupported(server); + + serializerOptions ??= McpJsonUtilities.DefaultOptions; + serializerOptions.MakeReadOnly(); + + var schema = BuildRequestSchema(serializerOptions); + + var request = new ElicitRequestParams + { + Message = message, + RequestedSchema = schema, + }; + + var raw = await server.ElicitAsync(request, cancellationToken).ConfigureAwait(false); + + if (!string.Equals(raw.Action, "accept", StringComparison.OrdinalIgnoreCase) || raw.Content is null) + { + return new ElicitResult { Action = raw.Action, Content = default }; + } + + // Compose a JsonObject from the flat content dictionary and deserialize to T + var obj = new JsonObject(); + foreach (var kvp in raw.Content) + { + // JsonNode.Parse handles numbers/strings/bools that came back as JsonElement + obj[kvp.Key] = JsonNode.Parse(kvp.Value.GetRawText()); + } + + T? typed = JsonSerializer.Deserialize(obj, serializerOptions.GetTypeInfo()); + return new ElicitResult { Action = raw.Action, Content = typed }; + } + + private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSerializerOptions serializerOptions) + { + var schema = new ElicitRequestParams.RequestSchema(); + var props = schema.Properties; + + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(); + foreach (JsonPropertyInfo pi in typeInfo.Properties) + { + var memberType = pi.PropertyType; + string name = pi.Name; // serialized name honoring naming policy/attributes + var def = CreatePrimitiveSchema(memberType, serializerOptions); + if (def is not null) + { + props[name] = def; + } + } + + return schema; + } + + private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) + { + Type t = Nullable.GetUnderlyingType(type) ?? type; + + // Check if t is a supported primitive type for elicitation (string, enum, bool, number) + if ( + t == typeof(string) || t.IsEnum || + t == typeof(bool) || + t == typeof(byte) || t == typeof(sbyte) || t == typeof(short) || t == typeof(ushort) || + t == typeof(int) || t == typeof(uint) || t == typeof(long) || t == typeof(ulong) || + t == typeof(float) || t == typeof(double) || t == typeof(decimal)) + { + var jsonElement = AIJsonUtilities.CreateJsonSchema(t, serializerOptions: serializerOptions); + var primitiveSchemaDefinition = + jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); + return primitiveSchemaDefinition; + } + + return null; // Unsupported type for elicitation schema + } + private static void ThrowIfSamplingUnsupported(IMcpServer server) { if (server.ClientCapabilities?.Sampling is null) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs new file mode 100644 index 00000000..b21e6d43 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -0,0 +1,230 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Tests.Configuration; + +public partial class ElicitationTypedTests : ClientServerTestBase +{ + public ElicitationTypedTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithCallToolHandler(async (request, cancellationToken) => + { + Assert.NotNull(request.Params); + + if (request.Params!.Name == "TestElicitationTyped") + { + var result = await request.Server.ElicitAsync( + message: "Please provide more information.", + serializerOptions: ElicitationTypedDefaultJsonContext.Default.Options, + cancellationToken: CancellationToken.None); + + Assert.Equal("accept", result.Action); + Assert.NotNull(result.Content); + Assert.Equal("Alice", result.Content!.Name); + Assert.Equal(30, result.Content!.Age); + Assert.True(result.Content!.Active); + Assert.Equal(SampleRole.Admin, result.Content!.Role); + Assert.Equal(99.5, result.Content!.Score); + } + else if (request.Params!.Name == "TestElicitationTypedCamel") + { + var result = await request.Server.ElicitAsync( + message: "Please provide more information.", + serializerOptions: ElicitationTypedCamelJsonContext.Default.Options, + cancellationToken: CancellationToken.None); + + Assert.Equal("accept", result.Action); + Assert.NotNull(result.Content); + Assert.Equal("Bob", result.Content!.FirstName); + Assert.Equal(90210, result.Content!.ZipCode); + Assert.False(result.Content!.IsAdmin); + } + else + { + Assert.Fail($"Unexpected tool name: {request.Params!.Name}"); + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "success" }], + }; + }); + } + + [Fact] + public async Task Can_Elicit_Typed_Information() + { + await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new() + { + Elicitation = new() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.Equal("Please provide more information.", request.Message); + + // Expect unsupported members like DateTime to be ignored + Assert.Equal(5, request.RequestedSchema.Properties.Count); + + foreach (var entry in request.RequestedSchema.Properties) + { + var key = entry.Key; + var value = entry.Value; + switch (key) + { + case nameof(SampleForm.Name): + var stringSchema = Assert.IsType(value); + Assert.Equal("string", stringSchema.Type); + break; + + case nameof(SampleForm.Age): + var intSchema = Assert.IsType(value); + Assert.Equal("integer", intSchema.Type); + break; + + case nameof(SampleForm.Active): + var boolSchema = Assert.IsType(value); + Assert.Equal("boolean", boolSchema.Type); + break; + + case nameof(SampleForm.Role): + var enumSchema = Assert.IsType(value); + Assert.Equal("string", enumSchema.Type); + Assert.Equal([nameof(SampleRole.User), nameof(SampleRole.Admin)], enumSchema.Enum); + break; + + case nameof(SampleForm.Score): + var numSchema = Assert.IsType(value); + Assert.Equal("number", numSchema.Type); + break; + + default: + Assert.Fail($"Unexpected property in schema: {key}"); + break; + } + } + + return new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + [nameof(SampleForm.Name)] = (JsonElement)JsonSerializer.Deserialize(""" + "Alice" + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + [nameof(SampleForm.Age)] = (JsonElement)JsonSerializer.Deserialize(""" + 30 + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + [nameof(SampleForm.Active)] = (JsonElement)JsonSerializer.Deserialize(""" + true + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + [nameof(SampleForm.Role)] = (JsonElement)JsonSerializer.Deserialize(""" + "Admin" + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + [nameof(SampleForm.Score)] = (JsonElement)JsonSerializer.Deserialize(""" + 99.5 + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + }, + }; + }, + }, + }, + }); + + var result = await client.CallToolAsync("TestElicitationTyped", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); + } + + [Fact] + public async Task Elicit_Typed_Respects_NamingPolicy() + { + await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new() + { + Elicitation = new() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.Equal("Please provide more information.", request.Message); + + // Expect camelCase names based on serializer options + Assert.Contains("firstName", request.RequestedSchema.Properties.Keys); + Assert.Contains("zipCode", request.RequestedSchema.Properties.Keys); + Assert.Contains("isAdmin", request.RequestedSchema.Properties.Keys); + + return new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["firstName"] = (JsonElement)JsonSerializer.Deserialize(""" + "Bob" + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + ["zipCode"] = (JsonElement)JsonSerializer.Deserialize(""" + 90210 + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + ["isAdmin"] = (JsonElement)JsonSerializer.Deserialize(""" + false + """, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!, + }, + }; + }, + }, + }, + }); + + var result = await client.CallToolAsync("TestElicitationTypedCamel", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); + } + + [JsonConverter(typeof(CustomizableJsonStringEnumConverter))] + + public enum SampleRole + { + User, + Admin, + } + + public sealed class SampleForm + { + public string? Name { get; set; } + public int Age { get; set; } + public bool? Active { get; set; } + public SampleRole Role { get; set; } + public double Score { get; set; } + + // Unsupported by elicitation schema; should be ignored + public DateTime Created { get; set; } + } + + public sealed class CamelForm + { + public string? FirstName { get; set; } + public int ZipCode { get; set; } + public bool IsAdmin { get; set; } + } + + [JsonSerializable(typeof(SampleForm))] + [JsonSerializable(typeof(SampleRole))] + [JsonSerializable(typeof(JsonElement))] + internal partial class ElicitationTypedDefaultJsonContext : JsonSerializerContext; + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(CamelForm))] + [JsonSerializable(typeof(JsonElement))] + internal partial class ElicitationTypedCamelJsonContext : JsonSerializerContext; +}