diff --git a/Directory.Packages.props b/Directory.Packages.props
index 04291f30..b1af09c2 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 84d5aca7..e4fd42fe 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -24,7 +24,6 @@
-
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index fda08f76..0bc9ee77 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 0e83ef1b..173041f4 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 JsonNode? 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 71e1101f..8ebf0f9b 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 7f91186b..73862ddc 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)
@@ -229,6 +232,8 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
{
newOptions.ReadOnly ??= readOnly;
}
+
+ newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
}
if (method.GetCustomAttribute() is { } descAttr)
@@ -243,11 +248,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 +301,46 @@ public override async ValueTask InvokeAsync(
};
}
+ 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 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 +351,90 @@ 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)
+ {
+ 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 contentItems, JsonNode? structuredContent)
{
List contentList = [];
bool allErrorContent = true;
@@ -363,6 +454,7 @@ private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEn
return new()
{
Content = contentList,
+ StructuredContent = structuredContent,
IsError = allErrorContent && hasAny
};
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
index 73ee786b..95556174 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; }
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
index 80d63856..67ffe88f 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 41a3524c..d88ec985 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 db6f1cde..0cd9f616 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