diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/OptionSchemaGeneratorTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/OptionSchemaGeneratorTests.cs index fcad84fdf5..a8cd03fe22 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/OptionSchemaGeneratorTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/OptionSchemaGeneratorTests.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Microsoft.Mcp.Core.Areas.Server.Commands; using Xunit; @@ -183,4 +184,61 @@ public void CreatePropertySchema_WithNullType_Throws() private static Option CreateOption(string name, string description = "desc", bool required = false) => new(name) { Description = description, Required = required }; + + [Fact] + public void CreateOutputSchema_ObjectResult_ReturnsObjectRootUnwrapped() + { + var schema = OptionSchemaGenerator.CreateOutputSchema(OutputSchemaTestJsonContext.Default.OutputSchemaSampleResult); + + // An object-root result already satisfies MCP's "root must be an object" rule, so it is returned + // as-is: its own properties are exposed directly rather than nested under a wrapper. + Assert.Equal("object", (string?)schema["type"]); + + var properties = Assert.IsType(schema["properties"]); + Assert.True(properties.ContainsKey("name"), "Object-root result should expose its own 'name' property directly."); + Assert.False(properties.ContainsKey("value"), "Object-root result must not be wrapped under a 'value' property."); + } + + [Fact] + public void CreateOutputSchema_ArrayResult_IsWrappedUnderValue() + { + var schema = OptionSchemaGenerator.CreateOutputSchema(OutputSchemaTestJsonContext.Default.StringArray); + + AssertWrappedValue(schema, expectedInnerType: "array"); + } + + [Fact] + public void CreateOutputSchema_ScalarResult_IsWrappedUnderValue() + { + var schema = OptionSchemaGenerator.CreateOutputSchema(OutputSchemaTestJsonContext.Default.Int32); + + AssertWrappedValue(schema, expectedInnerType: "integer"); + } + + // MCP requires the outputSchema root to be an object, so a non-object export (array or scalar) must be + // wrapped as { "type": "object", "properties": { "value": }, "required": ["value"] }. + private static void AssertWrappedValue(JsonObject schema, string expectedInnerType) + { + Assert.Equal("object", (string?)schema["type"]); + + var properties = Assert.IsType(schema["properties"]); + Assert.True(properties.ContainsKey("value"), "Non-object result must be wrapped under a single 'value' property."); + + var inner = Assert.IsType(properties["value"]); + Assert.Equal(expectedInnerType, (string?)inner["type"]); + + var required = Assert.IsType(schema["required"]); + Assert.Contains(required, node => (string?)node == "value"); + } } + +// Shared sample result type + source-generated context used by the outputSchema tests. Declaring them in +// the parent test namespace keeps the schema-shaping contract decoupled from any shipping tool while +// remaining visible to the nested ToolLoading tests. +internal sealed record OutputSchemaSampleResult(string Name, int Count); + +[JsonSerializable(typeof(OutputSchemaSampleResult))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(int))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class OutputSchemaTestJsonContext : JsonSerializerContext; diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index 28d6f5d49a..86098735da 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -9,6 +9,7 @@ using Microsoft.Mcp.Core.Areas.Server.Commands.ToolLoading; using Microsoft.Mcp.Core.Commands; using Microsoft.Mcp.Core.Helpers; +using Microsoft.Mcp.Core.Models; using Microsoft.Mcp.Core.Models.Command; using Microsoft.Mcp.Core.Options; using ModelContextProtocol.Protocol; @@ -680,6 +681,173 @@ static bool IsNullType(JsonElement type) => } } + [Fact] + public async Task ListToolsHandler_CommandWithResultTypeInfo_EmitsObjectOutputSchema() + { + // Arrange + // A fake command that advertises a source-generated result type. GetTool should surface that type as + // the tool's outputSchema. + var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + var fakeCommand = Substitute.For(); + fakeCommand.GetCommand().Returns(new Command("fake-output-get", "A fake command that advertises a result type.")); + fakeCommand.Title.Returns("Fake Output Get"); + fakeCommand.Metadata.Returns(new ToolMetadata()); + fakeCommand.ResultTypeInfo.Returns(OutputSchemaTestJsonContext.Default.OutputSchemaSampleResult); + + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-output-get"] = fakeCommand; + + var toolLoader = new CommandFactoryToolLoader(serviceProvider, commandFactory, toolLoaderOptions, logger); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + // A migrated command (where ResultTypeInfo != null) must surface an MCP outputSchema whose root is an + // object exposing the result record's own properties. + var tool = result.Tools.FirstOrDefault(t => t.Name == "fake-output-get"); + Assert.NotNull(tool); + Assert.NotNull(tool.OutputSchema); + + var outputSchema = tool.OutputSchema!.Value; + Assert.Equal(JsonValueKind.Object, outputSchema.ValueKind); + + Assert.True(outputSchema.TryGetProperty("type", out var typeProperty), "outputSchema is missing 'type'."); + Assert.Equal("object", typeProperty.GetString()); + + Assert.True(outputSchema.TryGetProperty("properties", out var properties), "outputSchema is missing 'properties'."); + Assert.True(properties.TryGetProperty("name", out _), "outputSchema should expose the result record's 'name' property."); + } + + [Fact] + public async Task ListToolsHandler_CommandWithoutResultTypeInfo_OmitsOutputSchema() + { + // Arrange + // A fake command that does not advertise a result type (ResultTypeInfo == null, the default value). + // GetTool should leave the tool's outputSchema unset so the command gracefully advertises + // no structured output. + var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); + + var fakeCommand = Substitute.For(); + fakeCommand.GetCommand().Returns(new Command("fake-noschema-get", "A fake command with no result type.")); + fakeCommand.Title.Returns("Fake No Schema Get"); + fakeCommand.Metadata.Returns(new ToolMetadata()); + + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var commandMapField = typeof(CommandFactory).GetField("_commandMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var commandMap = (Dictionary)commandMapField!.GetValue(commandFactory)!; + commandMap["fake-noschema-get"] = fakeCommand; + + var toolLoader = new CommandFactoryToolLoader(serviceProvider, commandFactory, toolLoaderOptions, logger); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + var tool = result.Tools.FirstOrDefault(t => t.Name == "fake-noschema-get"); + Assert.NotNull(tool); + Assert.Null(tool.OutputSchema); + } + + [Fact] + public void TryBuildStructuredContent_ObjectResult_IsUnwrapped() + { + // An object-root command result already satisfies MCP's "structuredContent must be an object" rule, + // so it included as-is: its own properties are surfaced directly rather than nested under a + // wrapper. This stays aligned with CreateOutputSchema, which leaves object roots unwrapped. + var json = SerializeResponse(ResponseResult.Create( + new OutputSchemaSampleResult("alpha", 3), + OutputSchemaTestJsonContext.Default.OutputSchemaSampleResult)); + + var structuredContent = CommandFactoryToolLoader.TryBuildStructuredContent(json); + + Assert.NotNull(structuredContent); + var value = structuredContent!.Value; + Assert.Equal(JsonValueKind.Object, value.ValueKind); + Assert.Equal("alpha", value.GetProperty("name").GetString()); + Assert.Equal(3, value.GetProperty("count").GetInt32()); + Assert.False(value.TryGetProperty("value", out _), "Object results must not be wrapped under a 'value' property."); + } + + [Fact] + public void TryBuildStructuredContent_ArrayResult_IsWrappedUnderValue() + { + // A non-object payload (array) cannot be the structuredContent root, so it is wrapped under a single + // 'value' property - mirroring the wrapping CreateOutputSchema applies to the advertised schema so the + // payload validates against it. + var json = SerializeResponse(ResponseResult.Create( + new[] { "one", "two" }, + OutputSchemaTestJsonContext.Default.StringArray)); + + var structuredContent = CommandFactoryToolLoader.TryBuildStructuredContent(json); + + Assert.NotNull(structuredContent); + var value = structuredContent!.Value; + Assert.Equal(JsonValueKind.Object, value.ValueKind); + Assert.True(value.TryGetProperty("value", out var wrapped), "Array results must be wrapped under a 'value' property."); + Assert.Equal(JsonValueKind.Array, wrapped.ValueKind); + Assert.Equal(2, wrapped.GetArrayLength()); + } + + [Fact] + public void TryBuildStructuredContent_ScalarResult_IsWrappedUnderValue() + { + // A scalar payload likewise cannot be the root object, so it is wrapped under 'value' to match the + // advertised schema. + var json = SerializeResponse(ResponseResult.Create( + 42, + OutputSchemaTestJsonContext.Default.Int32)); + + var structuredContent = CommandFactoryToolLoader.TryBuildStructuredContent(json); + + Assert.NotNull(structuredContent); + var value = structuredContent!.Value; + Assert.Equal(JsonValueKind.Object, value.ValueKind); + Assert.True(value.TryGetProperty("value", out var wrapped), "Scalar results must be wrapped under a 'value' property."); + Assert.Equal(42, wrapped.GetInt32()); + } + + [Fact] + public void TryBuildStructuredContent_NoResult_ReturnsNull() + { + // A command that sets no result serializes without a 'results' property (JsonIgnore-when-null), so + // there is nothing to mirror and structuredContent is left unset. + var json = SerializeResponse(results: null); + + var structuredContent = CommandFactoryToolLoader.TryBuildStructuredContent(json); + + Assert.Null(structuredContent); + } + + [Fact] + public void TryBuildStructuredContent_NullResultsProperty_ReturnsNull() + { + // Even if a 'results' property is present but null, there is no payload to mirror. + // The realistic serializer path omits null results, so this uses a hand-built response to exercise it. + var structuredContent = CommandFactoryToolLoader.TryBuildStructuredContent("{\"results\":null}"); + + Assert.Null(structuredContent); + } + + // Serializes a CommandResponse exactly as CallToolHandler does, so the structuredContent tests exercise + // the real 'results' property name and shape rather than a hand-rolled approximation. + private static string SerializeResponse(ResponseResult? results) + { + var response = new CommandResponse { Status = HttpStatusCode.OK, Results = results }; + return JsonSerializer.Serialize(response, ModelsJsonContext.Default.CommandResponse); + } + // A self-contained enum + options POCO used only by ListToolsHandler_EnumOption_IsExportedAsStringType. // Declaring them here keeps the enum-to-schema contract test independent of any shipping tool. private enum SchemaSampleLevel diff --git a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/OptionSchemaGenerator.cs b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/OptionSchemaGenerator.cs index 76a7e5d075..d06ab63113 100644 --- a/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/OptionSchemaGenerator.cs +++ b/core/Microsoft.Mcp.Core/src/Areas/Server/Commands/OptionSchemaGenerator.cs @@ -112,4 +112,35 @@ public static JsonObject CreateInputSchema(IReadOnlyList