diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
index 9e7f1c34..024f5eb1 100644
--- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs
@@ -33,6 +33,15 @@ public sealed class ElicitResult : Result
[JsonPropertyName("action")]
public string Action { get; set; } = "cancel";
+ ///
+ /// Convenience indicator for whether the elicitation was accepted by the user.
+ ///
+ ///
+ /// Indicates that the elicitation request completed successfully and value of has been populated with a value.
+ ///
+ [JsonIgnore]
+ public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase);
+
///
/// Gets or sets the submitted form data.
///
@@ -48,3 +57,28 @@ 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.
+ ///
+ public string Action { get; set; } = "cancel";
+
+ ///
+ /// Convenience indicator for whether the elicitation was accepted by the user.
+ ///
+ ///
+ /// Indicates that the elicitation request completed successfully and value of has been populated with a value.
+ ///
+ public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Gets or sets the submitted form data as a typed value.
+ ///
+ 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..97adcc30 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs
@@ -1,9 +1,13 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
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;
@@ -12,6 +16,13 @@ namespace ModelContextProtocol.Server;
///
public static class McpServerExtensions
{
+ ///
+ /// Caches request schemas for elicitation requests based on the type and serializer options.
+ ///
+ private static readonly ConditionalWeakTable> s_elicitResultSchemaCache = new();
+
+ private static Dictionary>? s_elicitAllowedProperties = null;
+
///
/// Requests to sample an LLM via the client using the specified request parameters.
///
@@ -234,6 +245,190 @@ 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)
+ {
+ Throw.IfNull(server);
+ ThrowIfElicitationUnsupported(server);
+
+ serializerOptions ??= McpJsonUtilities.DefaultOptions;
+ serializerOptions.MakeReadOnly();
+
+ var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new());
+
+#if NET
+ var schema = dict.GetOrAdd(typeof(T), static (t, s) => BuildRequestSchema(t, s), serializerOptions);
+#else
+ var schema = dict.GetOrAdd(typeof(T), type => BuildRequestSchema(type, serializerOptions));
+#endif
+
+ var request = new ElicitRequestParams
+ {
+ Message = message,
+ RequestedSchema = schema,
+ };
+
+ var raw = await server.ElicitAsync(request, cancellationToken).ConfigureAwait(false);
+
+ if (!raw.IsAccepted || raw.Content is null)
+ {
+ return new ElicitResult { Action = raw.Action, Content = default };
+ }
+
+ var obj = new JsonObject();
+ foreach (var kvp in raw.Content)
+ {
+ obj[kvp.Key] = JsonNode.Parse(kvp.Value.GetRawText());
+ }
+
+ T? typed = JsonSerializer.Deserialize(obj, serializerOptions.GetTypeInfo());
+ return new ElicitResult { Action = raw.Action, Content = typed };
+ }
+
+ ///
+ /// Builds a request schema for elicitation based on the public serializable properties of .
+ ///
+ /// The type of the schema being built.
+ /// The serializer options to use.
+ /// The built request schema.
+ ///
+ private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, JsonSerializerOptions serializerOptions)
+ {
+ var schema = new ElicitRequestParams.RequestSchema();
+ var props = schema.Properties;
+
+ JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type);
+
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ {
+ throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests.");
+ }
+
+ foreach (JsonPropertyInfo pi in typeInfo.Properties)
+ {
+ var def = CreatePrimitiveSchema(pi.PropertyType, serializerOptions);
+ props[pi.Name] = def;
+ }
+
+ return schema;
+ }
+
+ ///
+ /// Creates a primitive schema definition for the specified type, if supported.
+ ///
+ /// The type to create the schema for.
+ /// The serializer options to use.
+ /// The created primitive schema definition.
+ /// Thrown when the type is not supported.
+ private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions)
+ {
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
+ {
+ throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests. Nullable types are not supported.");
+ }
+
+ var typeInfo = serializerOptions.GetTypeInfo(type);
+
+ if (typeInfo.Kind != JsonTypeInfoKind.None)
+ {
+ throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests.");
+ }
+
+ var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions);
+
+ if (!TryValidateElicitationPrimitiveSchema(jsonElement, type, out var error))
+ {
+ throw new McpException(error);
+ }
+
+ var primitiveSchemaDefinition =
+ jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition);
+
+ if (primitiveSchemaDefinition is null)
+ throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests.");
+
+ return primitiveSchemaDefinition;
+ }
+
+ ///
+ /// Validate the produced schema strictly to the subset we support. We only accept an object schema
+ /// with a supported primitive type keyword and no additional unsupported keywords.Reject things like
+ /// {}, 'true', or schemas that include unrelated keywords(e.g.items, properties, patternProperties, etc.).
+ ///
+ /// The schema to validate.
+ /// The type of the schema being validated, just for reporting errors.
+ /// The error message, if validation fails.
+ ///
+ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Type type,
+ [NotNullWhen(false)] out string? error)
+ {
+ if (schema.ValueKind is not JsonValueKind.Object)
+ {
+ error = $"Schema generated for type '{type.FullName}' is invalid: expected an object schema.";
+ return false;
+ }
+
+ if (!schema.TryGetProperty("type", out JsonElement typeProperty)
+ || typeProperty.ValueKind is not JsonValueKind.String)
+ {
+ error = $"Schema generated for type '{type.FullName}' is invalid: missing or invalid 'type' keyword.";
+ return false;
+ }
+
+ var typeKeyword = typeProperty.GetString();
+
+ if (string.IsNullOrEmpty(typeKeyword))
+ {
+ error = $"Schema generated for type '{type.FullName}' is invalid: empty 'type' value.";
+ return false;
+ }
+
+ if (typeKeyword is not ("string" or "number" or "integer" or "boolean"))
+ {
+ error = $"Schema generated for type '{type.FullName}' is invalid: unsupported primitive type '{typeKeyword}'.";
+ return false;
+ }
+
+ s_elicitAllowedProperties ??= new()
+ {
+ ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"],
+ ["number"] = ["type", "title", "description", "minimum", "maximum"],
+ ["integer"] = ["type", "title", "description", "minimum", "maximum"],
+ ["boolean"] = ["type", "title", "description", "default"]
+ };
+
+ var allowed = s_elicitAllowedProperties[typeKeyword];
+
+ foreach (JsonProperty prop in schema.EnumerateObject())
+ {
+ if (!allowed.Contains(prop.Name))
+ {
+ error = $"The property '{type.FullName}.{prop.Name}' is not supported for elicitation.";
+ return false;
+ }
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
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..11c7995c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs
@@ -0,0 +1,379 @@
+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 == "TestElicitationCamelForm")
+ {
+ 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 if (request.Params!.Name == "TestElicitationNullablePropertyForm")
+ {
+ var result = await request.Server.ElicitAsync(
+ message: "Please provide more information.",
+ serializerOptions: ElicitationNullablePropertyJsonContext.Default.Options,
+ cancellationToken: CancellationToken.None);
+
+ // Should be unreachable
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "unexpected" }],
+ };
+ }
+ else if (request.Params!.Name == "TestElicitationUnsupportedType")
+ {
+ await request.Server.ElicitAsync(
+ message: "Please provide more information.",
+ serializerOptions: ElicitationUnsupportedJsonContext.Default.Options,
+ cancellationToken: CancellationToken.None);
+
+ // Should be unreachable
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "unexpected" }],
+ };
+ }
+ else if (request.Params!.Name == "TestElicitationNonObjectGenericType")
+ {
+ // This should throw because T is not an object type with properties (string primitive)
+ await request.Server.ElicitAsync(
+ message: "Any message",
+ serializerOptions: McpJsonUtilities.DefaultOptions,
+ cancellationToken: CancellationToken.None);
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "unexpected" }],
+ };
+ }
+ 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);
+
+ Assert.Equal(6, 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;
+
+ case nameof(SampleForm.Created):
+ var dateTimeSchema = Assert.IsType(value);
+ Assert.Equal("string", dateTimeSchema.Type);
+ Assert.Equal("date-time", dateTimeSchema.Format);
+
+ 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)))!,
+ [nameof(SampleForm.Created)] = (JsonElement)JsonSerializer.Deserialize("""
+ "2023-08-27T03:05:00"
+ """, 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("TestElicitationCamelForm", cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text);
+ }
+
+ [Fact]
+ public async Task Elicit_Typed_With_Unsupported_Property_Type_Throws()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions
+ {
+ Capabilities = new()
+ {
+ Elicitation = new()
+ {
+ // Handler should never be invoked because the exception occurs before the request is sent.
+ ElicitationHandler = async (req, ct) =>
+ {
+ Assert.Fail("Elicitation handler should not be called for unsupported schema test.");
+ return new ElicitResult { Action = "cancel" };
+ },
+ },
+ },
+ });
+
+ var ex = await Assert.ThrowsAsync(async() =>
+ await client.CallToolAsync("TestElicitationUnsupportedType", cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(typeof(UnsupportedForm.Nested).FullName!, ex.Message);
+ }
+
+ [Fact]
+ public async Task Elicit_Typed_With_Nullable_Property_Type_Throws()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions
+ {
+ Capabilities = new()
+ {
+ Elicitation = new()
+ {
+ // Handler should never be invoked because the exception occurs before the request is sent.
+ ElicitationHandler = async (req, ct) =>
+ {
+ Assert.Fail("Elicitation handler should not be called for unsupported schema test.");
+ return new ElicitResult { Action = "cancel" };
+ },
+ },
+ },
+ });
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Elicit_Typed_With_NonObject_Generic_Type_Throws()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer(new McpClientOptions
+ {
+ Capabilities = new()
+ {
+ Elicitation = new()
+ {
+ // Should not be invoked
+ ElicitationHandler = async (req, ct) =>
+ {
+ Assert.Fail("Elicitation handler should not be called for non-object generic type test.");
+ return new ElicitResult { Action = "cancel" };
+ },
+ },
+ },
+ });
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ await client.CallToolAsync("TestElicitationNonObjectGenericType", cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(typeof(string).FullName!, ex.Message);
+ }
+
+ [JsonConverter(typeof(CustomizableJsonStringEnumConverter))]
+
+ public enum SampleRole
+ {
+ User,
+ Admin,
+ }
+
+ public sealed class SampleForm
+ {
+ public required string Name { get; set; }
+ public int Age { get; set; }
+ public bool Active { get; set; }
+ public SampleRole Role { get; set; }
+ public double Score { get; set; }
+
+
+ public DateTime Created { get; set; }
+ }
+
+ public sealed class CamelForm
+ {
+ public required string FirstName { get; set; }
+ public int ZipCode { get; set; }
+ public bool IsAdmin { get; set; }
+ }
+
+ public sealed class NullablePropertyForm
+ {
+ 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;
+
+
+ [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+ [JsonSerializable(typeof(NullablePropertyForm))]
+ [JsonSerializable(typeof(JsonElement))]
+ internal partial class ElicitationNullablePropertyJsonContext : JsonSerializerContext;
+
+ public sealed class UnsupportedForm
+ {
+ public string? Name { get; set; }
+ public Nested? NestedProperty { get; set; } // Triggers unsupported (complex object)
+ public sealed class Nested
+ {
+ public string? Value { get; set; }
+ }
+ }
+
+ [JsonSerializable(typeof(UnsupportedForm))]
+ [JsonSerializable(typeof(UnsupportedForm.Nested))]
+ [JsonSerializable(typeof(JsonElement))]
+ internal partial class ElicitationUnsupportedJsonContext : JsonSerializerContext;
+}