Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -183,4 +184,61 @@ public void CreatePropertySchema_WithNullType_Throws()

private static Option<T> CreateOption<T>(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<JsonObject>(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": <inner> }, "required": ["value"] }.
private static void AssertWrappedValue(JsonObject schema, string expectedInnerType)
{
Assert.Equal("object", (string?)schema["type"]);

var properties = Assert.IsType<JsonObject>(schema["properties"]);
Assert.True(properties.ContainsKey("value"), "Non-object result must be wrapped under a single 'value' property.");

var inner = Assert.IsType<JsonObject>(properties["value"]);
Assert.Equal(expectedInnerType, (string?)inner["type"]);

var required = Assert.IsType<JsonArray>(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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<CommandFactoryToolLoader>();
var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions());

var fakeCommand = Substitute.For<IBaseCommand>();
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<string, IBaseCommand>)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<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<CommandFactoryToolLoader>();
var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions());

var fakeCommand = Substitute.For<IBaseCommand>();
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<string, IBaseCommand>)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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,35 @@ public static JsonObject CreateInputSchema(IReadOnlyList<Option> options)

return root;
}

/// <summary>
/// Builds the <c>outputSchema</c> root <see cref="JsonObject"/> for the supplied result type.
/// MCP requires the root schema to be an <c>"object"</c>, so result types that export as a
/// non-object root (arrays or scalars) are wrapped under a single <c>value</c> property. Unlike the
/// input schema, <c>additionalProperties</c> is intentionally left unset for forward compatibility
/// with evolving result shapes.
/// </summary>
public static JsonObject CreateOutputSchema(JsonTypeInfo resultTypeInfo)
{
ArgumentNullException.ThrowIfNull(resultTypeInfo);

var schema = JsonSchemaExporter.GetJsonSchemaAsNode(resultTypeInfo, ExporterOptions);

if (schema is JsonObject rootObject && IsObjectRoot(rootObject))
{
return rootObject;
}

return new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject { ["value"] = schema },
["required"] = new JsonArray { (JsonNode)"value" },
};
}

private static bool IsObjectRoot(JsonObject schema)
=> schema["type"] is JsonValue typeValue
&& typeValue.TryGetValue<string>(out var typeName)
&& typeName == "object";
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,30 @@ public override async ValueTask<CallToolResult> CallToolHandler(RequestContext<C
var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse);
var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous;

return McpHelper.InjectToolIdMetadata(new CallToolResult
var callToolResult = new CallToolResult
{
Content = [
new TextContentBlock {
Text = jsonResponse
}
],
IsError = isError
}, command.Id);
};

// When the command advertises an output schema, set its payload as structuredContent so
// clients can consume it against the schema. Object payloads are used as-is;
// array or scalar payloads are wrapped under a single 'value' property to match the
// wrapping applied by OptionSchemaGenerator.CreateOutputSchema
if (!isError && command.ResultTypeInfo != null)
{
var structuredContent = TryBuildStructuredContent(jsonResponse);
if (structuredContent != null)
{
callToolResult.StructuredContent = structuredContent;
}
}

return McpHelper.InjectToolIdMetadata(callToolResult, command.Id);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -263,6 +278,13 @@ private static Tool GetTool(string fullName, IBaseCommand command)
}
tool.Meta = meta;

var resultTypeInfo = command.ResultTypeInfo;
if (resultTypeInfo != null)
{
var outputSchema = OptionSchemaGenerator.CreateOutputSchema(resultTypeInfo);
tool.OutputSchema = JsonSerializer.SerializeToElement(outputSchema, ServerJsonContext.Default.JsonObject);
}

var options = command.GetCommand().Options
.Where(o => !CommandFactory.IsLearnOption(o))
.ToList();
Expand All @@ -280,6 +302,37 @@ private static Tool GetTool(string fullName, IBaseCommand command)
return tool;
}

/// <summary>
/// Extracts the command result payload from a serialized <see cref="CommandResponse"/> and shapes it
/// into the <c>structuredContent</c> value. Object payloads are used as-is; array or scalar payloads
/// are wrapped under a single <c>value</c> property to match the wrapping applied by
/// <see cref="OptionSchemaGenerator.CreateOutputSchema"/>. Returns <see langword="null"/> when there
/// is no result payload.
/// </summary>
/// <remarks><see langword="internal"/> (rather than <see langword="private"/>) so the payload-shaping
/// contract can be unit tested directly without exercising the full call-tool pipeline.</remarks>
internal static JsonElement? TryBuildStructuredContent(string jsonResponse)
{
using var document = JsonDocument.Parse(jsonResponse);

if (!document.RootElement.TryGetProperty("results", out var results))
{
return null;
}

switch (results.ValueKind)
{
case JsonValueKind.Object:
return results.Clone();
case JsonValueKind.Null:
case JsonValueKind.Undefined:
return null;
default:
var wrapper = new JsonObject { ["value"] = JsonNode.Parse(results.GetRawText()) };
return JsonSerializer.SerializeToElement(wrapper, ServerJsonContext.Default.JsonObject);
}
}

/// <summary>
/// Disposes resources owned by this tool loader.
/// CommandFactoryToolLoader doesn't own external resources that need disposal.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public override async ValueTask<ListToolsResult> ListToolsHandler(RequestContext
{
var exposedTool = string.IsNullOrEmpty(prefix)
? tool
: new Tool { Name = prefix + tool.Name, Description = tool.Description, InputSchema = tool.InputSchema, Annotations = tool.Annotations };
: new Tool { Name = prefix + tool.Name, Description = tool.Description, InputSchema = tool.InputSchema, OutputSchema = tool.OutputSchema, Annotations = tool.Annotations };
allToolsResponse.Tools.Add(exposedTool);
}
}
Expand Down
Loading