Fix deserialization of PrimitiveSchemaDefinition (#537)

This is preventing clients from correctly handling elicitation requests.
This commit is contained in:
Stephen Toub 2025-06-24 08:48:12 -04:00 • committed by GitHub
commit 068f0418fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,7 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
@ -54,38 +57,272 @@ public sealed class ElicitRequestParams
public IList<string>? Required { get; set; }
}
/// <summary>
/// Represents restricted subset of JSON Schema:
/// <see cref="StringSchema"/>, <see cref="NumberSchema"/>, <see cref="BooleanSchema"/>, or <see cref="EnumSchema"/>.
/// </summary>
[JsonDerivedType(typeof(BooleanSchema))]
[JsonDerivedType(typeof(EnumSchema))]
[JsonDerivedType(typeof(NumberSchema))]
[JsonDerivedType(typeof(StringSchema))]
[JsonConverter(typeof(Converter))] // TODO: This converter exists due to the lack of downlevel support for AllowOutOfOrderMetadataProperties.
public abstract class PrimitiveSchemaDefinition
{
/// <summary>Prevent external derivations.</summary>
protected private PrimitiveSchemaDefinition()
{
}
/// <summary>Gets the type of the schema.</summary>
[JsonPropertyName("type")]
public abstract string Type { get; set; }
/// <summary>Gets or sets a title for the schema.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets a description for the schema.</summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Provides a <see cref="JsonConverter"/> for <see cref="ResourceContents"/>.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public class Converter : JsonConverter<PrimitiveSchemaDefinition>
{
/// <inheritdoc/>
public override PrimitiveSchemaDefinition? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
string? type = null;
string? title = null;
string? description = null;
int? minLength = null;
int? maxLength = null;
string? format = null;
double? minimum = null;
double? maximum = null;
bool? defaultBool = null;
IList<string>? enumValues = null;
IList<string>? enumNames = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType != JsonTokenType.PropertyName)
{
continue;
}
string? propertyName = reader.GetString();
bool success = reader.Read();
Debug.Assert(success, "STJ must have buffered the entire object for us.");
switch (propertyName)
{
case "type":
type = reader.GetString();
break;
case "title":
title = reader.GetString();
break;
case "description":
description = reader.GetString();
break;
case "minLength":
minLength = reader.GetInt32();
break;
case "maxLength":
maxLength = reader.GetInt32();
break;
case "format":
format = reader.GetString();
break;
case "minimum":
minimum = reader.GetDouble();
break;
case "maximum":
maximum = reader.GetDouble();
break;
case "default":
defaultBool = reader.GetBoolean();
break;
case "enum":
enumValues = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.IListString);
break;
case "enumNames":
enumNames = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.IListString);
break;
default:
break;
}
}
if (type is null)
{
throw new JsonException("The 'type' property is required.");
}
PrimitiveSchemaDefinition? psd = null;
switch (type)
{
case "string":
if (enumValues is not null)
{
psd = new EnumSchema
{
Enum = enumValues,
EnumNames = enumNames
};
}
else
{
psd = new StringSchema
{
MinLength = minLength,
MaxLength = maxLength,
Format = format,
};
}
break;
case "integer":
case "number":
psd = new NumberSchema
{
Minimum = minimum,
Maximum = maximum,
};
break;
case "boolean":
psd = new BooleanSchema
{
Default = defaultBool,
};
break;
}
if (psd is not null)
{
psd.Type = type;
psd.Title = title;
psd.Description = description;
}
return psd;
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, PrimitiveSchemaDefinition value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
writer.WriteStartObject();
writer.WriteString("type", value.Type);
if (value.Title is not null)
{
writer.WriteString("title", value.Title);
}
if (value.Description is not null)
{
writer.WriteString("description", value.Description);
}
switch (value)
{
case StringSchema stringSchema:
if (stringSchema.MinLength.HasValue)
{
writer.WriteNumber("minLength", stringSchema.MinLength.Value);
}
if (stringSchema.MaxLength.HasValue)
{
writer.WriteNumber("maxLength", stringSchema.MaxLength.Value);
}
if (stringSchema.Format is not null)
{
writer.WriteString("format", stringSchema.Format);
}
break;
case NumberSchema numberSchema:
if (numberSchema.Minimum.HasValue)
{
writer.WriteNumber("minimum", numberSchema.Minimum.Value);
}
if (numberSchema.Maximum.HasValue)
{
writer.WriteNumber("maximum", numberSchema.Maximum.Value);
}
break;
case BooleanSchema booleanSchema:
if (booleanSchema.Default.HasValue)
{
writer.WriteBoolean("default", booleanSchema.Default.Value);
}
break;
case EnumSchema enumSchema:
if (enumSchema.Enum is not null)
{
writer.WritePropertyName("enum");
JsonSerializer.Serialize(writer, enumSchema.Enum, McpJsonUtilities.JsonContext.Default.IListString);
}
if (enumSchema.EnumNames is not null)
{
writer.WritePropertyName("enumNames");
JsonSerializer.Serialize(writer, enumSchema.EnumNames, McpJsonUtilities.JsonContext.Default.IListString);
}
break;
default:
throw new JsonException($"Unexpected schema type: {value.GetType().Name}");
}
writer.WriteEndObject();
}
}
}
/// <summary>Represents a schema for a string type.</summary>
public sealed class StringSchema : PrimitiveSchemaDefinition
{
/// <summary>Gets the type of the schema.</summary>
/// <remarks>This is always "string".</remarks>
/// <inheritdoc/>
[JsonPropertyName("type")]
public string Type => "string";
/// <summary>Gets or sets a title for the string.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets a description for the string.</summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
public override string Type
{
get => "string";
set
{
if (value is not "string")
{
throw new ArgumentException("Type must be 'string'.", nameof(value));
}
}
}
/// <summary>Gets or sets the minimum length for the string.</summary>
[JsonPropertyName("minLength")]
@ -139,11 +376,9 @@ public sealed class ElicitRequestParams
/// <summary>Represents a schema for a number or integer type.</summary>
public sealed class NumberSchema : PrimitiveSchemaDefinition
{
/// <summary>Gets the type of the schema.</summary>
/// <remarks>This should be "number" or "integer".</remarks>
[JsonPropertyName("type")]
/// <inheritdoc/>
[field: MaybeNull]
public string Type
public override string Type
{
get => field ??= "number";
set
@ -157,14 +392,6 @@ public sealed class ElicitRequestParams
}
}
/// <summary>Gets or sets a title for the number input.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets a description for the number input.</summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>Gets or sets the minimum allowed value.</summary>
[JsonPropertyName("minimum")]
public double? Minimum { get; set; }
@ -177,18 +404,19 @@ public sealed class ElicitRequestParams
/// <summary>Represents a schema for a Boolean type.</summary>
public sealed class BooleanSchema : PrimitiveSchemaDefinition
{
/// <summary>Gets the type of the schema.</summary>
/// <remarks>This is always "boolean".</remarks>
/// <inheritdoc/>
[JsonPropertyName("type")]
public string Type => "boolean";
/// <summary>Gets or sets a title for the Boolean.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets a description for the Boolean.</summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
public override string Type
{
get => "boolean";
set
{
if (value is not "boolean")
{
throw new ArgumentException("Type must be 'boolean'.", nameof(value));
}
}
}
/// <summary>Gets or sets the default value for the Boolean.</summary>
[JsonPropertyName("default")]
@ -198,18 +426,19 @@ public sealed class ElicitRequestParams
/// <summary>Represents a schema for an enum type.</summary>
public sealed class EnumSchema : PrimitiveSchemaDefinition
{
/// <summary>Gets the type of the schema.</summary>
/// <remarks>This is always "string".</remarks>
/// <inheritdoc/>
[JsonPropertyName("type")]
public string Type => "string";
/// <summary>Gets or sets a title for the enum.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets a description for the enum.</summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
public override string Type
{
get => "string";
set
{
if (value is not "string")
{
throw new ArgumentException("Type must be 'string'.", nameof(value));
}
}
}
/// <summary>Gets or sets the list of allowed string values for the enum.</summary>
[JsonPropertyName("enum")]