From 66c084c795e6ea408f5b39ca0f11d0d5c9c35ab6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:01:55 +0200 Subject: [PATCH] Add default value support for all primitive types in elicitation schemas (SEP-1034) (#924) * Initial plan * Add SEP-1034 support for default values in all primitive schema types Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Address PR feedback: convert if-else to switch and add default value mapping tests Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Remove default value extraction code and linker annotations per feedback Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Use literal true/false values instead of GetBoolean() call Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Reinstate tests and fix CreatePrimitiveSchema to extract defaults from AttributeProvider Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Remove manual default extraction and update test to use positional record Removed the manual default value extraction code from BuildRequestSchema and SetDefaultValue helper as this should be handled by AIJsonUtilities. Updated FormWithDefaults to be a positional record with optional parameters. Test is now skipped until AIJsonUtilities supports extracting defaults from optional parameters. Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> * Use 'is not null' pattern for consistency with nullable checks Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Protocol/ElicitRequestParams.cs | 51 ++- .../Protocol/ElicitationDefaultValuesTests.cs | 332 ++++++++++++++++++ .../Protocol/ElicitationTypedTests.cs | 88 +++++ 3 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 3a9926e..2c73153 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -109,6 +109,8 @@ public sealed class ElicitRequestParams double? minimum = null; double? maximum = null; bool? defaultBool = null; + double? defaultNumber = null; + string? defaultString = null; IList? enumValues = null; IList? enumNames = null; @@ -158,7 +160,23 @@ public sealed class ElicitRequestParams break; case "default": - defaultBool = reader.GetBoolean(); + // We need to handle different types for default values + // Store the value based on the JSON token type + switch (reader.TokenType) + { + case JsonTokenType.True: + defaultBool = true; + break; + case JsonTokenType.False: + defaultBool = false; + break; + case JsonTokenType.Number: + defaultNumber = reader.GetDouble(); + break; + case JsonTokenType.String: + defaultString = reader.GetString(); + break; + } break; case "enum": @@ -188,7 +206,8 @@ public sealed class ElicitRequestParams psd = new EnumSchema { Enum = enumValues, - EnumNames = enumNames + EnumNames = enumNames, + Default = defaultString, }; } else @@ -198,6 +217,7 @@ public sealed class ElicitRequestParams MinLength = minLength, MaxLength = maxLength, Format = format, + Default = defaultString, }; } break; @@ -208,6 +228,7 @@ public sealed class ElicitRequestParams { Minimum = minimum, Maximum = maximum, + Default = defaultNumber, }; break; @@ -265,6 +286,10 @@ public sealed class ElicitRequestParams { writer.WriteString("format", stringSchema.Format); } + if (stringSchema.Default is not null) + { + writer.WriteString("default", stringSchema.Default); + } break; case NumberSchema numberSchema: @@ -276,10 +301,14 @@ public sealed class ElicitRequestParams { writer.WriteNumber("maximum", numberSchema.Maximum.Value); } + if (numberSchema.Default is not null) + { + writer.WriteNumber("default", numberSchema.Default.Value); + } break; case BooleanSchema booleanSchema: - if (booleanSchema.Default.HasValue) + if (booleanSchema.Default is not null) { writer.WriteBoolean("default", booleanSchema.Default.Value); } @@ -296,6 +325,10 @@ public sealed class ElicitRequestParams writer.WritePropertyName("enumNames"); JsonSerializer.Serialize(writer, enumSchema.EnumNames, McpJsonUtilities.JsonContext.Default.IListString); } + if (enumSchema.Default is not null) + { + writer.WriteString("default", enumSchema.Default); + } break; default: @@ -371,6 +404,10 @@ public sealed class ElicitRequestParams field = value; } } + + /// Gets or sets the default value for the string. + [JsonPropertyName("default")] + public string? Default { get; set; } } /// Represents a schema for a number or integer type. @@ -399,6 +436,10 @@ public sealed class ElicitRequestParams /// Gets or sets the maximum allowed value. [JsonPropertyName("maximum")] public double? Maximum { get; set; } + + /// Gets or sets the default value for the number. + [JsonPropertyName("default")] + public double? Default { get; set; } } /// Represents a schema for a Boolean type. @@ -456,5 +497,9 @@ public sealed class ElicitRequestParams /// Gets or sets optional display names corresponding to the enum values. [JsonPropertyName("enumNames")] public IList? EnumNames { get; set; } + + /// Gets or sets the default value for the enum. + [JsonPropertyName("default")] + public string? Default { get; set; } } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs new file mode 100644 index 0000000..420c35f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs @@ -0,0 +1,332 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +public class ElicitationDefaultValuesTests +{ + [Fact] + public void StringSchema_Default_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.StringSchema + { + Title = "Name", + Description = "User's name", + Default = "John Doe" + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var stringSchema = Assert.IsType(deserialized); + Assert.Equal("John Doe", stringSchema.Default); + Assert.Equal("Name", stringSchema.Title); + Assert.Equal("User's name", stringSchema.Description); + Assert.Contains("\"default\":\"John Doe\"", json); + } + + [Fact] + public void StringSchema_Default_Null_DoesNotSerialize() + { + // Arrange + var schema = new ElicitRequestParams.StringSchema + { + Title = "Name" + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.DoesNotContain("\"default\"", json); + } + + [Fact] + public void NumberSchema_Default_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.NumberSchema + { + Title = "Age", + Description = "User's age", + Default = 25.5, + Minimum = 0, + Maximum = 150 + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var numberSchema = Assert.IsType(deserialized); + Assert.Equal(25.5, numberSchema.Default); + Assert.Equal("Age", numberSchema.Title); + Assert.Equal("User's age", numberSchema.Description); + Assert.Contains("\"default\":25.5", json); + } + + [Fact] + public void NumberSchema_Integer_Default_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.NumberSchema + { + Type = "integer", + Title = "Count", + Default = 42 + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var numberSchema = Assert.IsType(deserialized); + Assert.Equal(42, numberSchema.Default); + Assert.Equal("Count", numberSchema.Title); + Assert.Contains("\"default\":42", json); + } + + [Fact] + public void NumberSchema_Default_Null_DoesNotSerialize() + { + // Arrange + var schema = new ElicitRequestParams.NumberSchema + { + Title = "Age" + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.DoesNotContain("\"default\"", json); + } + + [Fact] + public void EnumSchema_Default_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.EnumSchema + { + Title = "Priority", + Description = "Task priority", + Enum = ["low", "medium", "high"], + EnumNames = ["Low Priority", "Medium Priority", "High Priority"], + Default = "medium" + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var enumSchema = Assert.IsType(deserialized); + Assert.Equal("medium", enumSchema.Default); + Assert.Equal("Priority", enumSchema.Title); + Assert.Equal("Task priority", enumSchema.Description); + Assert.Contains("\"default\":\"medium\"", json); + } + + [Fact] + public void EnumSchema_Default_Null_DoesNotSerialize() + { + // Arrange + var schema = new ElicitRequestParams.EnumSchema + { + Title = "Priority", + Enum = ["low", "medium", "high"] + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.DoesNotContain("\"default\"", json); + } + + [Fact] + public void BooleanSchema_Default_True_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.BooleanSchema + { + Title = "Active", + Description = "Is user active", + Default = true + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var booleanSchema = Assert.IsType(deserialized); + Assert.True(booleanSchema.Default); + Assert.Contains("\"default\":true", json); + } + + [Fact] + public void BooleanSchema_Default_False_Serializes_Correctly() + { + // Arrange + var schema = new ElicitRequestParams.BooleanSchema + { + Title = "Active", + Default = false + }; + + // Act - serialize as base type to use the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var booleanSchema = Assert.IsType(deserialized); + Assert.False(booleanSchema.Default); + Assert.Contains("\"default\":false", json); + } + + [Fact] + public void PrimitiveSchemaDefinition_StringSchema_WithDefault_RoundTrips() + { + // Arrange + var schema = new ElicitRequestParams.StringSchema + { + Title = "Email", + Format = "email", + Default = "user@example.com" + }; + + // Act - serialize as base type to test the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var stringSchema = Assert.IsType(deserialized); + Assert.Equal("user@example.com", stringSchema.Default); + Assert.Equal("email", stringSchema.Format); + } + + [Fact] + public void PrimitiveSchemaDefinition_NumberSchema_WithDefault_RoundTrips() + { + // Arrange + var schema = new ElicitRequestParams.NumberSchema + { + Title = "Score", + Minimum = 0, + Maximum = 100, + Default = 75.5 + }; + + // Act - serialize as base type to test the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var numberSchema = Assert.IsType(deserialized); + Assert.Equal(75.5, numberSchema.Default); + Assert.Equal(0, numberSchema.Minimum); + Assert.Equal(100, numberSchema.Maximum); + } + + [Fact] + public void PrimitiveSchemaDefinition_EnumSchema_WithDefault_RoundTrips() + { + // Arrange + var schema = new ElicitRequestParams.EnumSchema + { + Title = "Status", + Enum = ["draft", "published", "archived"], + Default = "draft" + }; + + // Act - serialize as base type to test the converter + string json = JsonSerializer.Serialize(schema, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + var enumSchema = Assert.IsType(deserialized); + Assert.Equal("draft", enumSchema.Default); + Assert.Equal(["draft", "published", "archived"], enumSchema.Enum); + } + + [Fact] + public void RequestSchema_WithAllDefaultTypes_Serializes_Correctly() + { + // Arrange + var requestParams = new ElicitRequestParams + { + Message = "Please fill out the form", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema + { + Title = "Name", + Default = "John Doe" + }, + ["age"] = new ElicitRequestParams.NumberSchema + { + Title = "Age", + Type = "integer", + Default = 30 + }, + ["score"] = new ElicitRequestParams.NumberSchema + { + Title = "Score", + Default = 85.5 + }, + ["active"] = new ElicitRequestParams.BooleanSchema + { + Title = "Active", + Default = true + }, + ["status"] = new ElicitRequestParams.EnumSchema + { + Title = "Status", + Enum = ["active", "inactive"], + Default = "active" + } + } + } + }; + + // Act + string json = JsonSerializer.Serialize(requestParams, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(5, deserialized.RequestedSchema.Properties.Count); + + var nameSchema = Assert.IsType(deserialized.RequestedSchema.Properties["name"]); + Assert.Equal("John Doe", nameSchema.Default); + + var ageSchema = Assert.IsType(deserialized.RequestedSchema.Properties["age"]); + Assert.Equal(30, ageSchema.Default); + + var scoreSchema = Assert.IsType(deserialized.RequestedSchema.Properties["score"]); + Assert.Equal(85.5, scoreSchema.Default); + + var activeSchema = Assert.IsType(deserialized.RequestedSchema.Properties["active"]); + Assert.True(activeSchema.Default); + + var statusSchema = Assert.IsType(deserialized.RequestedSchema.Properties["status"]); + Assert.Equal("active", statusSchema.Default); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 55e32f4..b5cfec7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -86,6 +86,19 @@ public partial class ElicitationTypedTests : ClientServerTestBase Content = [new TextContentBlock { Text = "unexpected" }], }; } + else if (request.Params!.Name == "TestElicitationWithDefaults") + { + var result = await request.Server.ElicitAsync( + message: "Please provide information.", + serializerOptions: ElicitationDefaultsJsonContext.Default.Options, + cancellationToken: CancellationToken.None); + + // The test will validate the schema in the client handler + return new CallToolResult + { + Content = [new TextContentBlock { Text = "success" }], + }; + } else { Assert.Fail($"Unexpected tool name: {request.Params!.Name}"); @@ -360,4 +373,79 @@ public partial class ElicitationTypedTests : ClientServerTestBase [JsonSerializable(typeof(UnsupportedForm.Nested))] [JsonSerializable(typeof(JsonElement))] internal partial class ElicitationUnsupportedJsonContext : JsonSerializerContext; + + public sealed record FormWithDefaults( + string Name = "John Doe", + int Age = 30, + double Score = 85.5, + bool IsActive = true, + string Status = "active" + ); + + [JsonSerializable(typeof(FormWithDefaults))] + [JsonSerializable(typeof(JsonElement))] + internal partial class ElicitationDefaultsJsonContext : JsonSerializerContext; + + [Fact(Skip = "Requires AIJsonUtilities to support extracting default values from optional parameters")] + public async Task Elicit_Typed_With_Defaults_Maps_To_Schema_Defaults() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.Equal("Please provide information.", request.Message); + + Assert.Equal(5, request.RequestedSchema.Properties.Count); + + // Verify that default values from the type are mapped to the schema + foreach (var entry in request.RequestedSchema.Properties) + { + switch (entry.Key) + { + case nameof(FormWithDefaults.Name): + var nameSchema = Assert.IsType(entry.Value); + Assert.Equal("John Doe", nameSchema.Default); + break; + + case nameof(FormWithDefaults.Age): + var ageSchema = Assert.IsType(entry.Value); + Assert.Equal(30, ageSchema.Default); + break; + + case nameof(FormWithDefaults.Score): + var scoreSchema = Assert.IsType(entry.Value); + Assert.Equal(85.5, scoreSchema.Default); + break; + + case nameof(FormWithDefaults.IsActive): + var activeSchema = Assert.IsType(entry.Value); + Assert.True(activeSchema.Default); + break; + + case nameof(FormWithDefaults.Status): + var statusSchema = Assert.IsType(entry.Value); + Assert.Equal("active", statusSchema.Default); + break; + + default: + Assert.Fail($"Unexpected property: {entry.Key}"); + break; + } + } + + return new ElicitResult + { + Action = "accept", + Content = new Dictionary() + }; + }, + } + }); + + var result = await client.CallToolAsync("TestElicitationWithDefaults", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text); + } }