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>
This commit is contained in:
Copilot 2025-10-31 08:01:55 +02:00 • committed by GitHub
commit 66c084c795
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 468 additions and 3 deletions

View file

@ -109,6 +109,8 @@ public sealed class ElicitRequestParams
double? minimum = null;
double? maximum = null;
bool? defaultBool = null;
double? defaultNumber = null;
string? defaultString = null;
IList<string>? enumValues = null;
IList<string>? 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;
}
}
/// <summary>Gets or sets the default value for the string.</summary>
[JsonPropertyName("default")]
public string? Default { get; set; }
}
/// <summary>Represents a schema for a number or integer type.</summary>
@ -399,6 +436,10 @@ public sealed class ElicitRequestParams
/// <summary>Gets or sets the maximum allowed value.</summary>
[JsonPropertyName("maximum")]
public double? Maximum { get; set; }
/// <summary>Gets or sets the default value for the number.</summary>
[JsonPropertyName("default")]
public double? Default { get; set; }
}
/// <summary>Represents a schema for a Boolean type.</summary>
@ -456,5 +497,9 @@ public sealed class ElicitRequestParams
/// <summary>Gets or sets optional display names corresponding to the enum values.</summary>
[JsonPropertyName("enumNames")]
public IList<string>? EnumNames { get; set; }
/// <summary>Gets or sets the default value for the enum.</summary>
[JsonPropertyName("default")]
public string? Default { get; set; }
}
}

View file

@ -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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var stringSchema = Assert.IsType<ElicitRequestParams.StringSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var numberSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var numberSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var enumSchema = Assert.IsType<ElicitRequestParams.EnumSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var booleanSchema = Assert.IsType<ElicitRequestParams.BooleanSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var booleanSchema = Assert.IsType<ElicitRequestParams.BooleanSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var stringSchema = Assert.IsType<ElicitRequestParams.StringSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var numberSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(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<ElicitRequestParams.PrimitiveSchemaDefinition>(schema, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams.PrimitiveSchemaDefinition>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
var enumSchema = Assert.IsType<ElicitRequestParams.EnumSchema>(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<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<ElicitRequestParams>(json, McpJsonUtilities.DefaultOptions);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(5, deserialized.RequestedSchema.Properties.Count);
var nameSchema = Assert.IsType<ElicitRequestParams.StringSchema>(deserialized.RequestedSchema.Properties["name"]);
Assert.Equal("John Doe", nameSchema.Default);
var ageSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(deserialized.RequestedSchema.Properties["age"]);
Assert.Equal(30, ageSchema.Default);
var scoreSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(deserialized.RequestedSchema.Properties["score"]);
Assert.Equal(85.5, scoreSchema.Default);
var activeSchema = Assert.IsType<ElicitRequestParams.BooleanSchema>(deserialized.RequestedSchema.Properties["active"]);
Assert.True(activeSchema.Default);
var statusSchema = Assert.IsType<ElicitRequestParams.EnumSchema>(deserialized.RequestedSchema.Properties["status"]);
Assert.Equal("active", statusSchema.Default);
}
}

View file

@ -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<FormWithDefaults>(
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<ElicitRequestParams.StringSchema>(entry.Value);
Assert.Equal("John Doe", nameSchema.Default);
break;
case nameof(FormWithDefaults.Age):
var ageSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(entry.Value);
Assert.Equal(30, ageSchema.Default);
break;
case nameof(FormWithDefaults.Score):
var scoreSchema = Assert.IsType<ElicitRequestParams.NumberSchema>(entry.Value);
Assert.Equal(85.5, scoreSchema.Default);
break;
case nameof(FormWithDefaults.IsActive):
var activeSchema = Assert.IsType<ElicitRequestParams.BooleanSchema>(entry.Value);
Assert.True(activeSchema.Default);
break;
case nameof(FormWithDefaults.Status):
var statusSchema = Assert.IsType<ElicitRequestParams.StringSchema>(entry.Value);
Assert.Equal("active", statusSchema.Default);
break;
default:
Assert.Fail($"Unexpected property: {entry.Key}");
break;
}
}
return new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>()
};
},
}
});
var result = await client.CallToolAsync("TestElicitationWithDefaults", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("success", (result.Content[0] as TextContentBlock)?.Text);
}
}