diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f72e5a483..9c61a4c73 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -76,7 +76,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? DeriveName(method), Description = options?.Description, MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), - SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + SerializerOptions = GetSerializerOptions(options?.SerializerOptions), JsonSchemaCreateOptions = options?.SchemaCreateOptions, ConfigureParameterBinding = pi => { @@ -580,4 +580,29 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer IsError = allErrorContent && hasAny }; } + + /// + /// Gets the appropriate for tool serialization. + /// + /// + /// 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. + /// + 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; + customOptions.MakeReadOnly(); + } + + return customOptions; + } } \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2e826d591..29ab59062 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -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(); + var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams + { + Name = "tool", + Arguments = new Dictionary + { + ["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(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 StructuredOutput_ReturnsExpectedSchema_Inputs() { yield return new object[] { "string" };