Skip to content

Fix deserialization of PrimitiveSchemaDefinition #537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 276 additions & 47 deletions src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,39 +57,273 @@ public IDictionary<string, PrimitiveSchemaDefinition> Properties
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>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>
[JsonPropertyName("type")]
public string Type => "string";
public abstract string Type { get; set; }

/// <summary>Gets or sets a title for the string.</summary>
/// <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 string.</summary>
/// <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
{
/// <inheritdoc/>
[JsonPropertyName("type")]
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")]
public int? MinLength
Expand Down Expand Up @@ -139,11 +376,9 @@ public string? Format
/// <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
Expand All @@ -157,14 +392,6 @@ public string Type
}
}

/// <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; }
Expand All @@ -177,18 +404,19 @@ public string Type
/// <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")]
Expand All @@ -198,18 +426,19 @@ public sealed class BooleanSchema : PrimitiveSchemaDefinition
/// <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")]
Expand Down
Loading
Loading