diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Abstractions/Configuration/McpToolOptions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Abstractions/Configuration/McpToolOptions.cs new file mode 100644 index 00000000000..70147bdc2b7 --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Abstractions/Configuration/McpToolOptions.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.Adapters.Mcp.Configuration; + +/// +/// Holds the per-schema options that control how MCP tools are generated from operations. +/// +public sealed class McpToolOptions +{ + /// + /// Gets or sets a value indicating whether named input object types are emitted in a tool's + /// input schema as JSON Schema definitions ($defs) referenced through $ref. + /// + /// When true (the default), each named input object type is emitted once under + /// $defs and referenced by $ref. This is the only way to represent recursive + /// input types (such as filter inputs that reference themselves through and/or) + /// in a finite schema. + /// + /// + /// When false, input object types are inlined. This suits MCP clients that do not + /// support JSON Schema references. Where a type refers to itself, that point is collapsed to + /// a generic object ({ "type": "object" }) and the rest of the schema keeps its full + /// structure. + /// + /// + public bool UseJsonSchemaReferences { get; set; } = true; +} diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/InputValueDefinitionExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/InputValueDefinitionExtensions.cs index cdf48b208ac..2fb34226f4e 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/InputValueDefinitionExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/InputValueDefinitionExtensions.cs @@ -5,7 +5,9 @@ namespace HotChocolate.Adapters.Mcp.Extensions; internal static class InputValueDefinitionExtensions { - public static JsonSchema ToJsonSchema(this IInputValueDefinition inputField) + public static JsonSchema ToJsonSchema( + this IInputValueDefinition inputField, + JsonSchemaContext? context = null) { var type = inputField.Type; var schemaBuilder = @@ -13,7 +15,8 @@ public static JsonSchema ToJsonSchema(this IInputValueDefinition inputField) isOneOf: inputField.DeclaringMember is IInputObjectTypeDefinition { IsOneOf: true - }); + }, + context); // Description. if (inputField.Description is not null) diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/JsonSchemaContext.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/JsonSchemaContext.cs new file mode 100644 index 00000000000..54b108195bd --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/JsonSchemaContext.cs @@ -0,0 +1,29 @@ +using Json.Schema; + +namespace HotChocolate.Adapters.Mcp.Extensions; + +/// +/// Carries the state shared across a single JSON schema generation walk: the accumulated +/// definitions, the set of input object types currently being expanded (for cycle detection), +/// and whether references are used. +/// +internal sealed class JsonSchemaContext +{ + /// + /// Gets the named input object type definitions discovered during the walk, keyed by type + /// name. These are emitted at the root of the schema under $defs. + /// + public Dictionary Defs { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the names of the input object types currently being expanded on the active path. + /// Used to detect cycles so the walk terminates on self-referencing types. + /// + public HashSet Building { get; } = new(StringComparer.Ordinal); + + /// + /// Gets a value indicating whether named input object types are emitted as $defs + /// referenced through $ref (true) or inlined (false). + /// + public bool UseReferences { get; init; } = true; +} diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs index 417e80d075e..3a4d26917c5 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs @@ -58,10 +58,25 @@ public static void AddMcpSchemaServices( var mcpManager = applicationServices.GetRequiredService(); var setup = mcpManager.GetSetup(schemaName); - services - .AddLogging() - .TryAddSingleton( - static sp => new OperationToolFactory(sp.GetRequiredService())); + services.AddLogging(); + + services.TryAddSingleton( + static sp => + { + var toolOptions = new McpToolOptions(); + + foreach (var configure in sp.GetServices>()) + { + configure(toolOptions); + } + + return toolOptions; + }); + + services.TryAddSingleton( + static sp => new OperationToolFactory( + sp.GetRequiredService(), + sp.GetRequiredService())); services.TryAddSingleton(sp => { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/TypeExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/TypeExtensions.cs index 481a1e3e219..edda51e62ee 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/TypeExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/TypeExtensions.cs @@ -8,14 +8,30 @@ namespace HotChocolate.Adapters.Mcp.Extensions; internal static class TypeExtensions { - public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneOf = false) + public static JsonSchemaBuilder ToJsonSchemaBuilder( + this IType type, + bool isOneOf = false, + JsonSchemaContext? context = null) { + var namedType = type.NullableType(); + var isNullable = !type.IsNonNullType() && !isOneOf; + + // Named input object types are emitted once under $defs and referenced through $ref. + // This is the only way to represent a self-referencing input type (such as a filter + // input) in a finite schema. + if (context is { UseReferences: true } + && namedType is IInputObjectTypeDefinition inputObjectReference) + { + RegisterInputObjectDef(inputObjectReference, context); + return RefSchema(inputObjectReference.Name, isNullable); + } + var schemaBuilder = new JsonSchemaBuilder(); // Type. var jsonType = type.GetJsonSchemaValueType(); - if (!type.IsNonNullType() && !isOneOf) + if (isNullable) { // Nullability. jsonType |= SchemaValueType.Null; @@ -47,7 +63,7 @@ public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneO schemaBuilder.Pattern(pattern); } - switch (type.NullableType()) + switch (namedType) { case IEnumTypeDefinition enumType: // Enum values. @@ -67,58 +83,109 @@ public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneO break; case IInputObjectTypeDefinition inputObjectType: - // Object properties. - var objectProperties = new Dictionary(); - var requiredObjectProperties = new List(); - - foreach (var field in inputObjectType.Fields) + // References are disabled, so the type is inlined. The cycle guard keeps the walk + // finite by collapsing a self-reference to the generic object schema already set. + // A false result means the type is already being expanded on the current path. + if (context?.Building.Add(inputObjectType.Name) == false) { - var fieldSchema = field.ToJsonSchema(); - - objectProperties.Add(field.Name, fieldSchema); - - if (field.Type.IsNonNullType() && field.DefaultValue is null) - { - requiredObjectProperties.Add(field.Name); - } + break; } - // OneOf. - if (inputObjectType.IsOneOf) - { - List oneOfSchemas = []; - - foreach (var (propertyName, propertySchema) in objectProperties) - { - var oneOfSchema = new JsonSchemaBuilder(); - - oneOfSchema - .Type(SchemaValueType.Object) - .Properties((propertyName, propertySchema)) - .Required(propertyName); - - oneOfSchemas.Add(oneOfSchema.Build()); - } - - schemaBuilder.OneOf(oneOfSchemas); - } - else - { - schemaBuilder.Properties(objectProperties); - schemaBuilder.Required(requiredObjectProperties); - } + PopulateInputObjectMembers(schemaBuilder, inputObjectType, context); + context?.Building.Remove(inputObjectType.Name); break; case ListType listType: // Array items. - schemaBuilder.Items(listType.ElementType().ToJsonSchemaBuilder()); + schemaBuilder.Items(listType.ElementType().ToJsonSchemaBuilder(context: context)); break; } return schemaBuilder; } + private static void RegisterInputObjectDef( + IInputObjectTypeDefinition inputObjectType, + JsonSchemaContext context) + { + var name = inputObjectType.Name; + + // The definition name is reserved before its members are expanded so that a field + // referencing the same type resolves to a $ref instead of recursing. + if (context.Defs.ContainsKey(name) || !context.Building.Add(name)) + { + return; + } + + var defBuilder = new JsonSchemaBuilder().Type(SchemaValueType.Object); + PopulateInputObjectMembers(defBuilder, inputObjectType, context); + + context.Defs[name] = defBuilder; + context.Building.Remove(name); + } + + private static JsonSchemaBuilder RefSchema(string typeName, bool isNullable) + { + var reference = new JsonSchemaBuilder().Ref("#/$defs/" + typeName); + + if (!isNullable) + { + return reference; + } + + // A bare $ref cannot also permit null, so a nullable reference is expressed as an anyOf. + return new JsonSchemaBuilder() + .AnyOf(reference, new JsonSchemaBuilder().Type(SchemaValueType.Null)); + } + + private static void PopulateInputObjectMembers( + JsonSchemaBuilder schemaBuilder, + IInputObjectTypeDefinition inputObjectType, + JsonSchemaContext? context) + { + // Object properties. + var objectProperties = new Dictionary(); + var requiredObjectProperties = new List(); + + foreach (var field in inputObjectType.Fields) + { + var fieldSchema = field.ToJsonSchema(context); + + objectProperties.Add(field.Name, fieldSchema); + + if (field.Type.IsNonNullType() && field.DefaultValue is null) + { + requiredObjectProperties.Add(field.Name); + } + } + + // OneOf. + if (inputObjectType.IsOneOf) + { + List oneOfSchemas = []; + + foreach (var (propertyName, propertySchema) in objectProperties) + { + var oneOfSchema = new JsonSchemaBuilder(); + + oneOfSchema + .Type(SchemaValueType.Object) + .Properties((propertyName, propertySchema)) + .Required(propertyName); + + oneOfSchemas.Add(oneOfSchema.Build()); + } + + schemaBuilder.OneOf(oneOfSchemas); + } + else + { + schemaBuilder.Properties(objectProperties); + schemaBuilder.Required(requiredObjectProperties); + } + } + private static SchemaValueType GetJsonSchemaValueType(this IType type) { return type switch diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/OperationToolFactory.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/OperationToolFactory.cs index bd3ff587796..ac5b5e541ae 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/OperationToolFactory.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/OperationToolFactory.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Text; using System.Text.Json.Nodes; +using HotChocolate.Adapters.Mcp.Configuration; using HotChocolate.Adapters.Mcp.Extensions; using HotChocolate.Adapters.Mcp.Storage; using HotChocolate.Language; @@ -13,7 +14,7 @@ namespace HotChocolate.Adapters.Mcp; -internal sealed class OperationToolFactory(ISchemaDefinition schema) +internal sealed class OperationToolFactory(ISchemaDefinition schema, McpToolOptions options) { private static readonly Walker s_walker = new(); @@ -137,11 +138,12 @@ private JsonSchema CreateInputSchema(OperationDefinitionNode operation) { var properties = new Dictionary(); var requiredProperties = new List(); + var context = new JsonSchemaContext { UseReferences = options.UseJsonSchemaReferences }; foreach (var variableNode in operation.VariableDefinitions) { var type = variableNode.Type.ToType(schema); - var propertyBuilder = type.ToJsonSchemaBuilder(); + var propertyBuilder = type.ToJsonSchemaBuilder(context: context); var variableName = variableNode.Variable.Name.Value; // Description. @@ -165,12 +167,22 @@ private JsonSchema CreateInputSchema(OperationDefinitionNode operation) properties.Add(variableName, propertyBuilder); } - return + var schemaBuilder = new JsonSchemaBuilder() .Type(SchemaValueType.Object) .Properties(properties) - .Required(requiredProperties) - .Build(); + .Required(requiredProperties); + + // Definitions, emitted in a stable order so the schema is deterministic. + if (context.Defs.Count > 0) + { + schemaBuilder.Defs( + context.Defs + .OrderBy(definition => definition.Key, StringComparer.Ordinal) + .ToDictionary()); + } + + return schemaBuilder.Build(); } private static JsonSchema CreateOutputSchema(JsonSchema dataSchema) diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp/Extensions/McpRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp/Extensions/McpRequestExecutorBuilderExtensions.cs index f2a7f050a9d..ab143704b8d 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp/Extensions/McpRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp/Extensions/McpRequestExecutorBuilderExtensions.cs @@ -42,6 +42,36 @@ public static IRequestExecutorBuilder AddMcp( return builder; } + /// + /// Modifies the options that control how MCP tools are generated from operations. + /// + /// + /// The . + /// + /// + /// A delegate to modify the . + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// + public static IRequestExecutorBuilder ModifyMcpToolOptions( + this IRequestExecutorBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + builder.ConfigureSchemaServices(services => services.AddSingleton(configure)); + + return builder; + } + /// /// Adds an MCP storage to the schema. /// diff --git a/src/HotChocolate/Adapters/src/Fusion.Adapters.Mcp/Extensions/FusionGatewayBuilderExtensions.cs b/src/HotChocolate/Adapters/src/Fusion.Adapters.Mcp/Extensions/FusionGatewayBuilderExtensions.cs index 3725e573b30..fda96ecf28d 100644 --- a/src/HotChocolate/Adapters/src/Fusion.Adapters.Mcp/Extensions/FusionGatewayBuilderExtensions.cs +++ b/src/HotChocolate/Adapters/src/Fusion.Adapters.Mcp/Extensions/FusionGatewayBuilderExtensions.cs @@ -39,6 +39,36 @@ public static IFusionGatewayBuilder AddMcp( return builder; } + /// + /// Modifies the options that control how MCP tools are generated from operations. + /// + /// + /// The . + /// + /// + /// A delegate to modify the . + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The is null. + /// + /// + /// The is null. + /// + public static IFusionGatewayBuilder ModifyMcpToolOptions( + this IFusionGatewayBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + builder.ConfigureSchemaServices((_, services) => services.AddSingleton(configure)); + + return builder; + } + /// /// Adds an MCP storage to the gateway. /// diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/OperationToolFactoryTests.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/OperationToolFactoryTests.cs index 20e40942a82..910eb206021 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/OperationToolFactoryTests.cs +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/OperationToolFactoryTests.cs @@ -1,3 +1,4 @@ +using HotChocolate.Adapters.Mcp.Configuration; using HotChocolate.Adapters.Mcp.Storage; using HotChocolate.Language; using HotChocolate.Types; @@ -16,7 +17,7 @@ static OperationTool Action() var document = Utf8GraphQLParser.Parse("fragment Fragment on Type { field }"); var toolDefinition = new OperationToolDefinition(document); - return new OperationToolFactory(schema).CreateTool(toolDefinition); + return new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); } // assert @@ -48,7 +49,7 @@ query Operation2 { """); var toolDefinition = new OperationToolDefinition(document); - return new OperationToolFactory(schema).CreateTool(toolDefinition); + return new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); } // assert @@ -74,7 +75,7 @@ query GetBooks { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -104,7 +105,7 @@ mutation AddBook { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -134,7 +135,7 @@ subscription BookAdded { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -167,7 +168,7 @@ query GetBooks { }; // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal("Custom Title", tool.Tool.Title); @@ -195,7 +196,7 @@ mutation AddBook { }; // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -232,7 +233,7 @@ mutation AddBook { }; // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -252,7 +253,7 @@ public void CreateTool_WithNullableVariables_CreatesCorrectSchema() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -270,7 +271,7 @@ public void CreateTool_WithNonNullableVariables_CreatesCorrectSchema() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -288,7 +289,7 @@ public void CreateTool_WithDefaultedVariables_CreatesCorrectSchema() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -306,7 +307,7 @@ public void CreateTool_WithComplexVariables_CreatesCorrectSchema() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -314,6 +315,72 @@ public void CreateTool_WithComplexVariables_CreatesCorrectSchema() mcpTool.OutputSchema.MatchSnapshot(postFix: "Output", extension: ".json"); } + // The same non-recursive fixture, with references off, must inline every nested input + // object and emit no $ref/$defs. + [Fact] + public void CreateTool_WithComplexVariables_ReferencesDisabled_InlinesWithoutReferences() + { + // arrange + var schema = CreateSchema(s => s.AddType(new DurationType(DurationFormat.DotNet))); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithComplexVariables.graphql")); + var toolDefinition = new OperationToolDefinition(document); + + // act + var tool = + new OperationToolFactory(schema, new McpToolOptions { UseJsonSchemaReferences = false }) + .CreateTool(toolDefinition); + + // assert + tool.Tool.InputSchema.MatchSnapshot(extension: ".json"); + } + + // An input object that references itself (as filter inputs do via and/or) is finite + // only when emitted through $defs/$ref. + [Fact] + public void CreateTool_SelfReferencingInputVariable_UsesReferences() + { + // arrange + var schema = CreateRecursiveFilterSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetWithRecursiveFilter($filter: RecursiveFilterInput) { + withRecursiveFilter(filter: $filter) + } + """); + var toolDefinition = new OperationToolDefinition(document); + + // act + var tool = + new OperationToolFactory(schema, new McpToolOptions()) + .CreateTool(toolDefinition); + + // assert + tool.Tool.InputSchema.MatchSnapshot(extension: ".json"); + } + + [Fact] + public void CreateTool_SelfReferencingInputVariable_ReferencesDisabled_Inlines() + { + // arrange + var schema = CreateRecursiveFilterSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetWithRecursiveFilter($filter: RecursiveFilterInput) { + withRecursiveFilter(filter: $filter) + } + """); + var toolDefinition = new OperationToolDefinition(document); + + // act + var tool = + new OperationToolFactory(schema, new McpToolOptions { UseJsonSchemaReferences = false }) + .CreateTool(toolDefinition); + + // assert + tool.Tool.InputSchema.MatchSnapshot(extension: ".json"); + } + [Fact] public void CreateTool_WithInterfaceType_CreatesCorrectOutputSchema() { @@ -337,7 +404,7 @@ ... on Dog { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert tool.Tool.OutputSchema.MatchSnapshot(extension: ".json"); @@ -365,7 +432,7 @@ ... on Dog { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert tool.Tool.OutputSchema.MatchSnapshot(extension: ".json"); @@ -381,7 +448,7 @@ public void CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert tool.Tool.OutputSchema.MatchSnapshot(extension: ".json"); @@ -401,7 +468,7 @@ public void CreateTool_McpToolAnnotationsDestructiveHintImplementationFirst_Sets var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(destructiveHint, tool.Tool.Annotations?.DestructiveHint); @@ -446,7 +513,7 @@ public void CreateTool_McpToolAnnotationsCodeFirst_SetsCorrectHint( var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(destructiveHint, tool.Tool.Annotations?.DestructiveHint); @@ -482,7 +549,7 @@ type Mutation { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(destructiveHint, tool.Tool.Annotations?.DestructiveHint); @@ -502,7 +569,7 @@ public void CreateTool_McpToolAnnotationsIdempotentHintImplementationFirst_SetsC var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(idempotentHint, tool.Tool.Annotations?.IdempotentHint); @@ -547,7 +614,7 @@ public void CreateTool_McpToolAnnotationsIdempotentHintCodeFirst_SetsCorrectHint var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(idempotentHint, tool.Tool.Annotations?.IdempotentHint); @@ -583,7 +650,7 @@ type Mutation { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(idempotentHint, tool.Tool.Annotations?.IdempotentHint); @@ -605,7 +672,7 @@ public void CreateTool_McpToolAnnotationsOpenWorldHintImplementationFirst_SetsCo var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(openWorldHint, tool.Tool.Annotations?.OpenWorldHint); @@ -661,7 +728,7 @@ public void CreateTool_McpToolAnnotationsOpenWorldHintCodeFirst_SetsCorrectHint( var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(openWorldHint, tool.Tool.Annotations?.OpenWorldHint); @@ -710,7 +777,7 @@ type ExplicitClosedWorld { var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); // assert Assert.Equal(openWorldHint, tool.Tool.Annotations?.OpenWorldHint); @@ -727,7 +794,7 @@ public void CreateTool_McpToolAnnotationsWithFragment_SetsCorrectHints() var toolDefinition = new OperationToolDefinition(document); // act - var tool = new OperationToolFactory(schema).CreateTool(toolDefinition); + var tool = new OperationToolFactory(schema, new McpToolOptions()).CreateTool(toolDefinition); var mcpTool = tool.Tool; // assert @@ -756,4 +823,28 @@ private static Schema CreateSchema(Action? configure = null) return schemaBuilder.Create(); } + + private static Schema CreateRecursiveFilterSchema() + { + return SchemaBuilder + .New() + .AddMcp() + .AddQueryType() + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + } + + public sealed class RecursiveFilterQuery + { + public int GetWithRecursiveFilter(RecursiveFilter? filter) => filter is null ? 0 : 1; + } + + public sealed class RecursiveFilter + { + public RecursiveFilter[]? And { get; set; } + + public RecursiveFilter[]? Or { get; set; } + + public string? Name { get; set; } + } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_ReferencesDisabled_Inlines.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_ReferencesDisabled_Inlines.json new file mode 100644 index 00000000000..8bf18c4f7e5 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_ReferencesDisabled_Inlines.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "properties": { + "filter": { + "type": [ + "object", + "null" + ], + "properties": { + "and": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object" + } + }, + "or": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object" + } + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [] + } + }, + "required": [] +} diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_UsesReferences.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_UsesReferences.json new file mode 100644 index 00000000000..49e0c6c9d52 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_SelfReferencingInputVariable_UsesReferences.json @@ -0,0 +1,48 @@ +{ + "type": "object", + "properties": { + "filter": { + "anyOf": [ + { + "$ref": "#/$defs/RecursiveFilterInput" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "$defs": { + "RecursiveFilterInput": { + "type": "object", + "properties": { + "and": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/RecursiveFilterInput" + } + }, + "or": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/RecursiveFilterInput" + } + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [] + } + } +} diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json index 6b2f2f3a596..f5898c1c400 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json @@ -4,48 +4,7 @@ "list": { "type": "array", "items": { - "type": "object", - "properties": { - "field1A": { - "type": "object", - "properties": { - "field1B": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field1C": { - "type": [ - "string", - "null" - ], - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description", - "default": "12:00:00" - } - }, - "required": [] - }, - "description": "field1B description", - "default": [ - { - "field1C": "12:00:00" - } - ] - } - }, - "required": [], - "description": "field1A description", - "default": { - "field1B": [ - { - "field1C": "12:00:00" - } - ] - } - } - }, - "required": [] + "$ref": "#/$defs/Object1ComplexInput" }, "description": "Complex list", "default": [ @@ -61,48 +20,7 @@ ] }, "object": { - "type": "object", - "properties": { - "field1A": { - "type": "object", - "properties": { - "field1B": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field1C": { - "type": [ - "string", - "null" - ], - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description", - "default": "12:00:00" - } - }, - "required": [] - }, - "description": "field1B description", - "default": [ - { - "field1C": "12:00:00" - } - ] - } - }, - "required": [], - "description": "field1A description", - "default": { - "field1B": [ - { - "field1C": "12:00:00" - } - ] - } - } - }, - "required": [], + "$ref": "#/$defs/Object1ComplexInput", "description": "Complex object", "default": { "field1A": { @@ -139,40 +57,70 @@ ] }, "objectWithNullDefault": { - "type": [ - "object", - "null" + "anyOf": [ + { + "$ref": "#/$defs/Object1ComplexInput" + }, + { + "type": "null" + } ], - "properties": { + "description": "Object with null default", + "default": { "field1A": { - "type": "object", - "properties": { - "field1B": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field1C": { - "type": [ - "string", - "null" - ], - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description", - "default": "12:00:00" - } - }, - "required": [] - }, - "description": "field1B description", - "default": [ - { - "field1C": "12:00:00" - } - ] + "field1B": [ + { + "field1C": null } - }, - "required": [], + ] + } + } + }, + "oneOf": { + "$ref": "#/$defs/OneOfInput", + "description": "OneOf", + "default": { + "field1": 1 + } + }, + "oneOfList": { + "type": "array", + "items": { + "$ref": "#/$defs/OneOfInput" + }, + "description": "OneOf list", + "default": [ + { + "field1": 1 + }, + { + "field2": "default" + } + ] + }, + "objectWithOneOfField": { + "$ref": "#/$defs/ObjectWithOneOfFieldInput", + "description": "Object with OneOf field", + "default": { + "field": { + "field1": 1 + } + } + }, + "durationDotNet": { + "type": "string", + "pattern": "^-?(?:(?:\\d{1,8})\\.)?(?:[0-1]?\\d|2[0-3]):(?:[0-5]?\\d):(?:[0-5]?\\d)(?:\\.(?:\\d{1,7}))?$", + "description": "Duration with DotNet format", + "default": "00:05:00" + } + }, + "required": [], + "$defs": { + "Object1ComplexInput": { + "type": "object", + "properties": { + "field1A": { + "$ref": "#/$defs/Object2ComplexInput", "description": "field1A description", "default": { "field1B": [ @@ -183,19 +131,55 @@ } } }, - "required": [], - "description": "Object with null default", - "default": { - "field1A": { - "field1B": [ + "required": [] + }, + "Object2ComplexInput": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "$ref": "#/$defs/Object3ComplexInput" + }, + "description": "field1B description", + "default": [ { - "field1C": null + "field1C": "12:00:00" } ] } - } + }, + "required": [] }, - "oneOf": { + "Object3ComplexInput": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description", + "default": "12:00:00" + } + }, + "required": [] + }, + "ObjectWithOneOfFieldInput": { + "type": "object", + "properties": { + "field": { + "$ref": "#/$defs/OneOfInput", + "description": "field description", + "default": { + "field1": 1 + } + } + }, + "required": [] + }, + "OneOfInput": { "type": "object", "oneOf": [ { @@ -224,108 +208,7 @@ "field2" ] } - ], - "description": "OneOf", - "default": { - "field1": 1 - } - }, - "oneOfList": { - "type": "array", - "items": { - "type": "object", - "oneOf": [ - { - "type": "object", - "properties": { - "field1": { - "type": "integer", - "minimum": -2147483648, - "maximum": 2147483647, - "description": "field1 description" - } - }, - "required": [ - "field1" - ] - }, - { - "type": "object", - "properties": { - "field2": { - "type": "string", - "description": "field2 description" - } - }, - "required": [ - "field2" - ] - } - ] - }, - "description": "OneOf list", - "default": [ - { - "field1": 1 - }, - { - "field2": "default" - } ] - }, - "objectWithOneOfField": { - "type": "object", - "properties": { - "field": { - "type": "object", - "oneOf": [ - { - "type": "object", - "properties": { - "field1": { - "type": "integer", - "minimum": -2147483648, - "maximum": 2147483647, - "description": "field1 description" - } - }, - "required": [ - "field1" - ] - }, - { - "type": "object", - "properties": { - "field2": { - "type": "string", - "description": "field2 description" - } - }, - "required": [ - "field2" - ] - } - ], - "description": "field description", - "default": { - "field1": 1 - } - } - }, - "required": [], - "description": "Object with OneOf field", - "default": { - "field": { - "field1": 1 - } - } - }, - "durationDotNet": { - "type": "string", - "pattern": "^-?(?:(?:\\d{1,8})\\.)?(?:[0-1]?\\d|2[0-3]):(?:[0-5]?\\d):(?:[0-5]?\\d)(?:\\.(?:\\d{1,7}))?$", - "description": "Duration with DotNet format", - "default": "00:05:00" } - }, - "required": [] + } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_ReferencesDisabled_InlinesWithoutReferences.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_ReferencesDisabled_InlinesWithoutReferences.json new file mode 100644 index 00000000000..6b2f2f3a596 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithComplexVariables_ReferencesDisabled_InlinesWithoutReferences.json @@ -0,0 +1,331 @@ +{ + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description", + "default": "12:00:00" + } + }, + "required": [] + }, + "description": "field1B description", + "default": [ + { + "field1C": "12:00:00" + } + ] + } + }, + "required": [], + "description": "field1A description", + "default": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + }, + "required": [] + }, + "description": "Complex list", + "default": [ + { + "field1A": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + ] + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description", + "default": "12:00:00" + } + }, + "required": [] + }, + "description": "field1B description", + "default": [ + { + "field1C": "12:00:00" + } + ] + } + }, + "required": [], + "description": "field1A description", + "default": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + }, + "required": [], + "description": "Complex object", + "default": { + "field1A": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + }, + "nullDefault": { + "type": [ + "string", + "null" + ], + "description": "Null default", + "default": null + }, + "listWithNullDefault": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + }, + "description": "List with null default", + "default": [ + null + ] + }, + "objectWithNullDefault": { + "type": [ + "object", + "null" + ], + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description", + "default": "12:00:00" + } + }, + "required": [] + }, + "description": "field1B description", + "default": [ + { + "field1C": "12:00:00" + } + ] + } + }, + "required": [], + "description": "field1A description", + "default": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + }, + "required": [], + "description": "Object with null default", + "default": { + "field1A": { + "field1B": [ + { + "field1C": null + } + ] + } + } + }, + "oneOf": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "field1": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "description": "field1 description" + } + }, + "required": [ + "field1" + ] + }, + { + "type": "object", + "properties": { + "field2": { + "type": "string", + "description": "field2 description" + } + }, + "required": [ + "field2" + ] + } + ], + "description": "OneOf", + "default": { + "field1": 1 + } + }, + "oneOfList": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "field1": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "description": "field1 description" + } + }, + "required": [ + "field1" + ] + }, + { + "type": "object", + "properties": { + "field2": { + "type": "string", + "description": "field2 description" + } + }, + "required": [ + "field2" + ] + } + ] + }, + "description": "OneOf list", + "default": [ + { + "field1": 1 + }, + { + "field2": "default" + } + ] + }, + "objectWithOneOfField": { + "type": "object", + "properties": { + "field": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "field1": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "description": "field1 description" + } + }, + "required": [ + "field1" + ] + }, + { + "type": "object", + "properties": { + "field2": { + "type": "string", + "description": "field2 description" + } + }, + "required": [ + "field2" + ] + } + ], + "description": "field description", + "default": { + "field1": 1 + } + } + }, + "required": [], + "description": "Object with OneOf field", + "default": { + "field": { + "field1": 1 + } + } + }, + "durationDotNet": { + "type": "string", + "pattern": "^-?(?:(?:\\d{1,8})\\.)?(?:[0-1]?\\d|2[0-3]):(?:[0-5]?\\d):(?:[0-5]?\\d)(?:\\.(?:\\d{1,7}))?$", + "description": "Duration with DotNet format", + "default": "00:05:00" + } + }, + "required": [] +} diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json index 258fe6022e9..80bf85098be 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json @@ -127,38 +127,7 @@ "default": 9223372036854775807 }, "object": { - "type": "object", - "properties": { - "field1A": { - "type": "object", - "properties": { - "field1B": { - "type": "object", - "properties": { - "field1C": { - "type": "string", - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description", - "default": "12:00:00" - } - }, - "required": [], - "description": "field1B description", - "default": { - "field1C": "12:00:00" - } - } - }, - "required": [], - "description": "field1A description", - "default": { - "field1B": { - "field1C": "12:00:00" - } - } - } - }, - "required": [], + "$ref": "#/$defs/Object1DefaultedInput", "description": "Object description", "default": { "field1A": { @@ -233,5 +202,47 @@ "default": "00000000-0000-0000-0000-000000000000" } }, - "required": [] + "required": [], + "$defs": { + "Object1DefaultedInput": { + "type": "object", + "properties": { + "field1A": { + "$ref": "#/$defs/Object2DefaultedInput", + "description": "field1A description", + "default": { + "field1B": { + "field1C": "12:00:00" + } + } + } + }, + "required": [] + }, + "Object2DefaultedInput": { + "type": "object", + "properties": { + "field1B": { + "$ref": "#/$defs/Object3DefaultedInput", + "description": "field1B description", + "default": { + "field1C": "12:00:00" + } + } + }, + "required": [] + }, + "Object3DefaultedInput": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description", + "default": "12:00:00" + } + }, + "required": [] + } + } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json index fbbda38f80a..851e981761f 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json @@ -106,35 +106,7 @@ "description": "Long description" }, "object": { - "type": "object", - "properties": { - "field1A": { - "type": "object", - "properties": { - "field1B": { - "type": "object", - "properties": { - "field1C": { - "type": "string", - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description" - } - }, - "required": [ - "field1C" - ], - "description": "field1B description" - } - }, - "required": [ - "field1B" - ], - "description": "field1A description" - } - }, - "required": [ - "field1A" - ], + "$ref": "#/$defs/Object1NonNullableInput", "description": "Object description" }, "short": { @@ -221,5 +193,44 @@ "uri", "url", "uuid" - ] + ], + "$defs": { + "Object1NonNullableInput": { + "type": "object", + "properties": { + "field1A": { + "$ref": "#/$defs/Object2NonNullableInput", + "description": "field1A description" + } + }, + "required": [ + "field1A" + ] + }, + "Object2NonNullableInput": { + "type": "object", + "properties": { + "field1B": { + "$ref": "#/$defs/Object3NonNullableInput", + "description": "field1B description" + } + }, + "required": [ + "field1B" + ] + }, + "Object3NonNullableInput": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ] + } + } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json index f3e257bbd5d..d34d90cfda3 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/OperationToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json @@ -157,41 +157,14 @@ "description": "Long description" }, "object": { - "type": [ - "object", - "null" - ], - "properties": { - "field1A": { - "type": [ - "object", - "null" - ], - "properties": { - "field1B": { - "type": [ - "object", - "null" - ], - "properties": { - "field1C": { - "type": [ - "string", - "null" - ], - "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", - "description": "field1C description" - } - }, - "required": [], - "description": "field1B description" - } - }, - "required": [], - "description": "field1A description" + "anyOf": [ + { + "$ref": "#/$defs/Object1NullableInput" + }, + { + "type": "null" } - }, - "required": [], + ], "description": "Object description" }, "short": { @@ -279,5 +252,55 @@ "description": "UUID description" } }, - "required": [] + "required": [], + "$defs": { + "Object1NullableInput": { + "type": "object", + "properties": { + "field1A": { + "anyOf": [ + { + "$ref": "#/$defs/Object2NullableInput" + }, + { + "type": "null" + } + ], + "description": "field1A description" + } + }, + "required": [] + }, + "Object2NullableInput": { + "type": "object", + "properties": { + "field1B": { + "anyOf": [ + { + "$ref": "#/$defs/Object3NullableInput" + }, + { + "type": "null" + } + ], + "description": "field1B description" + } + }, + "required": [] + }, + "Object3NullableInput": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "pattern": "^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?$", + "description": "field1C description" + } + }, + "required": [] + } + } } diff --git a/website/src/docs/fusion/v16/adapters/mcp.md b/website/src/docs/fusion/v16/adapters/mcp.md index b2306efc0df..1cdb78ab532 100644 --- a/website/src/docs/fusion/v16/adapters/mcp.md +++ b/website/src/docs/fusion/v16/adapters/mcp.md @@ -81,6 +81,24 @@ builder Tools registered through `configureServer` appear alongside the GraphQL-derived tools, so you can mix native MCP tools with operation tools in the same gateway. +## Input Schema References + +An operation's variables become a tool's input schema (JSON Schema). When a variable's type is an input object, the adapter emits that type once under `$defs` and references it with `$ref`. References are how a self-referencing input type is represented in a finite schema, for example a filter input whose `and` and `or` fields are lists of the same type. + +References are the default and are understood by current MCP clients and agents. Some clients have limited support for JSON Schema references (recursive `$ref` in particular). For those, turn references off with `ModifyMcpToolOptions()`: + +```csharp +builder + .AddGraphQLGateway() + .AddMcp() + .ModifyMcpToolOptions(options => + { + options.UseJsonSchemaReferences = false; + }); +``` + +With references disabled, input object types are inlined. Where a type refers to itself, that point is collapsed to a generic object (`{ "type": "object" }`) and the rest of the schema keeps its full structure. Leave references enabled unless a target client cannot resolve `$ref`. + ## Mapping the MCP Endpoint `MapGraphQLMcp()` accepts two optional arguments: @@ -185,6 +203,10 @@ Storage is registered but returned no definitions. With Nitro, ensure a publishe `NitroServiceOptions` is missing one or more of `ApiId`, `ApiKey`, or `Stage`. Set them through the `AddNitro()` configuration delegate or via the `NITRO_API_ID`, `NITRO_API_KEY`, and `NITRO_STAGE` environment variables. +### MCP client rejects or cannot render a tool's input schema + +The client may not support JSON Schema references (`$ref`/`$defs`), which the adapter uses for input object types by default. Disable them with `.ModifyMcpToolOptions(options => options.UseJsonSchemaReferences = false)` to inline the schema instead. See [Input Schema References](#input-schema-references). + ## Next Steps - Author tools and prompts and publish them to a stage in the [Nitro MCP](/docs/nitro/adapters/mcp) section. diff --git a/website/src/docs/hotchocolate/v16/build/adapters/mcp.md b/website/src/docs/hotchocolate/v16/build/adapters/mcp.md index 33bc72a5d36..c80b54092e4 100644 --- a/website/src/docs/hotchocolate/v16/build/adapters/mcp.md +++ b/website/src/docs/hotchocolate/v16/build/adapters/mcp.md @@ -81,6 +81,24 @@ builder Tools registered through `configureServer` appear alongside the GraphQL-derived tools, so you can mix native MCP tools with operation tools in the same server. +## Input Schema References + +An operation's variables become a tool's input schema (JSON Schema). When a variable's type is an input object, the adapter emits that type once under `$defs` and references it with `$ref`. References are how a self-referencing input type is represented in a finite schema, for example a filter input whose `and` and `or` fields are lists of the same type. + +References are the default and are understood by current MCP clients and agents. Some clients have limited support for JSON Schema references (recursive `$ref` in particular). For those, turn references off with `ModifyMcpToolOptions()`: + +```csharp +builder + .AddGraphQL() + .AddMcp() + .ModifyMcpToolOptions(options => + { + options.UseJsonSchemaReferences = false; + }); +``` + +With references disabled, input object types are inlined. Where a type refers to itself, that point is collapsed to a generic object (`{ "type": "object" }`) and the rest of the schema keeps its full structure. Leave references enabled unless a target client cannot resolve `$ref`. + ## Mapping the MCP Endpoint `MapGraphQLMcp()` accepts two optional arguments: @@ -185,6 +203,10 @@ Storage is registered but returned no definitions. With Nitro, ensure a publishe `NitroServiceOptions` is missing one or more of `ApiId`, `ApiKey`, or `Stage`. Set them through the `AddNitro()` configuration delegate or via the `NITRO_API_ID`, `NITRO_API_KEY`, and `NITRO_STAGE` environment variables. +### MCP client rejects or cannot render a tool's input schema + +The client may not support JSON Schema references (`$ref`/`$defs`), which the adapter uses for input object types by default. Disable them with `.ModifyMcpToolOptions(options => options.UseJsonSchemaReferences = false)` to inline the schema instead. See [Input Schema References](#input-schema-references). + ## Next Steps - Author tools and prompts and publish them with Nitro in the [Nitro MCP](/docs/nitro/adapters/mcp) section.