Skip to content
Merged
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 @@ -557,16 +557,78 @@ protected override ISyntaxVisitorAction Leave(
propertyJsonSchemaBuilder.Description(pendingField.Field.Description);
}

properties.Add(pendingField.ResponseName, propertyJsonSchemaBuilder.Build());
var propertySchema = propertyJsonSchemaBuilder.Build();

if (pendingField.SelectionState is SelectionState.Included)
// Multiple selections can share one response name (duplicate fields, or fields
// selected in different fragments), so merge on collision.
if (!properties.TryAdd(pendingField.ResponseName, propertySchema))
{
properties[pendingField.ResponseName] =
MergeProperties(properties[pendingField.ResponseName], propertySchema);
}

if (pendingField.SelectionState is SelectionState.Included
&& !requiredProperties.Contains(pendingField.ResponseName))
{
requiredProperties.Add(pendingField.ResponseName);
}

return Continue;
}

/// <summary>
/// Merges two JSON schemas built for the same response name into one. Object schemas
/// merge to the union of their properties, with only the properties required by both
/// sides staying required; array schemas merge their item schemas the same way; all
/// other keywords are taken from <paramref name="first"/>.
/// </summary>
private static JsonSchema MergeProperties(JsonSchema first, JsonSchema second)
Comment thread
glen-84 marked this conversation as resolved.
{
var builder = new JsonSchemaBuilder();
var mergeProperties =
first.GetProperties() is not null && second.GetProperties() is not null;
var mergeItems = first.GetItems() is not null && second.GetItems() is not null;

foreach (var keyword in first.Keywords ?? [])
{
switch (keyword)
{
case PropertiesKeyword or RequiredKeyword when mergeProperties:
case ItemsKeyword when mergeItems:
continue;

default:
builder.Add(keyword);
break;
}
}

if (mergeProperties)
{
var properties = new Dictionary<string, JsonSchema>(first.GetProperties()!);

foreach (var (propertyName, propertySchema) in second.GetProperties()!)
{
properties[propertyName] =
properties.TryGetValue(propertyName, out var existingSchema)
? MergeProperties(existingSchema, propertySchema)
: propertySchema;
}

builder.Properties(properties);
builder.Required(
(first.GetRequired() ?? [])
.Intersect(second.GetRequired() ?? [], StringComparer.Ordinal));
}

if (mergeItems)
{
builder.Items(MergeProperties(first.GetItems()!, second.GetItems()!));
}

return builder.Build();
}

protected override ISyntaxVisitorAction Enter(
FragmentSpreadNode fragmentSpreadNode,
WalkerContext context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,157 @@ ... on Dog {
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_InlineFragmentsOnDifferentTypesSelectSameField_MergesIntoSingleProperty()
{
// arrange
var schema = CreateVehicleSchema();
var document = Utf8GraphQLParser.Parse(
"""
query GetVehicles {
vehicles {
id
... on Car {
engine {
power
}
}
... on Truck {
engine {
power
}
}
}
}
""");
var toolDefinition = new OperationToolDefinition(document);

// act
var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition);

// assert
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_InlineFragmentsWithDivergingSubSelections_UnionsSubSelections()
{
// arrange
var schema = CreateVehicleSchema();
var document = Utf8GraphQLParser.Parse(
"""
query GetVehicles {
vehicles {
id
... on Car {
engine {
power
}
}
... on Truck {
engine {
power
fuelKind
}
}
}
}
""");
var toolDefinition = new OperationToolDefinition(document);

// act
var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition);

// assert
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_InlineFragmentsWithDivergingListSubSelections_UnionsItemSubSelections()
{
// arrange
var schema = CreateVehicleSchema();
var document = Utf8GraphQLParser.Parse(
"""
query GetVehicles {
vehicles {
... on Car {
wheels {
size
}
}
... on Truck {
wheels {
size
treadDepth
}
}
}
}
""");
var toolDefinition = new OperationToolDefinition(document);

// act
var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition);

// assert
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_FragmentSpreadsSelectSameField_MergesIntoSingleProperty()
{
// arrange
var schema = CreateSchema();
var document = Utf8GraphQLParser.Parse(
"""
query GetWithInterfaceType {
withInterfaceType {
...CatFields
...DogFields
}
}

fragment CatFields on Cat {
name
}

fragment DogFields on Dog {
name
}
""");
var toolDefinition = new OperationToolDefinition(document);

// act
var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition);

// assert
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_DuplicateFieldSelection_MergesIntoSingleProperty()
{
// arrange
var schema = CreateSchema();
var document = Utf8GraphQLParser.Parse(
"""
query GetBooks {
books {
title
title
}
}
""");
var toolDefinition = new OperationToolDefinition(document);

// act
var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition);

// assert
tool.Tool.OutputSchema.MatchSnapshot(extension: ".json");
}

[Fact]
public void CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema()
{
Expand Down Expand Up @@ -834,6 +985,19 @@ private static Schema CreateRecursiveFilterSchema()
.Create();
}

private static Schema CreateVehicleSchema()
{
return SchemaBuilder
.New()
.AddMcp()
.ModifyOptions(o => o.StripLeadingIFromInterface = true)
.AddQueryType<VehicleQuery>()
.AddInterfaceType<IVehicle>()
.AddObjectType<Car>()
.AddObjectType<Truck>()
.Create();
}

public sealed class RecursiveFilterQuery
{
public int GetWithRecursiveFilter(RecursiveFilter? filter) => filter is null ? 0 : 1;
Expand All @@ -847,4 +1011,22 @@ public sealed class RecursiveFilter

public string? Name { get; set; }
}

public sealed class VehicleQuery
{
public IVehicle[] GetVehicles() => [];
}

public interface IVehicle
{
string Id { get; }
}

public sealed record Car(string Id, Engine? Engine, Wheel[]? Wheels) : IVehicle;

public sealed record Truck(string Id, Engine? Engine, Wheel[]? Wheels) : IVehicle;

public sealed record Engine(int Power, string? FuelKind);

public sealed record Wheel(int Size, double? TreadDepth);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"type": "object",
"properties": {
"data": {
"type": [
"object",
"null"
],
"properties": {
"books": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
}
},
"required": [
"title"
],
"additionalProperties": false
}
}
},
"additionalProperties": false,
"required": [
"books"
]
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"locations": {
"type": [
"array",
"null"
],
"items": {
"type": "object",
"properties": {
"line": {
"type": "integer"
},
"column": {
"type": "integer"
}
},
"additionalProperties": false
}
},
"path": {
"type": [
"array",
"null"
],
"items": {
"type": [
"string",
"integer"
]
}
},
"extensions": {
"type": [
"object",
"null"
],
"additionalProperties": true
}
},
"required": [
"message"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
Loading
Loading