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