Add structured output/output schema support for server-side tools. (#480)

* Add structured output/output schema support for server-side tools.

* Address feedback.

* Add McpServerToolAttribute.UseStructuredContent

---------

Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
Eirik Tsarpalis 2025-06-06 08:20:12 +01:00 • committed by GitHub
commit fa017c04c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 310 additions and 20 deletions

View file

@ -75,5 +75,6 @@
<PackageVersion Include="xunit.v3" Version="2.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
</ItemGroup>
</Project>

View file

@ -24,7 +24,6 @@
<File Path="logo.png" />
<File Path="nuget.config" />
<File Path="README.MD" />
<File Path="version.json" />
</Folder>
<Folder Name="/src/">
<File Path="src/Directory.Build.props" />

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
@ -75,6 +76,30 @@ public static partial class McpJsonUtilities
return false; // No type keyword found.
}
internal static JsonElement? GetReturnSchema(this AIFunction function, AIJsonSchemaCreateOptions? schemaCreateOptions)
{
// TODO replace with https://github.com/dotnet/extensions/pull/6447 once merged.
if (function.UnderlyingMethod?.ReturnType is not Type returnType)
{
return null;
}
if (returnType == typeof(void) || returnType == typeof(Task) || returnType == typeof(ValueTask))
{
// Do not report an output schema for void or Task methods.
return null;
}
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() is Type genericTypeDef &&
(genericTypeDef == typeof(Task<>) || genericTypeDef == typeof(ValueTask<>)))
{
// Extract the real type from Task<T> or ValueTask<T> if applicable.
returnType = returnType.GetGenericArguments()[0];
}
return AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: function.JsonSerializerOptions, inferenceOptions: schemaCreateOptions);
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

View file

@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
@ -27,6 +28,12 @@ public class CallToolResponse
[JsonPropertyName("content")]
public List<Content> Content { get; set; } = [];
/// <summary>
/// Gets or sets an optional JSON object representing the structured result of the tool call.
/// </summary>
[JsonPropertyName("structuredContent")]
public JsonNode? StructuredContent { get; set; }
/// <summary>
/// Gets or sets an indication of whether the tool call was unsuccessful.
/// </summary>

View file

@ -8,8 +8,6 @@ namespace ModelContextProtocol.Protocol;
/// </summary>
public class Tool
{
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
/// <summary>
/// Gets or sets the name of the tool.
/// </summary>
@ -53,15 +51,44 @@ public class Tool
[JsonPropertyName("inputSchema")]
public JsonElement InputSchema
{
get => _inputSchema;
get => field;
set
{
if (!McpJsonUtilities.IsValidMcpToolSchema(value))
{
throw new ArgumentException("The specified document is not a valid MCP tool JSON schema.", nameof(InputSchema));
throw new ArgumentException("The specified document is not a valid MCP tool input JSON schema.", nameof(InputSchema));
}
_inputSchema = value;
field = value;
}
} = McpJsonUtilities.DefaultMcpToolSchema;
/// <summary>
/// Gets or sets a JSON Schema object defining the expected structured outputs for the tool.
/// </summary>
/// <remarks>
/// <para>
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
/// This is enforced by validation in the setter which will throw an <see cref="ArgumentException"/>
/// if an invalid schema is provided.
/// </para>
/// <para>
/// The schema should describe the shape of the data as returned in <see cref="CallToolResponse.StructuredContent"/>.
/// </para>
/// </remarks>
[JsonPropertyName("outputSchema")]
public JsonElement? OutputSchema
{
get => field;
set
{
if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value))
{
throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema));
}
field = value;
}
}

View file

@ -7,6 +7,7 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Server;
@ -14,6 +15,7 @@ namespace ModelContextProtocol.Server;
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
private readonly ILogger _logger;
private readonly bool _structuredOutputRequiresWrapping;
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
@ -176,7 +178,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
Name = options?.Name ?? function.Name,
Description = options?.Description ?? function.Description,
InputSchema = function.JsonSchema,
InputSchema = function.JsonSchema,
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
};
if (options is not null)
@ -198,7 +201,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
}
}
return new AIFunctionMcpServerTool(function, tool, options?.Services);
return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping);
}
private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
@ -229,6 +232,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
newOptions.ReadOnly ??= readOnly;
}
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
}
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
@ -243,11 +248,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
internal AIFunction AIFunction { get; }
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider)
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping)
{
AIFunction = function;
ProtocolTool = tool;
_logger = serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<McpServerTool>() ?? (ILogger)NullLogger.Instance;
_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
}
/// <inheritdoc />
@ -295,39 +301,46 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
};
}
JsonNode? structuredContent = CreateStructuredResponse(result);
return result switch
{
AIContent aiContent => new()
{
Content = [aiContent.ToContent()],
StructuredContent = structuredContent,
IsError = aiContent is ErrorContent
},
null => new()
{
Content = []
Content = [],
StructuredContent = structuredContent,
},
string text => new()
{
Content = [new() { Text = text, Type = "text" }]
Content = [new() { Text = text, Type = "text" }],
StructuredContent = structuredContent,
},
Content content => new()
{
Content = [content]
Content = [content],
StructuredContent = structuredContent,
},
IEnumerable<string> texts => new()
{
Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })]
Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })],
StructuredContent = structuredContent,
},
IEnumerable<AIContent> contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems),
IEnumerable<AIContent> contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems, structuredContent),
IEnumerable<Content> contents => new()
{
Content = [.. contents]
Content = [.. contents],
StructuredContent = structuredContent,
},
CallToolResponse callToolResponse => callToolResponse,
@ -338,12 +351,90 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
Type = "text"
}]
}],
StructuredContent = structuredContent,
},
};
}
private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable<AIContent> contentItems)
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
{
structuredOutputRequiresWrapping = false;
if (toolCreateOptions?.UseStructuredContent is not true)
{
return null;
}
if (function.GetReturnSchema(toolCreateOptions?.SchemaCreateOptions) is not JsonElement outputSchema)
{
return null;
}
if (outputSchema.ValueKind is not JsonValueKind.Object ||
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
typeProperty.ValueKind is not JsonValueKind.String ||
typeProperty.GetString() is not "object")
{
// If the output schema is not an object, need to modify to be a valid MCP output schema.
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
if (schemaNode is JsonObject objSchema &&
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
{
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
objSchema["type"] = "object";
}
else
{
// For anything else, wrap the schema in an envelope with a "result" property.
schemaNode = new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject
{
["result"] = schemaNode
},
["required"] = new JsonArray { (JsonNode)"result" }
};
structuredOutputRequiresWrapping = true;
}
outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
}
return outputSchema;
}
private JsonNode? CreateStructuredResponse(object? aiFunctionResult)
{
if (ProtocolTool.OutputSchema is null)
{
// Only provide structured responses if the tool has an output schema defined.
return null;
}
JsonNode? nodeResult = aiFunctionResult switch
{
JsonNode node => node,
JsonElement jsonElement => JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement),
_ => JsonSerializer.SerializeToNode(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
};
if (_structuredOutputRequiresWrapping)
{
return new JsonObject
{
["result"] = nodeResult
};
}
return nodeResult;
}
private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable<AIContent> contentItems, JsonNode? structuredContent)
{
List<Content> contentList = [];
bool allErrorContent = true;
@ -363,6 +454,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
return new()
{
Content = contentList,
StructuredContent = structuredContent,
IsError = allErrorContent && hasAny
};
}

View file

@ -240,4 +240,13 @@ public sealed class McpServerToolAttribute : Attribute
get => _readOnly ?? ReadOnlyDefault;
set => _readOnly = value;
}
/// <summary>
/// Gets or sets whether the tool should report an output schema for structured content.
/// </summary>
/// <remarks>
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
/// and provide structured content in the <see cref="CallToolResponse.StructuredContent"/> property.
/// </remarks>
public bool UseStructuredContent { get; set; }
}

View file

@ -1,4 +1,5 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using System.ComponentModel;
using System.Text.Json;
@ -24,7 +25,7 @@ public sealed class McpServerToolCreateOptions
/// Gets or sets optional services used in the construction of the <see cref="McpServerTool"/>.
/// </summary>
/// <remarks>
/// These services will be used to determine which parameters should be satisifed from dependency injection. As such,
/// These services will be used to determine which parameters should be satisfied from dependency injection. As such,
/// what services are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </remarks>
public IServiceProvider? Services { get; set; }
@ -124,6 +125,15 @@ public sealed class McpServerToolCreateOptions
/// </remarks>
public bool? ReadOnly { get; set; }
/// <summary>
/// Gets or sets whether the tool should report an output schema for structured content.
/// </summary>
/// <remarks>
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
/// and provide structured content in the <see cref="CallToolResponse.StructuredContent"/> property.
/// </remarks>
public bool UseStructuredContent { get; set; }
/// <summary>
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
/// </summary>
@ -154,6 +164,7 @@ public sealed class McpServerToolCreateOptions
Idempotent = Idempotent,
OpenWorld = OpenWorld,
ReadOnly = ReadOnly,
UseStructuredContent = UseStructuredContent,
SerializerOptions = SerializerOptions,
SchemaCreateOptions = SchemaCreateOptions,
};

View file

@ -45,7 +45,8 @@
<PackageReference Include="Moq" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
<PackageReference Include="JsonSchema.Net" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -1,4 +1,5 @@
using Microsoft.Extensions.AI;
using Json.Schema;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
@ -7,7 +8,10 @@ using ModelContextProtocol.Tests.Utils;
using Moq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Xunit.Sdk;
namespace ModelContextProtocol.Tests.Server;
@ -422,6 +426,94 @@ public partial class McpServerToolTests
Assert.Equal(exceptionMessage, errorLog.Exception.Message);
}
[Theory]
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(T value)
{
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
var mockServer = new Mock<IMcpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object)
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.NotNull(result.StructuredContent);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[Fact]
public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema()
{
McpServerTool tool = McpServerTool.Create(() => { });
var mockServer = new Mock<IMcpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object)
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
tool = McpServerTool.Create(() => Task.CompletedTask);
request = new RequestContext<CallToolRequestParams>(mockServer.Object)
{
Params = new CallToolRequestParams { Name = "tool" },
};
result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
tool = McpServerTool.Create(() => ValueTask.CompletedTask);
request = new RequestContext<CallToolRequestParams>(mockServer.Object)
{
Params = new CallToolRequestParams { Name = "tool" },
};
result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
}
[Theory]
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(T value)
{
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { UseStructuredContent = false, SerializerOptions = options });
var mockServer = new Mock<IMcpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object)
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
}
public static IEnumerable<object[]> StructuredOutput_ReturnsExpectedSchema_Inputs()
{
yield return new object[] { "string" };
yield return new object[] { 42 };
yield return new object[] { 3.14 };
yield return new object[] { true };
yield return new object[] { new object() };
yield return new object[] { new List<string> { "item1", "item2" } };
yield return new object[] { new Dictionary<string, int> { ["key1"] = 1, ["key2"] = 2 } };
yield return new object[] { new Person("John", 27) };
}
private sealed class MyService;
private class DisposableToolType : IDisposable
@ -510,9 +602,35 @@ public partial class McpServerToolTests
}
}
private static void AssertMatchesJsonSchema(JsonElement schemaDoc, JsonNode? value)
{
JsonSchema schema = JsonSerializer.Deserialize(schemaDoc, JsonContext2.Default.JsonSchema)!;
EvaluationOptions options = new() { OutputFormat = OutputFormat.List };
EvaluationResults results = schema.Evaluate(value, options);
if (!results.IsValid)
{
IEnumerable<string> errors = results.Details
.Where(d => d.HasErrors)
.SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}"));
throw new XunitException($"""
Instance JSON document does not match the specified schema.
Schema:
{JsonSerializer.Serialize(schema)}
Instance:
{value?.ToJsonString() ?? "null"}
Errors:
{string.Join(Environment.NewLine, errors)}
""");
}
}
record Person(string Name, int Age);
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DisposableToolType))]
[JsonSerializable(typeof(AsyncDisposableToolType))]
[JsonSerializable(typeof(AsyncDisposableAndDisposableToolType))]
[JsonSerializable(typeof(JsonSchema))]
partial class JsonContext2 : JsonSerializerContext;
}