From edde38ec164324ab0c5c7eb3243f0778e06302e6 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 2 Jun 2025 20:09:49 +0300 Subject: [PATCH 1/3] Add structured output/output schema support for server-side tools. --- Directory.Packages.props | 1 + ModelContextProtocol.slnx | 1 - .../Protocol/CallToolResponse.cs | 7 + .../Protocol/Tool.cs | 37 ++++- .../Server/AIFunctionMcpServerTool.cs | 133 ++++++++++++++++-- .../Server/McpServerToolCreateOptions.cs | 13 +- .../ModelContextProtocol.Tests.csproj | 3 +- .../Server/McpServerToolTests.cs | 120 +++++++++++++++- 8 files changed, 295 insertions(+), 20 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 04291f301..b1af09c22 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,5 +75,6 @@ + \ No newline at end of file diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 84d5aca72..e4fd42fe8 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -24,7 +24,6 @@ - diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs index 0e83ef1b1..729434cf4 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs @@ -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 { get; set; } = []; + /// + /// Gets or sets an optional JSON object representing the structured result of the tool call. + /// + [JsonPropertyName("structuredContent")] + public JsonObject? StructuredContent { get; set; } + /// /// Gets or sets an indication of whether the tool call was unsuccessful. /// diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 71e1101f0..8ebf0f9be 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -8,8 +8,6 @@ namespace ModelContextProtocol.Protocol; /// public class Tool { - private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema; - /// /// Gets or sets the name of the tool. /// @@ -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)); + } + + field = value; + } + + } = McpJsonUtilities.DefaultMcpToolSchema; + + /// + /// Gets or sets a JSON Schema object defining the expected structured outputs for the tool. + /// + /// + /// + /// 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 + /// if an invalid schema is provided. + /// + /// + /// The schema should describe the shape of the data as returned in . + /// + /// + [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)); } - _inputSchema = value; + field = value; } } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 7f91186b1..ce758f852 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -7,6 +7,7 @@ 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; /// /// Creates an instance for a method, specified via a instance. @@ -176,7 +178,8 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( { 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 @@ options.OpenWorld is not null || } } - return new AIFunctionMcpServerTool(function, tool, options?.Services); + return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -243,11 +246,12 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - 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()?.CreateLogger() ?? (ILogger)NullLogger.Instance; + _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; } /// @@ -295,39 +299,46 @@ public override async ValueTask InvokeAsync( }; } + JsonObject? 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 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 contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems), + IEnumerable contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems, structuredContent), IEnumerable contents => new() { - Content = [.. contents] + Content = [.. contents], + StructuredContent = structuredContent, }, CallToolResponse callToolResponse => callToolResponse, @@ -338,12 +349,111 @@ public override async ValueTask InvokeAsync( { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), Type = "text" - }] + }], + StructuredContent = structuredContent, }, }; } - private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable contentItems) + private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping) + { + // TODO replace with https://github.com/dotnet/extensions/pull/6447 once merged. + + structuredOutputRequiresWrapping = false; + + if (toolCreateOptions?.UseStructuredContent is not true) + { + return null; + } + + 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 or ValueTask if applicable. + returnType = returnType.GetGenericArguments()[0]; + } + + JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: function.JsonSerializerOptions, inferenceOptions: toolCreateOptions?.SchemaCreateOptions); + + 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 JsonObject? CreateStructuredResponse(object? aiFunctionResult) + { + if (ProtocolTool.OutputSchema is null) + { + 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 + }; + } + + if (nodeResult is JsonObject jsonObject) + { + return jsonObject; + } + + throw new InvalidOperationException("The result of the AIFunction does not match its declared output schema."); + } + + private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable contentItems, JsonObject? structuredContent) { List contentList = []; bool allErrorContent = true; @@ -363,6 +473,7 @@ private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEn return new() { Content = contentList, + StructuredContent = structuredContent, IsError = allErrorContent && hasAny }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 80d638560..67ffe88f2 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -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 . /// /// - /// 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. /// public IServiceProvider? Services { get; set; } @@ -124,6 +125,15 @@ public sealed class McpServerToolCreateOptions /// public bool? ReadOnly { get; set; } + /// + /// Gets or sets whether the tool should report an output schema for structured content. + /// + /// + /// When enabled, the tool will attempt to populate the + /// and provide structured content in the property. + /// + public bool UseStructuredContent { get; set; } + /// /// Gets or sets the JSON serializer options to use when marshalling data to/from JSON. /// @@ -154,6 +164,7 @@ internal McpServerToolCreateOptions Clone() => Idempotent = Idempotent, OpenWorld = OpenWorld, ReadOnly = ReadOnly, + UseStructuredContent = UseStructuredContent, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, }; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 41a3524ca..d88ec9859 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -45,7 +45,8 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index db6f1cde4..0cd9f6167 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -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 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 async Task ToolCallError_LogsErrorMessage() Assert.Equal(exceptionMessage, errorLog.Exception.Message); } + [Theory] + [MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))] + public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) + { + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); + var mockServer = new Mock(); + var request = new RequestContext(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(); + var request = new RequestContext(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(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(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 value) + { + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => value, new() { UseStructuredContent = false, SerializerOptions = options }); + var mockServer = new Mock(); + var request = new RequestContext(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 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 { "item1", "item2" } }; + yield return new object[] { new Dictionary { ["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 object InstanceMethod() } } + 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 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; } From d2348c70fc207e1e6ec8bcc6b6bc1a2b7762ae4b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 3 Jun 2025 13:29:19 +0300 Subject: [PATCH 2/3] Address feedback. --- .../McpJsonUtilities.cs | 25 ++++++++++++++ .../Protocol/CallToolResponse.cs | 2 +- .../Server/AIFunctionMcpServerTool.cs | 33 ++++--------------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index fda08f76b..0bc9ee777 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -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 @@ internal static bool IsValidMcpToolSchema(JsonElement element) 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 or ValueTask 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, diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs index 729434cf4..173041f40 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs @@ -32,7 +32,7 @@ public class CallToolResponse /// Gets or sets an optional JSON object representing the structured result of the tool call. /// [JsonPropertyName("structuredContent")] - public JsonObject? StructuredContent { get; set; } + public JsonNode? StructuredContent { get; set; } /// /// Gets or sets an indication of whether the tool call was unsuccessful. diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index ce758f852..cf3a0f846 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -299,7 +299,7 @@ public override async ValueTask InvokeAsync( }; } - JsonObject? structuredContent = CreateStructuredResponse(result); + JsonNode? structuredContent = CreateStructuredResponse(result); return result switch { AIContent aiContent => new() @@ -357,8 +357,6 @@ public override async ValueTask InvokeAsync( private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping) { - // TODO replace with https://github.com/dotnet/extensions/pull/6447 once merged. - structuredOutputRequiresWrapping = false; if (toolCreateOptions?.UseStructuredContent is not true) @@ -366,26 +364,11 @@ public override async ValueTask InvokeAsync( return null; } - if (function.UnderlyingMethod?.ReturnType is not Type returnType) - { - return null; - } - - if (returnType == typeof(void) || returnType == typeof(Task) || returnType == typeof(ValueTask)) + if (function.GetReturnSchema(toolCreateOptions?.SchemaCreateOptions) is not JsonElement outputSchema) { - // 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 or ValueTask if applicable. - returnType = returnType.GetGenericArguments()[0]; - } - - JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: function.JsonSerializerOptions, inferenceOptions: toolCreateOptions?.SchemaCreateOptions); - if (outputSchema.ValueKind is not JsonValueKind.Object || !outputSchema.TryGetProperty("type", out JsonElement typeProperty) || typeProperty.ValueKind is not JsonValueKind.String || @@ -423,10 +406,11 @@ typeProperty.ValueKind is not JsonValueKind.String || return outputSchema; } - private JsonObject? CreateStructuredResponse(object? aiFunctionResult) + private JsonNode? CreateStructuredResponse(object? aiFunctionResult) { if (ProtocolTool.OutputSchema is null) { + // Only provide structured responses if the tool has an output schema defined. return null; } @@ -445,15 +429,10 @@ typeProperty.ValueKind is not JsonValueKind.String || }; } - if (nodeResult is JsonObject jsonObject) - { - return jsonObject; - } - - throw new InvalidOperationException("The result of the AIFunction does not match its declared output schema."); + return nodeResult; } - private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable contentItems, JsonObject? structuredContent) + private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable contentItems, JsonNode? structuredContent) { List contentList = []; bool allErrorContent = true; From 441efc7e2572228dd6d2dbad97897677246513f5 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 5 Jun 2025 20:37:41 -0700 Subject: [PATCH 3/3] Add McpServerToolAttribute.UseStructuredContent --- .../Server/AIFunctionMcpServerTool.cs | 2 ++ .../Server/McpServerToolAttribute.cs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index cf3a0f846..73862ddc7 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -232,6 +232,8 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe { newOptions.ReadOnly ??= readOnly; } + + newOptions.UseStructuredContent = toolAttr.UseStructuredContent; } if (method.GetCustomAttribute() is { } descAttr) diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 73ee786b5..95556174a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -240,4 +240,13 @@ public bool ReadOnly get => _readOnly ?? ReadOnlyDefault; set => _readOnly = value; } + + /// + /// Gets or sets whether the tool should report an output schema for structured content. + /// + /// + /// When enabled, the tool will attempt to populate the + /// and provide structured content in the property. + /// + public bool UseStructuredContent { get; set; } }