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
13 changes: 11 additions & 2 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace ModelContextProtocol.Server;
Expand All @@ -14,6 +15,7 @@ namespace ModelContextProtocol.Server;
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
private readonly IReadOnlyList<object> _metadata;
private readonly JsonSerializerOptions _structuredContentSerializerOptions;

/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
Expand Down Expand Up @@ -225,6 +227,13 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider
ProtocolTool = tool;

_metadata = metadata;
_structuredContentSerializerOptions = tool.OutputSchema is not null &&
function.JsonSerializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull
? new JsonSerializerOptions(function.JsonSerializerOptions)
{
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
}
: function.JsonSerializerOptions;
}

/// <inheritdoc />
Expand Down Expand Up @@ -677,7 +686,7 @@ private static void RewriteRefPointers(JsonNode? node)
JsonElement jsonElement => jsonElement,
JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode),
null => null,
_ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
_ => JsonSerializer.SerializeToElement(aiFunctionResult, _structuredContentSerializerOptions.GetTypeInfo(typeof(object))),
};

// Pre-SEP-2106 clients expect the {"result": <value>} envelope for non-object
Expand Down Expand Up @@ -805,4 +814,4 @@ private static bool IsPrimitiveHeaderType(Type type)
type == typeof(uint) ||
type == typeof(long);
}
}
}
61 changes: 61 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,58 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(T value)
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}

[Theory]
[InlineData(null, Sep2106ProtocolVersion)]
[InlineData("note", Sep2106ProtocolVersion)]
[InlineData(null, "2025-11-25")]
[InlineData("note", "2025-11-25")]
public async Task StructuredOutput_Enabled_RequiredNullableProperties_MatchesOutputSchema(string? note, string protocolVersion)
{
JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
McpJsonUtilities.DefaultOptions.TypeInfoResolver,
new DefaultJsonTypeInfoResolver()),
};
McpServerTool tool = McpServerTool.Create(
() => new RequiredNullableOutput("42", note),
new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions });
var request = CreateRequestContextWithProtocolVersion(protocolVersion);

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

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.NotNull(result.StructuredContent);
Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}

[Theory]
[InlineData(null, Sep2106ProtocolVersion)]
[InlineData("note", Sep2106ProtocolVersion)]
[InlineData(null, "2025-11-25")]
[InlineData("note", "2025-11-25")]
public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_MatchesOutputSchema(string? note, string protocolVersion)
{
JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
McpJsonUtilities.DefaultOptions.TypeInfoResolver,
new DefaultJsonTypeInfoResolver()),
};
McpServerTool tool = McpServerTool.Create(
() => new RequiredNullablePropertiesOutput { Id = "42", Note = note },
new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions });
var request = CreateRequestContextWithProtocolVersion(protocolVersion);

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

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.NotNull(result.StructuredContent);
Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}

[Fact]
public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema()
{
Expand Down Expand Up @@ -1091,6 +1143,15 @@ Instance JSON document does not match the specified schema.

record Person(string Name, int Age);

private sealed record RequiredNullableOutput(string Id, string? Note);

private sealed class RequiredNullablePropertiesOutput
{
public required string Id { get; init; }

public required string? Note { get; init; }
}

private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
{
JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);
Expand Down