Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -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>
1 change: 0 additions & 1 deletion ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
25 changes: 25 additions & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<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,
Expand Down
7 changes: 7 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;
Expand Down Expand Up @@ -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>
Expand Down
37 changes: 32 additions & 5 deletions src/ModelContextProtocol.Core/Protocol/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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;

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

_inputSchema = value;
field = value;
}
}

Expand Down
114 changes: 103 additions & 11 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace ModelContextProtocol.Server;

/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -229,6 +232,8 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
{
newOptions.ReadOnly ??= readOnly;
}

newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
}

if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
Expand All @@ -243,11 +248,12 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
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 />
Expand Down Expand Up @@ -295,39 +301,46 @@ public override async ValueTask<CallToolResponse> 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<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,
Expand All @@ -338,12 +351,90 @@ public override async ValueTask<CallToolResponse> InvokeAsync(
{
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;
Expand All @@ -363,6 +454,7 @@ private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEn
return new()
{
Content = contentList,
StructuredContent = structuredContent,
IsError = allErrorContent && hasAny
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,13 @@ public bool ReadOnly
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; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using System.ComponentModel;
using System.Text.Json;

Expand All @@ -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; }
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -154,6 +164,7 @@ internal McpServerToolCreateOptions Clone() =>
Idempotent = Idempotent,
OpenWorld = OpenWorld,
ReadOnly = ReadOnly,
UseStructuredContent = UseStructuredContent,
SerializerOptions = SerializerOptions,
SchemaCreateOptions = SchemaCreateOptions,
};
Expand Down
Loading