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

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
};
}