diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 057831a4e..6184542a0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Client; @@ -913,6 +914,65 @@ public ValueTask CallToolAsync( cancellationToken: cancellationToken); } + /// + /// Invokes a tool on the server and deserializes the result as . + /// + /// The type to deserialize the tool's structured content or text content into. + /// The name of the tool to call on the server. + /// An optional dictionary of arguments to pass to the tool. + /// An optional progress reporter for server notifications. + /// Optional request options including metadata, serialization settings, and progress tracking. + /// The to monitor for cancellation requests. The default is . + /// The deserialized content of the tool result. + /// is . + /// The request failed, the server returned an error response, or is . + /// The result content could not be deserialized as . + /// + /// + /// This method calls the existing + /// and then deserializes the result. If the result has , that is deserialized + /// as . Otherwise, if the result has text content, the text of the first + /// is deserialized as . + /// + /// + /// If is , an is thrown. To inspect + /// error details without an exception, use the non-generic overload instead. + /// + /// + public async ValueTask CallToolAsync( + string toolName, + IReadOnlyDictionary? arguments = null, + IProgress? progress = null, + RequestOptions? options = null, + CancellationToken cancellationToken = default) + { + CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false); + + if (result.IsError is true) + { + string errorMessage = string.Join( + "\n", + result.Content.OfType().Select(c => c.Text)); + throw new McpException(errorMessage.Length > 0 ? errorMessage : "Tool call failed."); + } + + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; + JsonTypeInfo typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T)); + + // Prefer StructuredContent if available, otherwise fall back to text content + if (result.StructuredContent is { } structuredContent) + { + return JsonSerializer.Deserialize(structuredContent, typeInfo); + } + + if (result.Content.OfType().FirstOrDefault() is { } textContent) + { + return JsonSerializer.Deserialize(textContent.Text, typeInfo); + } + + return default; + } + /// /// Invokes a tool on the server as a task for long-running operations. /// diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index e91bdd206..906ee22b1 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -174,6 +174,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe { McpServerToolCreateOptions newOptions = options?.Clone() ?? new(); + Type? outputSchemaType = null; if (method.GetCustomAttribute() is { } toolAttr) { newOptions.Name ??= toolAttr.Name; @@ -204,13 +205,18 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Icons = [new() { Source = iconSource }]; } - newOptions.UseStructuredContent = toolAttr.UseStructuredContent; - if (toolAttr._taskSupport is { } taskSupport) { newOptions.Execution ??= new ToolExecution(); newOptions.Execution.TaskSupport ??= taskSupport; } + + if (toolAttr.UseStructuredContent) + { + newOptions.UseStructuredContent = true; + } + + outputSchemaType = toolAttr.OutputSchemaType; } if (method.GetCustomAttribute() is { } descAttr) @@ -221,6 +227,20 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe // Set metadata if not already provided newOptions.Metadata ??= CreateMetadata(method); + // Generate the output schema from the specified type or the return type. + // Priority: OutputSchemaType (explicit) > UseStructuredContent (infer from return type). + if (outputSchemaType is null && newOptions.UseStructuredContent) + { + outputSchemaType = method.ReturnType; + } + + if (outputSchemaType is not null) + { + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, + serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + inferenceOptions: newOptions.SchemaCreateOptions); + } + return newOptions; } @@ -360,6 +380,7 @@ private static bool IsAsyncMethod(MethodInfo method) return false; } + /// Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item. internal static IReadOnlyList CreateMetadata(MethodInfo method) { @@ -431,16 +452,16 @@ private static void ValidateToolName(string name) /// Gets the tool description, synthesizing from both the function description and return description when appropriate. /// /// - /// When UseStructuredContent is true, the return description is included in the output schema. - /// When UseStructuredContent is false (default), if there's a return description in the ReturnJsonSchema, + /// When an output schema is present, the return description is included in the output schema. + /// When no output schema is present (default), if there's a return description in the ReturnJsonSchema, /// it will be appended to the tool description so the information is still available to consumers. /// private static string? GetToolDescription(AIFunction function, McpServerToolCreateOptions? options) { string? description = options?.Description ?? function.Description; - // If structured content is enabled, the return description will be in the output schema - if (options?.UseStructuredContent is true) + // If structured content is enabled (output schema present), the return description will be in the output schema + if (options?.OutputSchema is not null) { return description; } @@ -482,12 +503,7 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; - if (toolCreateOptions?.UseStructuredContent is not true) - { - return null; - } - - if (function.ReturnJsonSchema is not JsonElement outputSchema) + if (toolCreateOptions?.OutputSchema is not JsonElement outputSchema) { return null; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index e2a9a34e0..81ceff2c0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -143,7 +143,9 @@ namespace ModelContextProtocol.Server; /// /// /// Other types -/// Serialized to JSON and returned as a single object with set to "text". +/// Serialized to JSON and returned as a single object with set to "text". +/// When is enabled, the serialized value is also set as +/// and used to generate the . /// /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 21a227e8f..7bdecc4a8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -139,7 +139,9 @@ namespace ModelContextProtocol.Server; /// /// /// Other types -/// Serialized to JSON and returned as a single object with set to "text". +/// Serialized to JSON and returned as a single object with set to "text". +/// When is enabled, the serialized value is also set as +/// and used to generate the . /// /// /// @@ -260,11 +262,42 @@ public bool ReadOnly /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// Setting will automatically enable structured content. + /// /// public bool UseStructuredContent { get; set; } + /// + /// Gets or sets the type to use for generating the tool's . + /// + /// + /// The default is . + /// + /// + /// + /// When set, the specified type is used to generate a JSON Schema that is advertised as the tool's + /// . This is particularly useful when the method returns + /// directly (for example, to control ), + /// but the tool should still advertise a meaningful output schema describing the shape of + /// . + /// + /// + /// Setting this property automatically enables structured content behavior, equivalent to + /// setting to . + /// + /// + /// For Native AOT scenarios, use to provide + /// a pre-generated JSON Schema instead. + /// + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public Type? OutputSchemaType { get; set; } + /// /// Gets or sets the source URI for the tool's icon. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 3bf0c5305..ce402f381 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -129,6 +129,28 @@ public sealed class McpServerToolCreateOptions /// public bool UseStructuredContent { get; set; } + /// + /// Gets or sets a JSON Schema object to use as the tool's output schema. + /// + /// + /// + /// When set, this schema is used directly as the instead of + /// inferring it from the method's return type. This is particularly useful when the method + /// returns directly (for example, to control + /// ), but the tool should still advertise a meaningful + /// output schema describing the shape of . + /// + /// + /// Setting this property to a non- value will enable structured content + /// for the tool, causing the tool to populate both and + /// . + /// + /// + /// The schema must be a valid JSON Schema object with the "type" property set to "object". + /// + /// + public JsonElement? OutputSchema { get; set; } + /// /// Gets or sets the JSON serializer options to use when marshalling data to/from JSON. /// @@ -209,6 +231,7 @@ internal McpServerToolCreateOptions Clone() => OpenWorld = OpenWorld, ReadOnly = ReadOnly, UseStructuredContent = UseStructuredContent, + OutputSchema = OutputSchema, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index fd62a05c7..d6567523c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -462,7 +462,7 @@ public async Task SupportsSchemaCreateOptions() 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 }); + McpServerTool tool = McpServerTool.Create([McpServerTool(UseStructuredContent = true)] () => value, new() { Name = "tool", SerializerOptions = options }); var mockServer = new Mock(); var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) { @@ -520,7 +520,7 @@ public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSch public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) { JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - McpServerTool tool = McpServerTool.Create(() => value, new() { UseStructuredContent = false, SerializerOptions = options }); + McpServerTool tool = McpServerTool.Create(() => value, new() { SerializerOptions = options }); var mockServer = new Mock(); var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) { @@ -533,6 +533,178 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) Assert.Null(result.StructuredContent); } + [Fact] + public void OutputSchema_ViaOptions_SetsSchemaDirectly() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}}}"""); + McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_ViaOptions_EnablesStructuredContent() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"n":{"type":"string"},"a":{"type":"integer"}},"required":["n","a"]}"""); + McpServerTool tool = McpServerTool.Create((string input) => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() + { + using var overrideDoc = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}}}"""); + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => new Person("Alice", 30), new() + { + OutputSchema = overrideDoc.RootElement, + SerializerOptions = serOpts, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(overrideDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchemaType_ProducesExpectedOutputSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(OutputSchemaType = typeof(Person))] () => new Person("Alice", 30), + new() { Name = "tool", SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("Name", out _)); + Assert.True(props.TryGetProperty("Age", out _)); + } + + [Fact] + public async Task OutputSchemaType_SerializesStructuredContent() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(OutputSchemaType = typeof(Person))] () => new Person("Alice", 30), + new() { Name = "tool", SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + + // Content should be serialized as text content block + Assert.Single(toolResult.Content); + var textBlock = Assert.IsType(toolResult.Content[0]); + Assert.Contains("Alice", textBlock.Text); + Assert.Contains("30", textBlock.Text); + + // StructuredContent should be populated + Assert.NotNull(toolResult.StructuredContent); + } + + [Fact] + public async Task OutputSchemaType_WithCallToolResult_ErrorHasMeaningfulMessage() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult + { + Content = [new TextContentBlock { Text = "Person not found" }], + IsError = true, + }, + new() { Name = "tool", SerializerOptions = serOpts }); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.True(toolResult.IsError); + Assert.Single(toolResult.Content); + Assert.Equal("Person not found", Assert.IsType(toolResult.Content[0]).Text); + } + + [Fact] + public void OutputSchemaType_ExplicitOutputSchemaOverrides() + { + using var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}"""); + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(OutputSchemaType = typeof(Person))] () => new Person("Alice", 30), + new() { Name = "tool", OutputSchema = customDoc.RootElement, SerializerOptions = serOpts }); + + // Explicit OutputSchema from options should override the inferred one from OutputSchemaType + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(customDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public async Task OutputSchemaType_AsyncMethod_ProducesExpectedSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(OutputSchemaType = typeof(Person))] async () => + { + await Task.CompletedTask; + return new Person("Charlie", 35); + }, + new() { Name = "tool", SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("Name", out _)); + Assert.True(props.TryGetProperty("Age", out _)); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.NotNull(toolResult.StructuredContent); + Assert.Single(toolResult.Content); + } + + [Fact] + public void OutputSchema_IsPreservedWhenCopyingOptions() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}"""); + + // Verify OutputSchema works correctly when used via tool creation (which clones internally) + McpServerTool tool1 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + McpServerTool tool2 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool1.ProtocolTool.OutputSchema); + Assert.NotNull(tool2.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(tool1.ProtocolTool.OutputSchema.Value, tool2.ProtocolTool.OutputSchema.Value)); + } + + [Theory] [InlineData(JsonNumberHandling.Strict)] [InlineData(JsonNumberHandling.AllowReadingFromString)] @@ -758,15 +930,14 @@ public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription public void ReturnDescription_StructuredOutputEnabled_NotIncludedInToolDescription() { // When UseStructuredContent is true, return description should be in the output schema, not in tool description - McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { UseStructuredContent = true }); + McpServerTool tool = McpServerTool.Create( + [McpServerTool(UseStructuredContent = true)] + [Description("Tool that returns data.")] + [return: Description("The computed result")] + static () => "result"); Assert.Equal("Tool that returns data.", tool.ProtocolTool.Description); Assert.NotNull(tool.ProtocolTool.OutputSchema); - // Verify the output schema contains the description - Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("result", out var result)); - Assert.True(result.TryGetProperty("description", out var description)); - Assert.Equal("The computed result", description.GetString()); } [Fact] @@ -804,11 +975,11 @@ public void ReturnDescription_NoReturnDescription_NoChange() public void ReturnDescription_StructuredOutputEnabled_WithExplicitDescription_NoSynthesis() { // When UseStructuredContent is true and Description is set, return description goes to output schema - McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() - { - Description = "Custom description", - UseStructuredContent = true - }); + McpServerTool tool = McpServerTool.Create( + [McpServerTool(UseStructuredContent = true)] + [return: Description("The computed result")] + static () => "result", + new() { Description = "Custom description" }); // Description should not have the return description appended Assert.Equal("Custom description", tool.ProtocolTool.Description);