From 6c04345d3d7791417e64732adbdff9b94adbd0e2 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 19 Aug 2025 02:39:40 +0330 Subject: [PATCH 01/31] Add ElicitAsync (#630) - Refactored `ElicitResult` to a generic class `ElicitResult` for typed content. - Added `ElicitAsync` method in `McpServerExtensions.cs` to request user input and construct schemas based on type `T`. - Implemented schema building logic to handle primitive types and enums, ignoring unsupported types. - Introduced `ElicitationTypedTests.cs` for testing the new elicitation functionality with typed forms. - Verified naming policies in tests to ensure correct serialization casing. - Defined `SampleForm` and `CamelForm` classes for expected input shapes, including unsupported properties for schema testing. - Created JSON serialization contexts for both forms using source generation for improved performance. --- .../Protocol/ElicitResult.cs | 19 ++ .../Server/McpServerExtensions.cs | 112 +++++++++ .../Protocol/ElicitationTypedTests.cs | 228 ++++++++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 39387f500..4ed50d4e2 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 277ed737b..f08929f7f 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,116 @@ 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 = BuildRequestSchemaFor(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 BuildRequestSchemaFor(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 = CreatePrimitiveSchemaFor(memberType); + if (def is not null) + { + props[name] = def; + } + } + + return schema; + } + + private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchemaFor(Type type) + { + Type t = Nullable.GetUnderlyingType(type) ?? type; + + if (t == typeof(string)) + { + return new ElicitRequestParams.StringSchema(); + } + + if (t.IsEnum) + { + return new ElicitRequestParams.EnumSchema + { + Enum = Enum.GetNames(t) + }; + } + + if (t == typeof(bool)) + { + return new ElicitRequestParams.BooleanSchema(); + } + + if (t == typeof(byte) || t == typeof(sbyte) || t == typeof(short) || t == typeof(ushort) || + t == typeof(int) || t == typeof(uint) || t == typeof(long) || t == typeof(ulong)) + { + return new ElicitRequestParams.NumberSchema { Type = "integer" }; + } + + if (t == typeof(float) || t == typeof(double) || t == typeof(decimal)) + { + return new ElicitRequestParams.NumberSchema { Type = "number" }; + } + + // Unsupported type for elicitation schema + return null; + } + 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 000000000..7b7a6654f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -0,0 +1,228 @@ +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); + } + + 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; +} From 9f1100b1cc41ba42bbc0cc9a00f1be9cc6469403 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 23 Aug 2025 02:11:06 +0330 Subject: [PATCH 02/31] Fix the enum issue. for ElicitAsync. --- .../Protocol/ElicitationTypedTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 7b7a6654f..b21e6d432 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -191,6 +191,8 @@ public async Task Elicit_Typed_Respects_NamingPolicy() Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); } + [JsonConverter(typeof(CustomizableJsonStringEnumConverter))] + public enum SampleRole { User, From 0259465b2663e59415392d77549238531d9e929b Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 23 Aug 2025 15:36:29 +0330 Subject: [PATCH 03/31] Use AIJsonUtilities.CreateJsonSchema to create PrimitiveSchemaDefinition. #630 Renamed `BuildRequestSchemaFor` to `BuildRequestSchema`. Updated the implementation to use `CreatePrimitiveSchema` and enhanced type checks for supported primitives with `AIJsonUtilities.CreateJsonSchema`. Streamlined handling of unsupported types by consolidating return statements. --- .../Server/McpServerExtensions.cs | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index f08929f7f..74d373d50 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -262,7 +262,7 @@ public static ValueTask ElicitAsync( serializerOptions ??= McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); - var schema = BuildRequestSchemaFor(serializerOptions); + var schema = BuildRequestSchema(serializerOptions); var request = new ElicitRequestParams { @@ -289,7 +289,7 @@ public static ValueTask ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } - private static ElicitRequestParams.RequestSchema BuildRequestSchemaFor(JsonSerializerOptions serializerOptions) + private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSerializerOptions serializerOptions) { var schema = new ElicitRequestParams.RequestSchema(); var props = schema.Properties; @@ -299,7 +299,7 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchemaFor(JsonSe { var memberType = pi.PropertyType; string name = pi.Name; // serialized name honoring naming policy/attributes - var def = CreatePrimitiveSchemaFor(memberType); + var def = CreatePrimitiveSchema(memberType, serializerOptions); if (def is not null) { props[name] = def; @@ -309,41 +309,25 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchemaFor(JsonSe return schema; } - private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchemaFor(Type type) + private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) { Type t = Nullable.GetUnderlyingType(type) ?? type; - if (t == typeof(string)) + // 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)) { - return new ElicitRequestParams.StringSchema(); + var jsonElement = AIJsonUtilities.CreateJsonSchema(t, serializerOptions: serializerOptions); + var primitiveSchemaDefinition = + jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); + return primitiveSchemaDefinition; } - if (t.IsEnum) - { - return new ElicitRequestParams.EnumSchema - { - Enum = Enum.GetNames(t) - }; - } - - if (t == typeof(bool)) - { - return new ElicitRequestParams.BooleanSchema(); - } - - if (t == typeof(byte) || t == typeof(sbyte) || t == typeof(short) || t == typeof(ushort) || - t == typeof(int) || t == typeof(uint) || t == typeof(long) || t == typeof(ulong)) - { - return new ElicitRequestParams.NumberSchema { Type = "integer" }; - } - - if (t == typeof(float) || t == typeof(double) || t == typeof(decimal)) - { - return new ElicitRequestParams.NumberSchema { Type = "number" }; - } - - // Unsupported type for elicitation schema - return null; + return null; // Unsupported type for elicitation schema } private static void ThrowIfSamplingUnsupported(IMcpServer server) From e520a33a30e22736c7b733dd08aa341fc2a61be9 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Wed, 27 Aug 2025 03:42:14 +0330 Subject: [PATCH 04/31] Simplify ElicitAsync for schema validation. #630 Simplified the `ElicitAsync` method in `McpServerExtensions.cs` by removing unnecessary JSON object construction and directly deserializing `raw.Content`. Updated `CreatePrimitiveSchema` to use `JsonTypeInfo` for type checking and adjusted return logic for unsupported types. In `ElicitationTypedTests.cs`, modified the `Can_Elicit_Typed_Information` test to account for the new `Created` property in `SampleForm`, increasing the expected property count from 5 to 6. Added assertions for the type and format of the `Created` property and included its deserialization in the test setup. --- .../Server/McpServerExtensions.cs | 36 ++++++++++--------- .../Protocol/ElicitationTypedTests.cs | 15 ++++++-- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 74d373d50..38bc63b8c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -277,11 +277,9 @@ public static ValueTask ElicitAsync( 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()); } @@ -311,23 +309,29 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria 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)) + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(underlyingType); + + if (typeInfo.Kind != JsonTypeInfoKind.None) { - var jsonElement = AIJsonUtilities.CreateJsonSchema(t, serializerOptions: serializerOptions); - var primitiveSchemaDefinition = - jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); - return primitiveSchemaDefinition; + return null; } - return null; // Unsupported type for elicitation schema + var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); + + if (jsonElement.TryGetProperty("type", out var typeElement)) + { + var typeValue = typeElement.GetString(); + if (typeValue is "string" or "number" or "integer" or "boolean") + { + var primitiveSchemaDefinition = + jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); + return primitiveSchemaDefinition; + } + } + + return null; } private static void ThrowIfSamplingUnsupported(IMcpServer server) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index b21e6d432..ee3fb6fcc 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -74,8 +74,7 @@ public async Task Can_Elicit_Typed_Information() 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); + Assert.Equal(6, request.RequestedSchema.Properties.Count); foreach (var entry in request.RequestedSchema.Properties) { @@ -109,6 +108,13 @@ public async Task Can_Elicit_Typed_Information() 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; @@ -135,6 +141,9 @@ public async Task Can_Elicit_Typed_Information() [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)))!, }, }; }, @@ -207,7 +216,7 @@ public sealed class SampleForm public SampleRole Role { get; set; } public double Score { get; set; } - // Unsupported by elicitation schema; should be ignored + public DateTime Created { get; set; } } From 3d5a1d659c7fbfcfcf53d031ed0de4f90f37eaf7 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 28 Aug 2025 01:26:42 +0330 Subject: [PATCH 05/31] Add error handling for unsupported elicitation types #630 Introduce exception handling in `McpServerExtensions.cs` for unsupported types. Add a test case in `ElicitationTypedTests.cs` to verify exception throwing for unsupported types. Define a new `UnsupportedForm` class with nested properties and include JSON serialization attributes for proper handling. --- .../Server/McpServerExtensions.cs | 2 +- .../Protocol/ElicitationTypedTests.cs | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 38bc63b8c..f3394560a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -315,7 +315,7 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria if (typeInfo.Kind != JsonTypeInfoKind.None) { - return null; + throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests."); } var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index ee3fb6fcc..8ddb5a279 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -48,6 +48,19 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer Assert.Equal(90210, result.Content!.ZipCode); Assert.False(result.Content!.IsAdmin); } + 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 { Assert.Fail($"Unexpected tool name: {request.Params!.Name}"); @@ -200,6 +213,31 @@ public async Task Elicit_Typed_Respects_NamingPolicy() 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); + } + [JsonConverter(typeof(CustomizableJsonStringEnumConverter))] public enum SampleRole @@ -236,4 +274,19 @@ internal partial class ElicitationTypedDefaultJsonContext : JsonSerializerContex [JsonSerializable(typeof(CamelForm))] [JsonSerializable(typeof(JsonElement))] internal partial class ElicitationTypedCamelJsonContext : 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; } From ade05e6c5d744839de4527d6bdef17dd7d02ef23 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 28 Aug 2025 17:32:48 +0330 Subject: [PATCH 06/31] Validate generic types in BuildRequestSchema #630 Introduce validation to ensure only object types are supported for elicitation requests in the `BuildRequestSchema` method. An exception is thrown for non-object types. Update the test suite with a new test case to verify this behavior, ensuring that an exception is raised when eliciting a non-object generic type (e.g., string) and that the elicitation handler is not invoked in this scenario. --- .../Server/McpServerExtensions.cs | 6 +++ .../Protocol/ElicitationTypedTests.cs | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index f3394560a..0127cb9b6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -293,6 +293,12 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria var props = schema.Properties; JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(); + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + { + throw new McpException($"Type '{typeof(T).FullName}' is not supported for elicitation requests."); + } + foreach (JsonPropertyInfo pi in typeInfo.Properties) { var memberType = pi.PropertyType; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 8ddb5a279..9b90a3fb8 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -61,6 +61,19 @@ await request.Server.ElicitAsync( 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}"); @@ -238,6 +251,31 @@ public async Task Elicit_Typed_With_Unsupported_Property_Type_Throws() Assert.Contains(typeof(UnsupportedForm.Nested).FullName!, ex.Message); } + [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 From a2fdf0a55e44548c0078065712d0c61cc6180684 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 2 Sep 2025 18:24:57 +0330 Subject: [PATCH 07/31] Move nullable types handling logic to ElicitationRequestParams.Coverter. #630 --- .../Protocol/ElicitRequestParams.cs | 14 ++++++++++++- .../Server/McpServerExtensions.cs | 21 +++++-------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 3a9926e22..e1ffcbf49 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -126,7 +126,19 @@ public class Converter : JsonConverter switch (propertyName) { case "type": - type = reader.GetString(); + if (reader.TokenType == JsonTokenType.String) + { + type = reader.GetString(); + } + else if (reader.TokenType == JsonTokenType.StartArray) + { + var types = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.StringArray); + if (types is [var nullableType, "null"]) + { + type = nullableType; + } + } + break; case "title": diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 0127cb9b6..5cee1b2de 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -315,29 +315,18 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) { - Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; - - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(underlyingType); + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type); if (typeInfo.Kind != JsonTypeInfoKind.None) { throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests."); } - var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); + var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); - if (jsonElement.TryGetProperty("type", out var typeElement)) - { - var typeValue = typeElement.GetString(); - if (typeValue is "string" or "number" or "integer" or "boolean") - { - var primitiveSchemaDefinition = - jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); - return primitiveSchemaDefinition; - } - } - - return null; + var primitiveSchemaDefinition = + jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); + return primitiveSchemaDefinition; } private static void ThrowIfSamplingUnsupported(IMcpServer server) From ec19bd50eff18d0dd338550bf5d5721e6ec40f36 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 2 Sep 2025 19:38:56 +0330 Subject: [PATCH 08/31] Add schema validation for elicitation requests Introduce `TryValidateElicitationPrimitiveSchema` method to validate JSON schemas for elicitation requests. This method checks for object type, verifies the "type" property, and ensures compliance with allowed properties based on primitive types (string, boolean, number). Integrate this validation in the `BuildRequestSchema` method to ensure generated schemas are valid before further processing. --- .../Server/McpServerExtensions.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 5cee1b2de..f40595a68 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -324,11 +324,113 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); + if (!TryValidateElicitationPrimitiveSchema(type, jsonElement, out var error)) + { + throw new McpException(error); + } + var primitiveSchemaDefinition = jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); 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 type of the schema being validated. + /// The schema to validate. + /// The error message, if validation fails. + /// + private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement schema, out string error) + { + if (schema.ValueKind is not JsonValueKind.Object) + { + error = $"Schema generated for type '{type.FullName}' is invalid: expected a JSON object."; + return false; + } + + if (!schema.TryGetProperty("type", out JsonElement typeProperty) + || !(typeProperty.ValueKind is JsonValueKind.String or JsonValueKind.Array)) + { + error = $"Schema generated for type '{type.FullName}' is invalid: missing or non-string 'type' property."; + return false; + } + + string? typeKeyword = null; + if (typeProperty.ValueKind == JsonValueKind.Array) // bool? will parse as ["boolean", "null"] + { + var types = JsonSerializer.Deserialize(typeProperty.GetRawText(), McpJsonUtilities.JsonContext.Default.StringArray); + if (types is [var nullableType, "null"]) + { + typeKeyword = nullableType; + } + else + { + error = $"Schema generated for type '{type.FullName}' is invalid: unsupported 'type' array."; + return false; + } + } + else + { + typeKeyword = typeProperty.GetString(); + } + + if (string.IsNullOrEmpty(typeKeyword)) + { + error = $"Schema generated for type '{type.FullName}' is invalid: empty 'type' value."; + return false; + } + + // Accept number or integer as the numeric primitive (both map to NumberSchema) + bool isString = typeKeyword == "string"; + bool isBoolean = typeKeyword == "boolean"; + bool isNumber = typeKeyword == "number" || typeKeyword == "integer"; + if (!isString && !isBoolean && !isNumber) + { + error = $"Schema generated for type '{type.FullName}' is invalid: unsupported primitive type '{typeKeyword}'."; + return false; + } + + // Allowed property names per primitive schema we support. + HashSet allowed = new(StringComparer.Ordinal) + { + "type", + "title", + "description" + }; + if (isString) + { + allowed.Add("minLength"); + allowed.Add("maxLength"); + allowed.Add("format"); + allowed.Add("enum"); // for string enums + allowed.Add("enumNames"); // for string enums + } + else if (isNumber) + { + allowed.Add("minimum"); + allowed.Add("maximum"); + } + else if (isBoolean) + { + allowed.Add("default"); + } + + 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) From 49c3fa97f255ebef744e3c2703ff068fc954882e Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Wed, 3 Sep 2025 14:33:59 +0330 Subject: [PATCH 09/31] Refactor nullable type pattern matching. #630 --- .../Protocol/ElicitRequestParams.cs | 8 ++++++-- .../Server/McpServerExtensions.cs | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index e1ffcbf49..3ce5ed384 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -133,9 +133,13 @@ public class Converter : JsonConverter else if (reader.TokenType == JsonTokenType.StartArray) { var types = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.StringArray); - if (types is [var nullableType, "null"]) + if (types is [var leftNullableType, "null"]) { - type = nullableType; + type = leftNullableType; + } + else if (types is ["null", var rightNullableType]) + { + type = rightNullableType; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index f40595a68..608379922 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -362,9 +362,13 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement if (typeProperty.ValueKind == JsonValueKind.Array) // bool? will parse as ["boolean", "null"] { var types = JsonSerializer.Deserialize(typeProperty.GetRawText(), McpJsonUtilities.JsonContext.Default.StringArray); - if (types is [var nullableType, "null"]) + if (types is [var leftNullableType, "null"]) { - typeKeyword = nullableType; + typeKeyword = leftNullableType; + } + else if (types is ["null", var rightNullableType]) + { + typeKeyword = rightNullableType; } else { From f31815ead644fa7c2192cd5bce28a84235f26eb0 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 4 Sep 2025 17:41:42 +0330 Subject: [PATCH 10/31] Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 608379922..8600aceaa 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -302,11 +302,10 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria 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; + props[pi.Name] = def; } } From 1abcfde18490dc5a65a3771d02df0ac47ce71335 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 4 Sep 2025 17:42:20 +0330 Subject: [PATCH 11/31] Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 8600aceaa..4ba2eab8e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -318,7 +318,7 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria if (typeInfo.Kind != JsonTypeInfoKind.None) { - throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests."); + throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); } var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); From 400b14de1644f2ec49e1d10e47f4bbfee056aaa4 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 4 Sep 2025 17:42:57 +0330 Subject: [PATCH 12/31] Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 4ba2eab8e..f99f1dac6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -342,7 +342,7 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria /// The schema to validate. /// The error message, if validation fails. /// - private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement schema, out string error) + private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement schema, [NotNullWhen(false)] out string? error) { if (schema.ValueKind is not JsonValueKind.Object) { From 0ac795b38b0b858cfe8eca6e1f79157108b7b08d Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 4 Sep 2025 17:43:24 +0330 Subject: [PATCH 13/31] Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index f99f1dac6..fbfbaa5b9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -353,7 +353,7 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement if (!schema.TryGetProperty("type", out JsonElement typeProperty) || !(typeProperty.ValueKind is JsonValueKind.String or JsonValueKind.Array)) { - error = $"Schema generated for type '{type.FullName}' is invalid: missing or non-string 'type' property."; + error = $"Schema generated for type '{type.FullName}' is invalid: missing or invalid 'type' keyword."; return false; } From c78da1265bfb37390c4d8ce35096d246167f1a6f Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 4 Sep 2025 17:43:41 +0330 Subject: [PATCH 14/31] Update src/ModelContextProtocol.Core/Server/McpServerExtensions.cs Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index fbfbaa5b9..fcecc43d4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -346,7 +346,7 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement { if (schema.ValueKind is not JsonValueKind.Object) { - error = $"Schema generated for type '{type.FullName}' is invalid: expected a JSON object."; + error = $"Schema generated for type '{type.FullName}' is invalid: expected an object schema."; return false; } From 6457cbddcde5e63f0dffe8ea65347a55c81513af Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Fri, 5 Sep 2025 05:57:01 +0330 Subject: [PATCH 15/31] Add ElicitResultSchemaCache. #630 --- .../Server/McpServerExtensions.cs | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index fcecc43d4..18136ab06 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -1,6 +1,8 @@ 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; @@ -14,6 +16,11 @@ namespace ModelContextProtocol.Server; /// public static class McpServerExtensions { + /// + /// Caches request schemas for elicitation requests based on the type and serializer options. + /// + private static readonly ConditionalWeakTable> ElicitResultSchemaCache = new(); + /// /// Requests to sample an LLM via the client using the specified request parameters. /// @@ -262,7 +269,8 @@ public static ValueTask ElicitAsync( serializerOptions ??= McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); - var schema = BuildRequestSchema(serializerOptions); + var dict = ElicitResultSchemaCache.GetValue(serializerOptions, _ => new()); + var schema = dict.GetOrAdd(typeof(T), _ => BuildRequestSchema(serializerOptions)); var request = new ElicitRequestParams { @@ -287,6 +295,13 @@ public static ValueTask ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } + /// + /// Builds a request schema for elicitation based on the public serializable properties of . + /// + /// The type to build the schema for. + /// The serializer options to use. + /// The built request schema. + /// private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSerializerOptions serializerOptions) { var schema = new ElicitRequestParams.RequestSchema(); @@ -303,16 +318,20 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria { var memberType = pi.PropertyType; var def = CreatePrimitiveSchema(memberType, serializerOptions); - if (def is not null) - { - props[pi.Name] = def; - } + props[pi.Name] = def; } return schema; } - private static ElicitRequestParams.PrimitiveSchemaDefinition? CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) + /// + /// 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) { JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type); @@ -330,6 +349,10 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria 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; } From dfaedf3ff0f7788d066143b8417b6c02e9b4619d Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Fri, 5 Sep 2025 16:23:04 +0330 Subject: [PATCH 16/31] Prepopulate elicit schema validation logic. #630 - Introduced `LazyElicitAllowedProperties` to cache allowed property names for primitive types, replacing hardcoded logic. - Streamlined `TryValidateElicitationPrimitiveSchema` method by consolidating type checks using pattern matching. - Removed manual addition of allowed properties and now retrieve them directly from the new dictionary. - Updated handling of "integer" type to be treated as "number" for consistency. --- .../Server/McpServerExtensions.cs | 43 ++++++------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 18136ab06..70cdd9a9b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -21,6 +21,13 @@ public static class McpServerExtensions /// private static readonly ConditionalWeakTable> ElicitResultSchemaCache = new(); + private static Lazy>> LazyElicitAllowedProperties { get; } = new(()=> new() + { + ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"], + ["number"] = ["type", "title", "description", "minimum", "maximum"], + ["boolean"] = ["type", "title", "description", "default"] + }); + /// /// Requests to sample an LLM via the client using the specified request parameters. /// @@ -381,7 +388,7 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement } string? typeKeyword = null; - if (typeProperty.ValueKind == JsonValueKind.Array) // bool? will parse as ["boolean", "null"] + if (typeProperty.ValueKind == JsonValueKind.Array) { var types = JsonSerializer.Deserialize(typeProperty.GetRawText(), McpJsonUtilities.JsonContext.Default.StringArray); if (types is [var leftNullableType, "null"]) @@ -409,40 +416,16 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement return false; } - // Accept number or integer as the numeric primitive (both map to NumberSchema) - bool isString = typeKeyword == "string"; - bool isBoolean = typeKeyword == "boolean"; - bool isNumber = typeKeyword == "number" || typeKeyword == "integer"; - if (!isString && !isBoolean && !isNumber) + 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; } - // Allowed property names per primitive schema we support. - HashSet allowed = new(StringComparer.Ordinal) - { - "type", - "title", - "description" - }; - if (isString) - { - allowed.Add("minLength"); - allowed.Add("maxLength"); - allowed.Add("format"); - allowed.Add("enum"); // for string enums - allowed.Add("enumNames"); // for string enums - } - else if (isNumber) - { - allowed.Add("minimum"); - allowed.Add("maximum"); - } - else if (isBoolean) - { - allowed.Add("default"); - } + if (typeKeyword == "integer") + typeKeyword = "number"; + + var allowed = LazyElicitAllowedProperties.Value[typeKeyword]; foreach (JsonProperty prop in schema.EnumerateObject()) { From c8e9bc8e9863b3da77687962b156598d227fde68 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 6 Sep 2025 03:00:22 +0330 Subject: [PATCH 17/31] Use Nullable.GetUnderlyingType to handle nullable types on elicitation. #630 - Update `ElicitRequestParams.cs` to throw a `JsonException` for non-string "type" properties, simplifying error handling. - Modify `CreatePrimitiveSchema` in `McpServerExtensions.cs` to better handle nullable types and improve error messages. - Revise `TryValidateElicitationPrimitiveSchema` to accept schema as the first parameter, enhancing clarity in error reporting. - Simplify validation logic for the "type" keyword by directly retrieving string values, ensuring unsupported "type" arrays are flagged as errors. --- .../Protocol/ElicitRequestParams.cs | 13 +----- .../Server/McpServerExtensions.cs | 40 ++++++++----------- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 3ce5ed384..108ed50bd 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -130,19 +130,10 @@ public class Converter : JsonConverter { type = reader.GetString(); } - else if (reader.TokenType == JsonTokenType.StartArray) + else { - var types = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.StringArray); - if (types is [var leftNullableType, "null"]) - { - type = leftNullableType; - } - else if (types is ["null", var rightNullableType]) - { - type = rightNullableType; - } + throw new JsonException("The 'type' property must be a string."); } - break; case "title": diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 70cdd9a9b..a1a79e241 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -340,16 +340,19 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSeria /// Thrown when the type is not supported. private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) { - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type); + var originalType = type; + var underlyingType = Nullable.GetUnderlyingType(originalType) ?? originalType; + + var typeInfo = serializerOptions.GetTypeInfo(underlyingType); if (typeInfo.Kind != JsonTypeInfoKind.None) { - throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); + throw new McpException($"Type '{originalType.FullName}' is not a supported property type for elicitation requests."); } - var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); + var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); - if (!TryValidateElicitationPrimitiveSchema(type, jsonElement, out var error)) + if (!TryValidateElicitationPrimitiveSchema(jsonElement, originalType, out var error)) { throw new McpException(error); } @@ -358,7 +361,7 @@ private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSche 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."); + throw new McpException($"Type '{originalType.FullName}' is not a supported property type for elicitation requests."); return primitiveSchemaDefinition; } @@ -368,11 +371,12 @@ private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSche /// 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 type of the schema being validated. /// 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(Type type, JsonElement schema, [NotNullWhen(false)] out string? error) + private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Type type, + [NotNullWhen(false)] out string? error) { if (schema.ValueKind is not JsonValueKind.Object) { @@ -387,27 +391,15 @@ private static bool TryValidateElicitationPrimitiveSchema(Type type, JsonElement return false; } - string? typeKeyword = null; - if (typeProperty.ValueKind == JsonValueKind.Array) + string? typeKeyword; + if (typeProperty.ValueKind == JsonValueKind.String) { - var types = JsonSerializer.Deserialize(typeProperty.GetRawText(), McpJsonUtilities.JsonContext.Default.StringArray); - if (types is [var leftNullableType, "null"]) - { - typeKeyword = leftNullableType; - } - else if (types is ["null", var rightNullableType]) - { - typeKeyword = rightNullableType; - } - else - { - error = $"Schema generated for type '{type.FullName}' is invalid: unsupported 'type' array."; - return false; - } + typeKeyword = typeProperty.GetString(); } else { - typeKeyword = typeProperty.GetString(); + error = $"Schema generated for type '{type.FullName}' is invalid: unsupported 'type' array."; + return false; } if (string.IsNullOrEmpty(typeKeyword)) From 7f554edc0be0c45991c3ee899365c5fa40fbf53d Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 6 Sep 2025 03:03:43 +0330 Subject: [PATCH 18/31] Rename static field. Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index a1a79e241..0ca400a57 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -19,7 +19,7 @@ public static class McpServerExtensions /// /// Caches request schemas for elicitation requests based on the type and serializer options. /// - private static readonly ConditionalWeakTable> ElicitResultSchemaCache = new(); + private static readonly ConditionalWeakTable> s_elicitResultSchemaCache = new(); private static Lazy>> LazyElicitAllowedProperties { get; } = new(()=> new() { From c8b3a084ecc3bae455e8b7c087a113687a4e78a4 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 6 Sep 2025 03:23:31 +0330 Subject: [PATCH 19/31] Fix static field renamings. #630 --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 0ca400a57..080b1ba96 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -21,7 +21,7 @@ public static class McpServerExtensions /// private static readonly ConditionalWeakTable> s_elicitResultSchemaCache = new(); - private static Lazy>> LazyElicitAllowedProperties { get; } = new(()=> new() + private static Lazy>> s_lazyElicitAllowedProperties = new(()=> new() { ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"], ["number"] = ["type", "title", "description", "minimum", "maximum"], @@ -276,7 +276,7 @@ public static ValueTask ElicitAsync( serializerOptions ??= McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); - var dict = ElicitResultSchemaCache.GetValue(serializerOptions, _ => new()); + var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new()); var schema = dict.GetOrAdd(typeof(T), _ => BuildRequestSchema(serializerOptions)); var request = new ElicitRequestParams @@ -417,7 +417,7 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty if (typeKeyword == "integer") typeKeyword = "number"; - var allowed = LazyElicitAllowedProperties.Value[typeKeyword]; + var allowed = s_lazyElicitAllowedProperties.Value[typeKeyword]; foreach (JsonProperty prop in schema.EnumerateObject()) { From a2274759303d8dbe16d840ed831291e885e89207 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 6 Sep 2025 03:26:36 +0330 Subject: [PATCH 20/31] Make BuildRequestSchema non-generic. #630 --- .../Server/McpServerExtensions.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 080b1ba96..335ab3c5b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -277,7 +277,7 @@ public static ValueTask ElicitAsync( serializerOptions.MakeReadOnly(); var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new()); - var schema = dict.GetOrAdd(typeof(T), _ => BuildRequestSchema(serializerOptions)); + var schema = dict.GetOrAdd(typeof(T), type => BuildRequestSchema(type, serializerOptions)); var request = new ElicitRequestParams { @@ -303,28 +303,27 @@ public static ValueTask ElicitAsync( } /// - /// Builds a request schema for elicitation based on the public serializable properties of . + /// Builds a request schema for elicitation based on the public serializable properties of . /// - /// The type to build the schema for. + /// The type of the schema being built. /// The serializer options to use. /// The built request schema. /// - private static ElicitRequestParams.RequestSchema BuildRequestSchema(JsonSerializerOptions serializerOptions) + private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, JsonSerializerOptions serializerOptions) { var schema = new ElicitRequestParams.RequestSchema(); var props = schema.Properties; - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(); + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(type); if (typeInfo.Kind != JsonTypeInfoKind.Object) { - throw new McpException($"Type '{typeof(T).FullName}' is not supported for elicitation requests."); + throw new McpException($"Type '{type.FullName}' is not supported for elicitation requests."); } foreach (JsonPropertyInfo pi in typeInfo.Properties) { - var memberType = pi.PropertyType; - var def = CreatePrimitiveSchema(memberType, serializerOptions); + var def = CreatePrimitiveSchema(pi.PropertyType, serializerOptions); props[pi.Name] = def; } From d14107373a5cecb08a8fa344362e89f1b2c9454d Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Sat, 6 Sep 2025 03:32:48 +0330 Subject: [PATCH 21/31] Avoid closure allocation for serializerOptions on netcore #630 Updated the `GetOrAdd` method to use a generic type parameter for .NET, allowing for better handling of `JsonSerializerOptions`. Retained the original implementation for other frameworks to ensure compatibility. --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 335ab3c5b..22412509e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -277,7 +277,12 @@ public static ValueTask ElicitAsync( serializerOptions.MakeReadOnly(); var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new()); + +#if NET + var schema = dict.GetOrAdd(typeof(T), (t, s) => BuildRequestSchema(t, s), serializerOptions); +#else var schema = dict.GetOrAdd(typeof(T), type => BuildRequestSchema(type, serializerOptions)); +#endif var request = new ElicitRequestParams { From c5419c6bf5a233d3ab49aece36a0a42c9f80c364 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Mon, 8 Sep 2025 21:37:32 +0330 Subject: [PATCH 22/31] Refactor ElicitRequestParams and McpServerExtensions. #630 - Simplified "type" property handling in Converter. - Changed s_lazyElicitAllowedProperties to nullable type. - Updated ElicitAsync to use static lambda for clarity. - Refactored CreatePrimitiveSchema to handle nullable types. - Initialized allowed properties in TryValidateElicitationPrimitiveSchema. - Updated ElicitationTypedTests for naming conventions and added tests for nullable properties. - Enforced required properties in SampleForm and introduced NullablePropertyForm. - Added JSON source generation context for NullablePropertyForm. --- .../Protocol/ElicitRequestParams.cs | 9 +-- .../Server/McpServerExtensions.cs | 36 ++++++------ .../Protocol/ElicitationTypedTests.cs | 57 +++++++++++++++++-- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 108ed50bd..3a9926e22 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -126,14 +126,7 @@ public class Converter : JsonConverter switch (propertyName) { case "type": - if (reader.TokenType == JsonTokenType.String) - { - type = reader.GetString(); - } - else - { - throw new JsonException("The 'type' property must be a string."); - } + type = reader.GetString(); break; case "title": diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 22412509e..bb8428446 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -21,12 +21,7 @@ public static class McpServerExtensions /// private static readonly ConditionalWeakTable> s_elicitResultSchemaCache = new(); - private static Lazy>> s_lazyElicitAllowedProperties = new(()=> new() - { - ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"], - ["number"] = ["type", "title", "description", "minimum", "maximum"], - ["boolean"] = ["type", "title", "description", "default"] - }); + private static Dictionary>? s_lazyElicitAllowedProperties = null; /// /// Requests to sample an LLM via the client using the specified request parameters. @@ -279,7 +274,7 @@ public static ValueTask ElicitAsync( var dict = s_elicitResultSchemaCache.GetValue(serializerOptions, _ => new()); #if NET - var schema = dict.GetOrAdd(typeof(T), (t, s) => BuildRequestSchema(t, s), serializerOptions); + 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 @@ -344,19 +339,21 @@ private static ElicitRequestParams.RequestSchema BuildRequestSchema(Type type, J /// Thrown when the type is not supported. private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSchema(Type type, JsonSerializerOptions serializerOptions) { - var originalType = type; - var underlyingType = Nullable.GetUnderlyingType(originalType) ?? originalType; - - var typeInfo = serializerOptions.GetTypeInfo(underlyingType); + 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 '{originalType.FullName}' is not a supported property type for elicitation requests."); + throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); } - var jsonElement = AIJsonUtilities.CreateJsonSchema(underlyingType, serializerOptions: serializerOptions); + var jsonElement = AIJsonUtilities.CreateJsonSchema(type, serializerOptions: serializerOptions); - if (!TryValidateElicitationPrimitiveSchema(jsonElement, originalType, out var error)) + if (!TryValidateElicitationPrimitiveSchema(jsonElement, type, out var error)) { throw new McpException(error); } @@ -365,7 +362,7 @@ private static ElicitRequestParams.PrimitiveSchemaDefinition CreatePrimitiveSche jsonElement.Deserialize(McpJsonUtilities.JsonContext.Default.PrimitiveSchemaDefinition); if (primitiveSchemaDefinition is null) - throw new McpException($"Type '{originalType.FullName}' is not a supported property type for elicitation requests."); + throw new McpException($"Type '{type.FullName}' is not a supported property type for elicitation requests."); return primitiveSchemaDefinition; } @@ -421,7 +418,14 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty if (typeKeyword == "integer") typeKeyword = "number"; - var allowed = s_lazyElicitAllowedProperties.Value[typeKeyword]; + s_lazyElicitAllowedProperties ??= new() + { + ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"], + ["number"] = ["type", "title", "description", "minimum", "maximum"], + ["boolean"] = ["type", "title", "description", "default"] + }; + + var allowed = s_lazyElicitAllowedProperties[typeKeyword]; foreach (JsonProperty prop in schema.EnumerateObject()) { diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 9b90a3fb8..11c7995cc 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -35,7 +35,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer Assert.Equal(SampleRole.Admin, result.Content!.Role); Assert.Equal(99.5, result.Content!.Score); } - else if (request.Params!.Name == "TestElicitationTypedCamel") + else if (request.Params!.Name == "TestElicitationCamelForm") { var result = await request.Server.ElicitAsync( message: "Please provide more information.", @@ -48,6 +48,19 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer 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( @@ -222,7 +235,7 @@ public async Task Elicit_Typed_Respects_NamingPolicy() }, }); - var result = await client.CallToolAsync("TestElicitationTypedCamel", cancellationToken: TestContext.Current.CancellationToken); + var result = await client.CallToolAsync("TestElicitationCamelForm", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); } @@ -251,6 +264,29 @@ public async Task Elicit_Typed_With_Unsupported_Property_Type_Throws() 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() { @@ -286,9 +322,9 @@ public enum SampleRole public sealed class SampleForm { - public string? Name { get; set; } + public required string Name { get; set; } public int Age { get; set; } - public bool? Active { get; set; } + public bool Active { get; set; } public SampleRole Role { get; set; } public double Score { get; set; } @@ -297,6 +333,13 @@ public sealed class SampleForm } 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; } @@ -313,6 +356,12 @@ internal partial class ElicitationTypedDefaultJsonContext : JsonSerializerContex [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; } From 1a32217f451b35a2bc95c84549abcd79be058acb Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Mon, 8 Sep 2025 21:48:41 +0330 Subject: [PATCH 23/31] Remove reduntant checks. #630 Refactor JSON schema type handling and add integer support This commit simplifies the validation logic for the `type` property in the JSON schema by removing unnecessary conditional checks. It directly retrieves the string value from `typeProperty` and assigns it to `typeKeyword`. Additionally, it enhances the `s_lazyElicitAllowedProperties` dictionary to include support for the `integer` type, allowing it to be recognized as a valid type with its corresponding allowed properties. --- .../Server/McpServerExtensions.cs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index bb8428446..422d6438a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -386,22 +386,13 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty } if (!schema.TryGetProperty("type", out JsonElement typeProperty) - || !(typeProperty.ValueKind is JsonValueKind.String or JsonValueKind.Array)) + || typeProperty.ValueKind is not JsonValueKind.String) { error = $"Schema generated for type '{type.FullName}' is invalid: missing or invalid 'type' keyword."; return false; } - string? typeKeyword; - if (typeProperty.ValueKind == JsonValueKind.String) - { - typeKeyword = typeProperty.GetString(); - } - else - { - error = $"Schema generated for type '{type.FullName}' is invalid: unsupported 'type' array."; - return false; - } + var typeKeyword = typeProperty.GetString(); if (string.IsNullOrEmpty(typeKeyword)) { @@ -415,13 +406,11 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty return false; } - if (typeKeyword == "integer") - typeKeyword = "number"; - s_lazyElicitAllowedProperties ??= 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"] }; From 4f20c12cd1cc64683ea41d29b8c1531c5ec91f74 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 9 Sep 2025 00:58:07 +0330 Subject: [PATCH 24/31] Add IsAccepted property and update ElicitAsync return type - Introduced `IsAccepted` property in `ElicitResult` and `ElicitResult` to indicate if the action was accepted. - Changed `ElicitAsync` to return `ValueTask>` instead of `ValueTask>`, ensuring non-nullable results. - Updated return statements in `ElicitAsync` to reflect the new return type, treating `Content` as non-nullable. --- .../Protocol/ElicitResult.cs | 12 ++++++++++++ .../Server/McpServerExtensions.cs | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 312977b2c..45670ae44 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs @@ -33,6 +33,12 @@ public sealed class ElicitResult : Result [JsonPropertyName("action")] public string Action { get; set; } = "cancel"; + /// + /// Convenience indicator for whether the elicitation was accepted by the user. + /// + [JsonIgnore] + public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase); + /// /// Gets or sets the submitted form data. /// @@ -61,6 +67,12 @@ public sealed class ElicitResult : Result [JsonPropertyName("action")] public string Action { get; set; } = "cancel"; + /// + /// Convenience indicator for whether the elicitation was accepted by the user. + /// + [JsonIgnore] + public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase); + /// /// Gets or sets the submitted form data as a typed value. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 422d6438a..6b04d777f 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -259,11 +259,11 @@ public static ValueTask ElicitAsync( /// 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( + public static async ValueTask> ElicitAsync( this IMcpServer server, string message, JsonSerializerOptions? serializerOptions = null, - CancellationToken cancellationToken = default) where T : class + CancellationToken cancellationToken = default) { Throw.IfNull(server); ThrowIfElicitationUnsupported(server); @@ -289,7 +289,7 @@ public static ValueTask ElicitAsync( if (!string.Equals(raw.Action, "accept", StringComparison.OrdinalIgnoreCase) || raw.Content is null) { - return new ElicitResult { Action = raw.Action, Content = default }; + return new ElicitResult { Action = raw.Action, Content = default }; } var obj = new JsonObject(); @@ -299,7 +299,7 @@ public static ValueTask ElicitAsync( } T? typed = JsonSerializer.Deserialize(obj, serializerOptions.GetTypeInfo()); - return new ElicitResult { Action = raw.Action, Content = typed }; + return new ElicitResult { Action = raw.Action, Content = typed }; } /// From 9419e113ab92d8a7607be4310ae59773471909cd Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 9 Sep 2025 00:59:37 +0330 Subject: [PATCH 25/31] Rename to s_elicitAllowedProperties Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 6b04d777f..a9c6e74d1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -21,7 +21,7 @@ public static class McpServerExtensions /// private static readonly ConditionalWeakTable> s_elicitResultSchemaCache = new(); - private static Dictionary>? s_lazyElicitAllowedProperties = null; + private static Dictionary>? s_elicitAllowedProperties = null; /// /// Requests to sample an LLM via the client using the specified request parameters. From 47750402b0eaa65438afd00a82a6c7387047d713 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 9 Sep 2025 15:34:25 +0330 Subject: [PATCH 26/31] Fix renaming s_elicitAllowedProperties. #630 --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index a9c6e74d1..9bbd3c014 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -406,7 +406,7 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty return false; } - s_lazyElicitAllowedProperties ??= new() + s_elicitAllowedProperties ??= new() { ["string"] = ["type", "title", "description", "minLength", "maxLength", "format", "enum", "enumNames"], ["number"] = ["type", "title", "description", "minimum", "maximum"], @@ -414,7 +414,7 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty ["boolean"] = ["type", "title", "description", "default"] }; - var allowed = s_lazyElicitAllowedProperties[typeKeyword]; + var allowed = s_elicitAllowedProperties[typeKeyword]; foreach (JsonProperty prop in schema.EnumerateObject()) { From a3b6c1152d663d97dea141c9fddea4c5699b09a1 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Tue, 9 Sep 2025 15:35:22 +0330 Subject: [PATCH 27/31] Remove unnecessary json attributes. #630 --- src/ModelContextProtocol.Core/Protocol/ElicitResult.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 45670ae44..83c37abab 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs @@ -64,18 +64,15 @@ public sealed class ElicitResult : Result /// /// Gets or sets the user action in response to the elicitation. /// - [JsonPropertyName("action")] public string Action { get; set; } = "cancel"; /// /// Convenience indicator for whether the elicitation was accepted by the user. /// - [JsonIgnore] public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase); /// /// Gets or sets the submitted form data as a typed value. /// - [JsonPropertyName("content")] public T? Content { get; set; } } \ No newline at end of file From c5a22f60a8e3adf562e813f3eae0ec0397aab46c Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Wed, 10 Sep 2025 14:42:12 +0330 Subject: [PATCH 28/31] Improve xml comment Co-authored-by: Eirik Tsarpalis --- src/ModelContextProtocol.Core/Protocol/ElicitResult.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 83c37abab..2eb77fcd3 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs @@ -69,6 +69,9 @@ public sealed class ElicitResult : Result /// /// 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); /// From 444dea7177bd0b51fee3e0b2b95b296cc209369f Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Thu, 11 Sep 2025 18:27:10 +0330 Subject: [PATCH 29/31] Remove extra IsAccepted property. #630 --- src/ModelContextProtocol.Core/Protocol/ElicitResult.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 2eb77fcd3..61ddf4fa9 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs @@ -33,12 +33,6 @@ public sealed class ElicitResult : Result [JsonPropertyName("action")] public string Action { get; set; } = "cancel"; - /// - /// Convenience indicator for whether the elicitation was accepted by the user. - /// - [JsonIgnore] - public bool IsAccepted => string.Equals(Action, "accept", StringComparison.OrdinalIgnoreCase); - /// /// Gets or sets the submitted form data. /// From 1d6c5c74729dd60b310122664ac2461c25c4f3b1 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Fri, 12 Sep 2025 15:33:43 +0330 Subject: [PATCH 30/31] Use IsAccepted for checks. Co-authored-by: Stephen Toub --- src/ModelContextProtocol.Core/Server/McpServerExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs index 9bbd3c014..97adcc307 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerExtensions.cs @@ -287,7 +287,7 @@ public static async ValueTask> ElicitAsync( var raw = await server.ElicitAsync(request, cancellationToken).ConfigureAwait(false); - if (!string.Equals(raw.Action, "accept", StringComparison.OrdinalIgnoreCase) || raw.Content is null) + if (!raw.IsAccepted || raw.Content is null) { return new ElicitResult { Action = raw.Action, Content = default }; } From 54e0f12633803f2cd4edcf9a7323cd32c282abc8 Mon Sep 17 00:00:00 2001 From: Mehran Davoudi Date: Fri, 12 Sep 2025 15:41:14 +0330 Subject: [PATCH 31/31] Add IsAccepted to non-generic ElicitResult. --- src/ModelContextProtocol.Core/Protocol/ElicitResult.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs b/src/ModelContextProtocol.Core/Protocol/ElicitResult.cs index 61ddf4fa9..024f5eb19 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. ///