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:
parent
46d25b7558
commit
fa017c04c3
10 changed files with 310 additions and 20 deletions
|
|
@ -75,5 +75,6 @@
|
||||||
<PackageVersion Include="xunit.v3" Version="2.0.2" />
|
<PackageVersion Include="xunit.v3" Version="2.0.2" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||||
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
|
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
|
||||||
|
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
<File Path="logo.png" />
|
<File Path="logo.png" />
|
||||||
<File Path="nuget.config" />
|
<File Path="nuget.config" />
|
||||||
<File Path="README.MD" />
|
<File Path="README.MD" />
|
||||||
<File Path="version.json" />
|
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<File Path="src/Directory.Build.props" />
|
<File Path="src/Directory.Build.props" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
@ -75,6 +76,30 @@ public static partial class McpJsonUtilities
|
||||||
return false; // No type keyword found.
|
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.
|
// Keep in sync with CreateDefaultOptions above.
|
||||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol;
|
namespace ModelContextProtocol.Protocol;
|
||||||
|
|
@ -27,6 +28,12 @@ public class CallToolResponse
|
||||||
[JsonPropertyName("content")]
|
[JsonPropertyName("content")]
|
||||||
public List<Content> Content { get; set; } = [];
|
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>
|
/// <summary>
|
||||||
/// Gets or sets an indication of whether the tool call was unsuccessful.
|
/// Gets or sets an indication of whether the tool call was unsuccessful.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ namespace ModelContextProtocol.Protocol;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Tool
|
public class Tool
|
||||||
{
|
{
|
||||||
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the tool.
|
/// Gets or sets the name of the tool.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -53,15 +51,44 @@ public class Tool
|
||||||
[JsonPropertyName("inputSchema")]
|
[JsonPropertyName("inputSchema")]
|
||||||
public JsonElement InputSchema
|
public JsonElement InputSchema
|
||||||
{
|
{
|
||||||
get => _inputSchema;
|
get => field;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (!McpJsonUtilities.IsValidMcpToolSchema(value))
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using System.ComponentModel;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Server;
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ namespace ModelContextProtocol.Server;
|
||||||
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
private readonly bool _structuredOutputRequiresWrapping;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
|
/// 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,
|
Name = options?.Name ?? function.Name,
|
||||||
Description = options?.Description ?? function.Description,
|
Description = options?.Description ?? function.Description,
|
||||||
InputSchema = function.JsonSchema,
|
InputSchema = function.JsonSchema,
|
||||||
|
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options is not null)
|
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)
|
private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
|
||||||
|
|
@ -229,6 +232,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
{
|
{
|
||||||
newOptions.ReadOnly ??= readOnly;
|
newOptions.ReadOnly ??= readOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
|
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
|
||||||
|
|
@ -243,11 +248,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
internal AIFunction AIFunction { get; }
|
internal AIFunction AIFunction { get; }
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
|
/// <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;
|
AIFunction = function;
|
||||||
ProtocolTool = tool;
|
ProtocolTool = tool;
|
||||||
_logger = serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<McpServerTool>() ?? (ILogger)NullLogger.Instance;
|
_logger = serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<McpServerTool>() ?? (ILogger)NullLogger.Instance;
|
||||||
|
_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -295,39 +301,46 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonNode? structuredContent = CreateStructuredResponse(result);
|
||||||
return result switch
|
return result switch
|
||||||
{
|
{
|
||||||
AIContent aiContent => new()
|
AIContent aiContent => new()
|
||||||
{
|
{
|
||||||
Content = [aiContent.ToContent()],
|
Content = [aiContent.ToContent()],
|
||||||
|
StructuredContent = structuredContent,
|
||||||
IsError = aiContent is ErrorContent
|
IsError = aiContent is ErrorContent
|
||||||
},
|
},
|
||||||
|
|
||||||
null => new()
|
null => new()
|
||||||
{
|
{
|
||||||
Content = []
|
Content = [],
|
||||||
|
StructuredContent = structuredContent,
|
||||||
},
|
},
|
||||||
|
|
||||||
string text => new()
|
string text => new()
|
||||||
{
|
{
|
||||||
Content = [new() { Text = text, Type = "text" }]
|
Content = [new() { Text = text, Type = "text" }],
|
||||||
|
StructuredContent = structuredContent,
|
||||||
},
|
},
|
||||||
|
|
||||||
Content content => new()
|
Content content => new()
|
||||||
{
|
{
|
||||||
Content = [content]
|
Content = [content],
|
||||||
|
StructuredContent = structuredContent,
|
||||||
},
|
},
|
||||||
|
|
||||||
IEnumerable<string> texts => new()
|
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()
|
IEnumerable<Content> contents => new()
|
||||||
{
|
{
|
||||||
Content = [.. contents]
|
Content = [.. contents],
|
||||||
|
StructuredContent = structuredContent,
|
||||||
},
|
},
|
||||||
|
|
||||||
CallToolResponse callToolResponse => callToolResponse,
|
CallToolResponse callToolResponse => callToolResponse,
|
||||||
|
|
@ -338,12 +351,90 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
{
|
{
|
||||||
Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
|
Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
|
||||||
Type = "text"
|
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 = [];
|
List<Content> contentList = [];
|
||||||
bool allErrorContent = true;
|
bool allErrorContent = true;
|
||||||
|
|
@ -363,6 +454,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Content = contentList,
|
Content = contentList,
|
||||||
|
StructuredContent = structuredContent,
|
||||||
IsError = allErrorContent && hasAny
|
IsError = allErrorContent && hasAny
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,4 +240,13 @@ public sealed class McpServerToolAttribute : Attribute
|
||||||
get => _readOnly ?? ReadOnlyDefault;
|
get => _readOnly ?? ReadOnlyDefault;
|
||||||
set => _readOnly = value;
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
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"/>.
|
/// Gets or sets optional services used in the construction of the <see cref="McpServerTool"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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.
|
/// what services are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IServiceProvider? Services { get; set; }
|
public IServiceProvider? Services { get; set; }
|
||||||
|
|
@ -124,6 +125,15 @@ public sealed class McpServerToolCreateOptions
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool? ReadOnly { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
|
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -154,6 +164,7 @@ public sealed class McpServerToolCreateOptions
|
||||||
Idempotent = Idempotent,
|
Idempotent = Idempotent,
|
||||||
OpenWorld = OpenWorld,
|
OpenWorld = OpenWorld,
|
||||||
ReadOnly = ReadOnly,
|
ReadOnly = ReadOnly,
|
||||||
|
UseStructuredContent = UseStructuredContent,
|
||||||
SerializerOptions = SerializerOptions,
|
SerializerOptions = SerializerOptions,
|
||||||
SchemaCreateOptions = SchemaCreateOptions,
|
SchemaCreateOptions = SchemaCreateOptions,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
<PackageReference Include="Moq" />
|
<PackageReference Include="Moq" />
|
||||||
<PackageReference Include="OpenTelemetry" />
|
<PackageReference Include="OpenTelemetry" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
<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.v3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio">
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.AI;
|
using Json.Schema;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
@ -7,7 +8,10 @@ using ModelContextProtocol.Tests.Utils;
|
||||||
using Moq;
|
using Moq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Server;
|
namespace ModelContextProtocol.Tests.Server;
|
||||||
|
|
||||||
|
|
@ -422,6 +426,94 @@ public partial class McpServerToolTests
|
||||||
Assert.Equal(exceptionMessage, errorLog.Exception.Message);
|
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 sealed class MyService;
|
||||||
|
|
||||||
private class DisposableToolType : IDisposable
|
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)]
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
[JsonSerializable(typeof(DisposableToolType))]
|
[JsonSerializable(typeof(DisposableToolType))]
|
||||||
[JsonSerializable(typeof(AsyncDisposableToolType))]
|
[JsonSerializable(typeof(AsyncDisposableToolType))]
|
||||||
[JsonSerializable(typeof(AsyncDisposableAndDisposableToolType))]
|
[JsonSerializable(typeof(AsyncDisposableAndDisposableToolType))]
|
||||||
|
[JsonSerializable(typeof(JsonSchema))]
|
||||||
partial class JsonContext2 : JsonSerializerContext;
|
partial class JsonContext2 : JsonSerializerContext;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue