Expose a JsonSerializerOptions setting in the prompts APIs (#279)

* Expose a JsonSerializerOptions setting in the prompts APIs

* Change namespace for workaround converter.
This commit is contained in:
Eirik Tsarpalis 2025-04-11 18:02:21 +01:00 • committed by GitHub
commit ce4e6586d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 54 additions and 19 deletions

View file

@ -143,6 +143,7 @@ public static partial class McpServerBuilderExtensions
/// <summary>Adds <see cref="McpServerPrompt"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TPromptType">The prompt type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="serializerOptions">The serializer options governing prompt parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
@ -154,7 +155,8 @@ public static partial class McpServerBuilderExtensions
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicConstructors)] TPromptType>(
this IMcpServerBuilder builder)
this IMcpServerBuilder builder,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
@ -163,8 +165,8 @@ public static partial class McpServerBuilderExtensions
if (promptMethod.GetCustomAttribute<McpServerPromptAttribute>() is not null)
{
builder.Services.AddSingleton((Func<IServiceProvider, McpServerPrompt>)(promptMethod.IsStatic ?
services => McpServerPrompt.Create(promptMethod, options: new() { Services = services }) :
services => McpServerPrompt.Create(promptMethod, typeof(TPromptType), new() { Services = services })));
services => McpServerPrompt.Create(promptMethod, options: new() { Services = services, SerializerOptions = serializerOptions }) :
services => McpServerPrompt.Create(promptMethod, typeof(TPromptType), new() { Services = services, SerializerOptions = serializerOptions })));
}
}
@ -174,6 +176,7 @@ public static partial class McpServerBuilderExtensions
/// <summary>Adds <see cref="McpServerPrompt"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="promptTypes">Types with marked methods to add as prompts to the server.</param>
/// <param name="serializerOptions">The serializer options governing prompt parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="promptTypes"/> is <see langword="null"/>.</exception>
@ -183,7 +186,7 @@ public static partial class McpServerBuilderExtensions
/// instance for each. For instance methods, an instance will be constructed for each invocation of the prompt.
/// </remarks>
[RequiresUnreferencedCode(WithPromptsRequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithPrompts(this IMcpServerBuilder builder, params IEnumerable<Type> promptTypes)
public static IMcpServerBuilder WithPrompts(this IMcpServerBuilder builder, IEnumerable<Type> promptTypes, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
Throw.IfNull(promptTypes);
@ -197,8 +200,8 @@ public static partial class McpServerBuilderExtensions
if (promptMethod.GetCustomAttribute<McpServerPromptAttribute>() is not null)
{
builder.Services.AddSingleton((Func<IServiceProvider, McpServerPrompt>)(promptMethod.IsStatic ?
services => McpServerPrompt.Create(promptMethod, options: new() { Services = services }) :
services => McpServerPrompt.Create(promptMethod, promptType, new() { Services = services })));
services => McpServerPrompt.Create(promptMethod, options: new() { Services = services, SerializerOptions = serializerOptions }) :
services => McpServerPrompt.Create(promptMethod, promptType, new() { Services = services, SerializerOptions = serializerOptions })));
}
}
}
@ -211,6 +214,7 @@ public static partial class McpServerBuilderExtensions
/// Adds types marked with the <see cref="McpServerPromptTypeAttribute"/> attribute from the given assembly as prompts to the server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="serializerOptions">The serializer options governing prompt parameter marshalling.</param>
/// <param name="promptAssembly">The assembly to load the types from. If <see langword="null"/>, the calling assembly will be used.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
@ -235,7 +239,7 @@ public static partial class McpServerBuilderExtensions
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithPromptsRequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithPromptsFromAssembly(this IMcpServerBuilder builder, Assembly? promptAssembly = null)
public static IMcpServerBuilder WithPromptsFromAssembly(this IMcpServerBuilder builder, Assembly? promptAssembly = null, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
@ -244,7 +248,8 @@ public static partial class McpServerBuilderExtensions
return builder.WithPrompts(
from t in promptAssembly.GetTypes()
where t.GetCustomAttribute<McpServerPromptTypeAttribute>() is not null
select t);
select t,
serializerOptions);
}
#endregion

View file

@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Types;

View file

@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Types;

View file

@ -1,3 +1,4 @@
using ModelContextProtocol.Utils.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Types;

View file

@ -2,6 +2,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
@ -66,6 +67,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
Name = options?.Name ?? method.GetCustomAttribute<McpServerPromptAttribute>()?.Name,
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<GetPromptRequestParams>))
@ -136,6 +138,10 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
Throw.IfNull(function);
List<PromptArgument> args = [];
HashSet<string>? requiredProps = function.JsonSchema.TryGetProperty("required", out JsonElement required)
? new(required.EnumerateArray().Select(p => p.GetString()!), StringComparer.Ordinal)
: null;
if (function.JsonSchema.TryGetProperty("properties", out JsonElement properties))
{
foreach (var param in properties.EnumerateObject())
@ -144,7 +150,7 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
{
Name = param.Name,
Description = param.Value.TryGetProperty("description", out JsonElement description) ? description.GetString() : null,
Required = param.Value.TryGetProperty("required", out JsonElement required) && required.GetBoolean(),
Required = requiredProps?.Contains(param.Name) ?? false,
});
}
}

View file

@ -1,4 +1,6 @@
using ModelContextProtocol.Utils.Json;
using System.ComponentModel;
using System.Text.Json;
namespace ModelContextProtocol.Server;
@ -45,14 +47,23 @@ public sealed class McpServerPromptCreateOptions
/// </remarks>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
/// </summary>
/// <remarks>
/// Defaults to <see cref="McpJsonUtilities.DefaultOptions"/> if left unspecified.
/// </remarks>
public JsonSerializerOptions? SerializerOptions { get; set; }
/// <summary>
/// Creates a shallow clone of the current <see cref="McpServerPromptCreateOptions"/> instance.
/// </summary>
internal McpServerPromptCreateOptions Clone() =>
new McpServerPromptCreateOptions()
new McpServerPromptCreateOptions
{
Services = Services,
Name = Name,
Description = Description,
SerializerOptions = SerializerOptions,
};
}

View file

@ -1,15 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
// NOTE:
// This is a temporary workaround for lack of System.Text.Json's JsonStringEnumConverter<T>
// This is a workaround for lack of System.Text.Json's JsonStringEnumConverter<T>
// 9.x support for JsonStringEnumMemberNameAttribute. Once all builds use the System.Text.Json 9.x
// version, this whole file can be removed.
// version, this whole file can be removed. Note that the type is public so that external source
// generators can use it, so removing it is a potential breaking change.
namespace System.Text.Json.Serialization;
namespace ModelContextProtocol.Utils.Json;
/// <summary>
/// A JSON converter for enums that allows customizing the serialized string value of enum members
@ -21,7 +25,8 @@ namespace System.Text.Json.Serialization;
/// 9.x support for custom enum member naming. It will be replaced by the built-in functionality
/// once .NET 9 is fully adopted.
/// </remarks>
internal sealed class CustomizableJsonStringEnumConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum> :
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class CustomizableJsonStringEnumConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum> :
JsonStringEnumConverter<TEnum> where TEnum : struct, Enum
{
#if !NET9_0_OR_GREATER

View file

@ -6,13 +6,14 @@ using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json.Serialization;
using System.Threading.Channels;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace ModelContextProtocol.Tests.Configuration;
public class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
{
public McpServerBuilderExtensionsPromptsTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
@ -237,7 +238,7 @@ public class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
ServiceCollection sc = new();
sc.AddMcpServer()
.WithPrompts<SimplePrompts>()
.WithPrompts<MorePrompts>();
.WithPrompts<MorePrompts>(JsonContext4.Default.Options);
IServiceProvider services = sc.BuildServiceProvider();
Assert.Contains(services.GetServices<McpServerPrompt>(), t => t.ProtocolPrompt.Name == nameof(SimplePrompts.ReturnsChatMessages));
@ -270,7 +271,7 @@ public class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
public sealed class MorePrompts
{
[McpServerPrompt]
public static PromptMessage AnotherPrompt() =>
public static PromptMessage AnotherPrompt(ObjectWithId id) =>
new PromptMessage
{
Role = Role.User,
@ -282,4 +283,8 @@ public class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
}
[JsonSerializable(typeof(ObjectWithId))]
[JsonSerializable(typeof(PromptMessage))]
partial class JsonContext4 : JsonSerializerContext;
}