Skip to content
Open
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
27 changes: 26 additions & 1 deletion src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
Name = options?.Name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name ?? DeriveName(method),
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
SerializerOptions = GetSerializerOptions(options?.SerializerOptions),
JsonSchemaCreateOptions = options?.SchemaCreateOptions,
ConfigureParameterBinding = pi =>
{
Expand Down Expand Up @@ -580,4 +580,29 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer
IsError = allErrorContent && hasAny
};
}

/// <summary>
/// Gets the appropriate <see cref="JsonSerializerOptions"/> for tool serialization.
/// </summary>
/// <remarks>
/// If no options are provided, returns the default MCP options. If options are provided but
/// don't have a TypeInfoResolver, this method silently mutates the options to add one,
/// approximating the behavior of the non-AOT friendly JsonSerializer.Serialize methods.
/// </remarks>
private static JsonSerializerOptions GetSerializerOptions(JsonSerializerOptions? customOptions)
{
if (customOptions is null)
{
return McpJsonUtilities.DefaultOptions;
}

if (customOptions.TypeInfoResolver is null)
{
Debug.Assert(!customOptions.IsReadOnly, "If no resolver is present then the options must still be editable.");
customOptions.TypeInfoResolver = McpJsonUtilities.DefaultOptions.TypeInfoResolver;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eiriktsarpalis presumably there's a race condition here, where by the time we go to set TypeInfoResolver, it's already been set and/or the instance has already been made read-only? Is the right answer for that to a) just accept/document that concurrent use may result in such overwrites and b) catch whatever exception would emerge when trying to set this on an already read-only instance?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible, although modifying the JSO while concurrently passing it to a serializer is a known source of errors and is not recommended. What bothers me most though is that this method could race with, say, JsonSerializer.Serialize which will try to assign it the default reflection-based resolver, or any other method that could try to set its own resolver following this convention.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think we should do, @eiriktsarpalis ?

Is going back to the original solution of creating a clone JSO the least-bad option?

customOptions.MakeReadOnly();
}

return customOptions;
}
}
38 changes: 38 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,44 @@ public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHan
Assert.True(JsonElement.DeepEquals(expectedSchema, tool.ProtocolTool.InputSchema));
}

[Fact]
public async Task Create_WithJsonSerializerOptionsWithoutTypeInfoResolver_Works()
{
// Arrange - Create options without a TypeInfoResolver (simulates issue #1150)
JsonSerializerOptions options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};

// Act - Should not throw when creating a tool with these options
McpServerTool tool = McpServerTool.Create((string name) => $"Hello, {name}!", new() { SerializerOptions = options });

// Assert - Verify the tool works correctly
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams
{
Name = "tool",
Arguments = new Dictionary<string, JsonElement>
{
["name"] = JsonDocument.Parse("\"World\"").RootElement.Clone()
}
},
};

var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);

Assert.NotNull(result);
Assert.Single(result.Content);
Assert.Equal("Hello, World!", Assert.IsType<TextContentBlock>(result.Content[0]).Text);

// Verify that the options now have a TypeInfoResolver and are read-only
Assert.NotNull(options.TypeInfoResolver);
Assert.True(options.IsReadOnly);
}

public static IEnumerable<object[]> StructuredOutput_ReturnsExpectedSchema_Inputs()
{
yield return new object[] { "string" };
Expand Down
Loading