diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 3a9926e2..2c731534 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -109,6 +109,8 @@ public class Converter : JsonConverter 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 class Converter : JsonConverter 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 class Converter : JsonConverter psd = new EnumSchema { Enum = enumValues, - EnumNames = enumNames + EnumNames = enumNames, + Default = defaultString, }; } else @@ -198,6 +217,7 @@ public class Converter : JsonConverter MinLength = minLength, MaxLength = maxLength, Format = format, + Default = defaultString, }; } break; @@ -208,6 +228,7 @@ public class Converter : JsonConverter { Minimum = minimum, Maximum = maximum, + Default = defaultNumber, }; break; @@ -265,6 +286,10 @@ public override void Write(Utf8JsonWriter writer, PrimitiveSchemaDefinition valu { 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 override void Write(Utf8JsonWriter writer, PrimitiveSchemaDefinition valu { 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 override void Write(Utf8JsonWriter writer, PrimitiveSchemaDefinition valu 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 string? Format 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 override string Type /// 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 IList Enum /// 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 00000000..420c35f9 --- /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 55e32f4a..b5cfec76 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -86,6 +86,19 @@ await request.Server.ElicitAsync( 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 sealed class Nested [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); + } }