diff --git a/src/All.slnx b/src/All.slnx index 2ccf0322499..b9fe1830ee1 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -213,6 +213,13 @@ + + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 02bcd753860..2c646edd019 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,8 @@ + + @@ -20,6 +22,7 @@ + diff --git a/src/HotChocolate/Core/src/Types/Types/Scalars/TimeSpanType.cs b/src/HotChocolate/Core/src/Types/Types/Scalars/TimeSpanType.cs index 298896dcaa0..11b6728885a 100644 --- a/src/HotChocolate/Core/src/Types/Types/Scalars/TimeSpanType.cs +++ b/src/HotChocolate/Core/src/Types/Types/Scalars/TimeSpanType.cs @@ -11,7 +11,7 @@ namespace HotChocolate.Types; public class TimeSpanType : ScalarType { - private readonly TimeSpanFormat _format; + public TimeSpanFormat Format { get; } public TimeSpanType( TimeSpanFormat format = TimeSpanFormat.Iso8601, @@ -27,7 +27,7 @@ public TimeSpanType( BindingBehavior bind = BindingBehavior.Explicit) : base(name, bind) { - _format = format; + Format = format; Description = description; } @@ -39,7 +39,7 @@ public TimeSpanType() protected override TimeSpan ParseLiteral(StringValueNode valueSyntax) { - if (TryDeserializeFromString(valueSyntax.Value, _format, out var value) + if (TryDeserializeFromString(valueSyntax.Value, Format, out var value) && value != null) { return value.Value; @@ -52,7 +52,7 @@ protected override TimeSpan ParseLiteral(StringValueNode valueSyntax) protected override StringValueNode ParseValue(TimeSpan runtimeValue) { - return _format == TimeSpanFormat.Iso8601 + return Format == TimeSpanFormat.Iso8601 ? new StringValueNode(XmlConvert.ToString(runtimeValue)) : new StringValueNode(runtimeValue.ToString("c")); } @@ -65,7 +65,7 @@ public override IValueNode ParseResult(object? resultValue) } if (resultValue is string s - && TryDeserializeFromString(s, _format, out var timeSpan)) + && TryDeserializeFromString(s, Format, out var timeSpan)) { return ParseValue(timeSpan); } @@ -90,7 +90,7 @@ public override bool TrySerialize(object? runtimeValue, out object? resultValue) if (runtimeValue is TimeSpan timeSpan) { - if (_format == TimeSpanFormat.Iso8601) + if (Format == TimeSpanFormat.Iso8601) { resultValue = XmlConvert.ToString(timeSpan); return true; @@ -113,7 +113,7 @@ public override bool TryDeserialize(object? resultValue, out object? runtimeValu } if (resultValue is string s - && TryDeserializeFromString(s, _format, out var timeSpan)) + && TryDeserializeFromString(s, Format, out var timeSpan)) { runtimeValue = timeSpan; return true; diff --git a/src/HotChocolate/ModelContextProtocol/Directory.Build.props b/src/HotChocolate/ModelContextProtocol/Directory.Build.props new file mode 100644 index 00000000000..106b12775b2 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/src/HotChocolate/ModelContextProtocol/HotChocolate.ModelContextProtocol.slnx b/src/HotChocolate/ModelContextProtocol/HotChocolate.ModelContextProtocol.slnx new file mode 100644 index 00000000000..a88c405cce1 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/HotChocolate.ModelContextProtocol.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/HotChocolate/ModelContextProtocol/src/Directory.Build.props b/src/HotChocolate/ModelContextProtocol/src/Directory.Build.props new file mode 100644 index 00000000000..db3f6f4adb2 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/Directory.Build.props @@ -0,0 +1,12 @@ + + + + + $(NoWarn);CA1812 + + + + true + + + diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Attributes/McpToolAnnotationsAttribute.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Attributes/McpToolAnnotationsAttribute.cs new file mode 100644 index 00000000000..baa7e0d9279 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Attributes/McpToolAnnotationsAttribute.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using HotChocolate.ModelContextProtocol.Directives; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors; + +namespace HotChocolate.ModelContextProtocol.Attributes; + +/// +/// Additional properties describing a Tool to clients. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class McpToolAnnotationsAttribute : DescriptorAttribute +{ + private readonly bool? _destructiveHint; + private readonly bool? _idempotentHint; + private readonly bool? _openWorldHint; + + /// + /// If true, the tool may perform destructive updates to its environment. If + /// false, the tool performs only additive updates. + /// + public bool DestructiveHint + { + get => _destructiveHint ?? throw new InvalidOperationException(); + init => _destructiveHint = value; + } + + /// + /// If true, calling the tool repeatedly with the same arguments will have no additional + /// effect on its environment. + /// + public bool IdempotentHint + { + get => _idempotentHint ?? throw new InvalidOperationException(); + init => _idempotentHint = value; + } + + /// + /// If true, this tool may interact with an “open world” of external entities. If + /// false, the tool’s domain of interaction is closed. For example, the world of a web + /// search tool is open, whereas that of a memory tool is not. + /// + public bool OpenWorldHint + { + get => _openWorldHint ?? throw new InvalidOperationException(); + init => _openWorldHint = value; + } + + protected override void TryConfigure( + IDescriptorContext context, + IDescriptor descriptor, + ICustomAttributeProvider element) + { + if (descriptor is IObjectFieldDescriptor objectFieldDescriptor) + { + objectFieldDescriptor.Directive( + new McpToolAnnotationsDirective + { + DestructiveHint = _destructiveHint, + IdempotentHint = _idempotentHint, + OpenWorldHint = _openWorldHint + }); + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolAnnotationsDirective.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolAnnotationsDirective.cs new file mode 100644 index 00000000000..219036c8992 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolAnnotationsDirective.cs @@ -0,0 +1,26 @@ +namespace HotChocolate.ModelContextProtocol.Directives; + +/// +/// Additional properties describing a Tool to clients. +/// +public sealed class McpToolAnnotationsDirective +{ + /// + /// If true, the tool may perform destructive updates to its environment. If + /// false, the tool performs only additive updates. + /// + public bool? DestructiveHint { get; init; } + + /// + /// If true, calling the tool repeatedly with the same arguments will have no additional + /// effect on its environment. + /// + public bool? IdempotentHint { get; init; } + + /// + /// If true, this tool may interact with an “open world” of external entities. If + /// false, the tool’s domain of interaction is closed. For example, the world of a web + /// search tool is open, whereas that of a memory tool is not. + /// + public bool? OpenWorldHint { get; init; } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirective.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirective.cs new file mode 100644 index 00000000000..ec66a6a4829 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirective.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.ModelContextProtocol.Directives; + +internal sealed class McpToolDirective +{ + public string? Title { get; init; } + + /// + /// If true, the tool may perform destructive updates to its environment. If + /// false, the tool performs only additive updates. + /// + public bool? DestructiveHint { get; init; } + + /// + /// If true, calling the tool repeatedly with the same arguments will have no additional + /// effect on its environment. + /// + public bool? IdempotentHint { get; init; } + + /// + /// If true, this tool may interact with an “open world” of external entities. If + /// false, the tool’s domain of interaction is closed. For example, the world of a web + /// search tool is open, whereas that of a memory tool is not. + /// + public bool? OpenWorldHint { get; init; } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirectiveParser.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirectiveParser.cs new file mode 100644 index 00000000000..2e8be88d8e3 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Directives/McpToolDirectiveParser.cs @@ -0,0 +1,67 @@ +using HotChocolate.Language; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Directives; + +internal static class McpToolDirectiveParser +{ + public static McpToolDirective Parse(DirectiveNode directive) + { + string? title = null; + bool? destructiveHint = null; + bool? idempotentHint = null; + bool? openWorldHint = null; + + foreach (var argument in directive.Arguments) + { + switch (argument.Name.Value) + { + case WellKnownArgumentNames.Title: + if (argument.Value is StringValueNode titleString) + { + title = titleString.Value; + } + + break; + + case WellKnownArgumentNames.DestructiveHint: + if (argument.Value is BooleanValueNode destructiveHintBoolean) + { + destructiveHint = destructiveHintBoolean.Value; + } + + break; + + case WellKnownArgumentNames.IdempotentHint: + if (argument.Value is BooleanValueNode idempotentHintBoolean) + { + idempotentHint = idempotentHintBoolean.Value; + } + + break; + + case WellKnownArgumentNames.OpenWorldHint: + if (argument.Value is BooleanValueNode openWorldHintBoolean) + { + openWorldHint = openWorldHintBoolean.Value; + } + + break; + + default: + throw new Exception( + string.Format( + McpToolDirectiveParser_ArgumentNotSupportedOnMcpToolDirective, + argument.Name.Value)); + } + } + + return new McpToolDirective() + { + Title = title, + DestructiveHint = destructiveHint, + IdempotentHint = idempotentHint, + OpenWorldHint = openWorldHint + }; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/EndpointRouteBuilderExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000000..4e18826189d --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,88 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.ModelContextProtocol.Proxies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +public static class EndpointRouteBuilderExtensions +{ + public static IEndpointConventionBuilder MapGraphQLMcp( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string pattern = "/graphql/mcp", + string? schemaName = null) + { + schemaName ??= ISchemaDefinition.DefaultName; + + var streamableHttpHandler = + endpoints.ServiceProvider.GetKeyedService(schemaName) + ?? throw new InvalidOperationException( + "You must call AddMcp(). Unable to find required services. Call " + + "builder.Services.AddGraphQL().AddMcp() in application startup code."); + + var mcpGroup = endpoints.MapGroup(pattern); + + var streamableHttpGroup = + mcpGroup + .MapGroup("") + .WithDisplayName(b => $"GraphQL MCP Streamable HTTP | {b.DisplayName}") + .WithMetadata( + new ProducesResponseTypeMetadata( + StatusCodes.Status404NotFound, + typeof(JsonRpcError), + contentTypes: ["application/json"])); + + streamableHttpGroup + .MapPost("", streamableHttpHandler.HandlePostRequestAsync) + .WithMetadata(new AcceptsMetadata(["application/json"])) + .WithMetadata( + new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, + contentTypes: ["text/event-stream"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); + + if (!streamableHttpHandler.HttpServerTransportOptions.Stateless) + { + // The GET and DELETE endpoints are not mapped in Stateless mode since there's no way to + // send unsolicited messages for the GET to handle, and there is no server-side state + // for the DELETE to clean up. + streamableHttpGroup + .MapGet("", streamableHttpHandler.HandleGetRequestAsync) + .WithMetadata( + new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, + contentTypes: ["text/event-stream"])); + + streamableHttpGroup.MapDelete("", streamableHttpHandler.HandleDeleteRequestAsync); + + // Map legacy HTTP with SSE endpoints only if not in Stateless mode, because we cannot + // guarantee the /message requests will be handled by the same process as the /sse + // request. + var sseHandler = + endpoints.ServiceProvider.GetRequiredKeyedService(schemaName); + + var sseGroup = + mcpGroup + .MapGroup("") + .WithDisplayName(b => $"GraphQL MCP HTTP with SSE | {b.DisplayName}"); + + sseGroup + .MapGet("/sse", sseHandler.HandleSseRequestAsync) + .WithMetadata( + new ProducesResponseTypeMetadata( + StatusCodes.Status200OK, + contentTypes: ["text/event-stream"])); + + sseGroup + .MapPost("/message", sseHandler.HandleMessageRequestAsync) + .WithMetadata(new AcceptsMetadata(["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); + } + + return mcpGroup; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/InputFieldExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/InputFieldExtensions.cs new file mode 100644 index 00000000000..67624eea051 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/InputFieldExtensions.cs @@ -0,0 +1,28 @@ +using HotChocolate.Types; +using Json.Schema; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class InputFieldExtensions +{ + public static JsonSchema ToJsonSchema(this InputField inputField) + { + var graphQLType = inputField.Type; + var schemaBuilder = + graphQLType.ToJsonSchemaBuilder(isOneOf: inputField.DeclaringType.IsOneOf); + + // Description. + if (inputField.Description is not null) + { + schemaBuilder.Description(inputField.Description); + } + + // Default value. + if (inputField.DefaultValue is not null) + { + schemaBuilder.Default(inputField.DefaultValue.ToJsonNode(graphQLType)); + } + + return schemaBuilder.Build(); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/JsonSchemaExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/JsonSchemaExtensions.cs new file mode 100644 index 00000000000..9d45a4507c0 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/JsonSchemaExtensions.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using HotChocolate.ModelContextProtocol.JsonSerializerContexts; +using Json.Schema; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class JsonSchemaExtensions +{ + public static JsonElement ToJsonElement(this JsonSchema jsonSchema) + { + var json = + JsonSerializer.Serialize( + jsonSchema, + JsonSchemaJsonSerializerContext.Default.JsonSchema); + + return JsonDocument.Parse(json).RootElement; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ObjectFieldDescriptorExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ObjectFieldDescriptorExtensions.cs new file mode 100644 index 00000000000..50c66df031e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ObjectFieldDescriptorExtensions.cs @@ -0,0 +1,30 @@ +using HotChocolate.ModelContextProtocol.Directives; +using HotChocolate.Types; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +/// +/// Provides extension methods to . +/// +public static class ObjectFieldDescriptorExtensions +{ + /// + /// Additional properties describing a Tool to clients. + /// + public static IObjectFieldDescriptor McpToolAnnotations( + this IObjectFieldDescriptor descriptor, + bool? destructiveHint = null, + bool? idempotentHint = null, + bool? openWorldHint = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + + return descriptor.Directive( + new McpToolAnnotationsDirective + { + DestructiveHint = destructiveHint, + IdempotentHint = idempotentHint, + OpenWorldHint = openWorldHint + }); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/OperationDefinitionNodeExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/OperationDefinitionNodeExtensions.cs new file mode 100644 index 00000000000..d2dedbfa51e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/OperationDefinitionNodeExtensions.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Directives; +using static HotChocolate.ModelContextProtocol.WellKnownDirectiveNames; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class OperationDefinitionNodeExtensions +{ + public static McpToolDirective? GetMcpToolDirective(this OperationDefinitionNode operationNode) + { + var mcpToolDirectiveNode = + operationNode.Directives.SingleOrDefault(d => d.Name.Value == McpTool); + + return mcpToolDirectiveNode is null + ? null + : McpToolDirectiveParser.Parse(mcpToolDirectiveNode); + } + + public static bool TryGetMcpToolDirective( + this OperationDefinitionNode operationNode, + [NotNullWhen(true)] out McpToolDirective? mcpToolDirective) + { + mcpToolDirective = operationNode.GetMcpToolDirective(); + + return mcpToolDirective is not null; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/RequestExecutorBuilderExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/RequestExecutorBuilderExtensions.cs new file mode 100644 index 00000000000..93e09e695df --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/RequestExecutorBuilderExtensions.cs @@ -0,0 +1,96 @@ +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using HotChocolate.ModelContextProtocol.Factories; +using HotChocolate.ModelContextProtocol.Handlers; +using HotChocolate.ModelContextProtocol.Proxies; +using HotChocolate.ModelContextProtocol.Registries; +using HotChocolate.ModelContextProtocol.Storage; +using HotChocolate.ModelContextProtocol.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +public static class RequestExecutorBuilderExtensions +{ + public static IRequestExecutorBuilder AddMcp(this IRequestExecutorBuilder builder) + { + builder.Services + .AddKeyedSingleton( + builder.Name, + static (sp, name) => new McpRequestExecutorProxy( + sp.GetRequiredService(), + sp.GetRequiredService(), + (string)name)) + .AddKeyedSingleton( + builder.Name, + static (sp, name) => new StreamableHttpHandlerProxy( + sp.GetRequiredKeyedService(name))) + .AddKeyedSingleton( + builder.Name, + static (sp, name) => new SseHandlerProxy( + sp.GetRequiredKeyedService(name))); + + builder.ConfigureSchemaServices( + services => + { + services + .TryAddSingleton + (); + + services + .AddSingleton( + static sp => sp + .GetRootServiceProvider() + .GetRequiredService()) + .AddSingleton( + static sp => sp + .GetRootServiceProvider().GetRequiredService()) + .AddSingleton(); + + services + .AddMcpServer() + .WithHttpTransport() + .WithListToolsHandler( + (context, _) => ValueTask.FromResult(ListToolsHandler.Handle(context))) + .WithCallToolHandler( + async (context, cancellationToken) + => await CallToolHandler + .HandleAsync(context, cancellationToken) + .ConfigureAwait(false)); + }); + + builder.AddDirectiveType(); + + builder.ConfigureOnRequestExecutorCreatedAsync( + async (executor, cancellationToken) => + { + var schema = executor.Schema; + var storage = schema.Services.GetRequiredService(); + var registry = schema.Services.GetRequiredService(); + var factory = new GraphQLMcpToolFactory(schema); + + var toolDocuments = + await storage.GetToolDocumentsAsync(cancellationToken).ConfigureAwait(false); + + registry.Clear(); + + foreach (var (name, document) in toolDocuments) + { + registry.Add(factory.CreateTool(name, document)); + } + }); + + return builder; + } + + public static IRequestExecutorBuilder AddMcpOperationDocumentStorage( + this IRequestExecutorBuilder builder, + IMcpOperationDocumentStorage storage) + { + builder.ConfigureSchemaServices(s => s.AddSingleton(storage)); + return builder; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SchemaBuilderExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SchemaBuilderExtensions.cs new file mode 100644 index 00000000000..302d98054a5 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SchemaBuilderExtensions.cs @@ -0,0 +1,13 @@ +using HotChocolate.ModelContextProtocol.Types; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +public static class SchemaBuilderExtensions +{ + public static ISchemaBuilder AddMcp(this ISchemaBuilder builder) + { + builder.AddDirectiveType(); + + return builder; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SelectionExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SelectionExtensions.cs new file mode 100644 index 00000000000..b716fbc1ffe --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/SelectionExtensions.cs @@ -0,0 +1,41 @@ +using HotChocolate.Execution.Processing; +using HotChocolate.Language; +using HotChocolate.Types; +using static HotChocolate.ModelContextProtocol.WellKnownDirectiveNames; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class SelectionExtensions +{ + public static SelectionState GetSelectionState(this ISelection selection) + { + var skip = + selection.SyntaxNode.Directives.FirstOrDefault(d => d.Name.Value == Skip)? + .GetArgumentValue(WellKnownArgumentNames.If); + + var include = + selection.SyntaxNode.Directives.FirstOrDefault(d => d.Name.Value == Include)? + .GetArgumentValue(WellKnownArgumentNames.If); + + if (skip is null or BooleanValueNode { Value: false } + && include is null or BooleanValueNode { Value: true }) + { + return SelectionState.Included; + } + + if (skip is BooleanValueNode { Value: true } + || include is BooleanValueNode { Value: false }) + { + return SelectionState.Excluded; + } + + return SelectionState.Conditional; + } +} + +internal enum SelectionState +{ + Included, + Excluded, + Conditional +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeExtensions.cs new file mode 100644 index 00000000000..71dd3d29d60 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeExtensions.cs @@ -0,0 +1,292 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; +using HotChocolate.Types; +using Json.Schema; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class TypeExtensions +{ + public static JsonSchemaBuilder ToJsonSchemaBuilder( + this IType graphQLType, + bool isOneOf = false) + { + var schemaBuilder = new JsonSchemaBuilder(); + + // Type. + var type = graphQLType.GetJsonSchemaValueType(); + + if (!graphQLType.IsNonNullType() && !isOneOf) + { + // Nullability. + type |= SchemaValueType.Null; + } + + schemaBuilder.Type(type); + + // Format. + if (graphQLType.TryGetJsonSchemaFormat(out var format)) + { + schemaBuilder.Format(format); + } + + // Pattern. + if (graphQLType.TryGetJsonSchemaPattern(out var pattern)) + { + schemaBuilder.Pattern(pattern); + } + + // Minimum. + if (graphQLType.TryGetJsonSchemaMinimum(out var minimum)) + { + schemaBuilder.Minimum(minimum); + } + + // Maximum. + if (graphQLType.TryGetJsonSchemaMaximum(out var maximum)) + { + schemaBuilder.Maximum(maximum); + } + + switch (graphQLType.NullableType()) + { + case EnumType enumType: + // Enum values. + List enumValues = []; + + foreach (var enumValue in enumType.Values) + { + enumValues.Add(JsonValue.Create(enumValue.Name)); + } + + if (graphQLType.IsNullableType()) + { + enumValues.Add(null); + } + + schemaBuilder.Enum(enumValues); + break; + + case InputObjectType inputObjectType: + // Object properties. + var objectProperties = new Dictionary(); + var requiredObjectProperties = new List(); + + foreach (var field in inputObjectType.Fields) + { + var fieldSchema = field.ToJsonSchema(); + + 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); + } + + break; + + case ListType listType: + // Array items. + schemaBuilder.Items(listType.ElementType().ToJsonSchemaBuilder()); + break; + } + + return schemaBuilder; + } + + private static SchemaValueType GetJsonSchemaValueType(this IType graphQLType) + { + return graphQLType switch + { + EnumType => SchemaValueType.String, + InputObjectType => SchemaValueType.Object, + InterfaceType => SchemaValueType.Object, + ListType => SchemaValueType.Array, + NonNullType => GetJsonSchemaValueType(graphQLType.NullableType()), + ObjectType => SchemaValueType.Object, + ScalarType => graphQLType switch + { + AnyType or JsonType => + SchemaValueType.Object + | SchemaValueType.Array + | SchemaValueType.Boolean + | SchemaValueType.String + | SchemaValueType.Number + | SchemaValueType.Integer, + BooleanType => SchemaValueType.Boolean, + ByteType => SchemaValueType.Integer, + DecimalType => SchemaValueType.Number, + FloatType => SchemaValueType.Number, + IdType => SchemaValueType.String, + IntType => SchemaValueType.Integer, + LongType => SchemaValueType.Integer, + ShortType => SchemaValueType.Integer, + StringType => SchemaValueType.String, + // The following types are serialized as strings: + // URL, UUID, ByteArray, DateTime, Date, TimeSpan, LocalDate, LocalDateTime, + // LocalTime. + // FIXME: Treating all unknown scalar types as strings is a temporary solution. + _ => SchemaValueType.String + }, + UnionType => SchemaValueType.Object, + _ => + throw new NotSupportedException( + string.Format( + TypeExtensions_UnableToDetermineJsonSchemaValueType, + graphQLType.GetType().Name)) + }; + } + + private static bool TryGetJsonSchemaFormat( + this IType graphQLType, + [NotNullWhen(true)] out Format? format) + { + format = graphQLType.NullableType() switch + { + DateTimeType => Formats.DateTime, // Further constrained by pattern. + DateType => Formats.Date, + LocalDateTimeType => Formats.DateTime, // Further constrained by pattern. + LocalDateType => Formats.Date, + LocalTimeType => Formats.Time, // Further constrained by pattern. + UrlType => Formats.UriReference, + UuidType => Formats.Uuid, + _ => null + }; + + return format is not null; + } + + private static bool TryGetJsonSchemaPattern( + this IType graphQLType, + [NotNullWhen(true)] out string? pattern) + { + pattern = graphQLType.NullableType() switch + { + ByteArrayType + // e.g. dmFsdWU= (Base64-encoded string) + => @"^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$", + DateTimeType + // e.g. 2011-08-30T13:22:53.108Z (https://www.graphql-scalars.com/date-time/) + => + @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}" + + @"(?:\.\d{1,7})?(?:[Zz]|[+-]\d{2}:\d{2})$", + LocalDateTimeType + // e.g. 2011-08-30T13:22:53 + => @"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$", + LocalTimeType + // e.g. 13:22:53 + => @"^\d{2}:\d{2}:\d{2}$", + TimeSpanType timeSpanType + => timeSpanType.Format switch + { + // e.g. PT5M + TimeSpanFormat.Iso8601 + => + @"^-?P(?:\d+W|(?=\d|T(?:\d|$))(?:\d+Y)?(?:\d+M)?(?:\d+D)?" + + @"(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?)$", + // e.g. 00:05:00 + TimeSpanFormat.DotNet + => + @"^-?(?:(?:\d{1,8})\.)?(?:[0-1]?\d|2[0-3]):(?:[0-5]?\d):(?:[0-5]?\d)" + + @"(?:\.(?:\d{1,7}))?$", + _ => throw new InvalidOperationException() + }, + _ => null + }; + + return pattern is not null; + } + + private static bool TryGetJsonSchemaMinimum(this IType graphQLType, out decimal minimum) + { + switch (graphQLType.NullableType()) + { + case ByteType byteType when byteType.MinValue != byte.MinValue: + minimum = byteType.MinValue; + return true; + + case DecimalType decimalType when decimalType.MinValue != decimal.MinValue: + minimum = decimalType.MinValue; + return true; + + case FloatType { MinValue: >= (double)decimal.MinValue } floatType: + minimum = (decimal)floatType.MinValue; + return true; + + case IntType intType when intType.MinValue != int.MinValue: + minimum = intType.MinValue; + return true; + + case LongType longType when longType.MinValue != long.MinValue: + minimum = longType.MinValue; + return true; + + case ShortType shortType when shortType.MinValue != short.MinValue: + minimum = shortType.MinValue; + return true; + } + + minimum = 0; + return false; + } + + private static bool TryGetJsonSchemaMaximum(this IType graphQLType, out decimal maximum) + { + switch (graphQLType.NullableType()) + { + case ByteType byteType when byteType.MaxValue != byte.MaxValue: + maximum = byteType.MaxValue; + return true; + + case DecimalType decimalType when decimalType.MaxValue != decimal.MaxValue: + maximum = decimalType.MaxValue; + return true; + + case FloatType { MaxValue: <= (double)decimal.MaxValue } floatType: + maximum = (decimal)floatType.MaxValue; + return true; + + case IntType intType when intType.MaxValue != int.MaxValue: + maximum = intType.MaxValue; + return true; + + case LongType longType when longType.MaxValue != long.MaxValue: + maximum = longType.MaxValue; + return true; + + case ShortType shortType when shortType.MaxValue != short.MaxValue: + maximum = shortType.MaxValue; + return true; + } + + maximum = 0; + return false; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeNodeExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeNodeExtensions.cs new file mode 100644 index 00000000000..8588f51c132 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/TypeNodeExtensions.cs @@ -0,0 +1,54 @@ +using System.Collections.Frozen; +using HotChocolate.Language; +using HotChocolate.Types; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class TypeNodeExtensions +{ + public static IType GetGraphQLType( + this ITypeNode graphQLTypeNode, + ISchemaDefinition graphQLSchema) + { + var typeName = graphQLTypeNode.NamedType().Name.Value; + + if (graphQLSchema.Types.TryGetType(typeName, out var typeDefinition)) + { + return graphQLTypeNode.RewriteToType(typeDefinition); + } + + if (s_typeMap.TryGetValue(typeName, out var type)) + { + return graphQLTypeNode.RewriteToType(type.AsTypeDefinition()); + } + + throw new NotSupportedException( + string.Format(TypeNodeExtensions_UnableToFindGraphQLTypeInSchemaOrTypeMap, typeName)); + } + + private static readonly FrozenDictionary s_typeMap = + new Dictionary + { + { ScalarNames.Any, new AnyType() }, + { ScalarNames.Boolean, new BooleanType() }, + { ScalarNames.Byte, new ByteType() }, + { ScalarNames.ByteArray, new ByteArrayType() }, + { ScalarNames.Date, new DateType() }, + { ScalarNames.DateTime, new DateTimeType() }, + { ScalarNames.Decimal, new DecimalType() }, + { ScalarNames.Float, new FloatType() }, + { ScalarNames.ID, new IdType() }, + { ScalarNames.Int, new IntType() }, + { ScalarNames.JSON, new JsonType() }, + { ScalarNames.LocalDate, new LocalDateType() }, + { ScalarNames.LocalDateTime, new LocalDateTimeType() }, + { ScalarNames.LocalTime, new LocalTimeType() }, + { ScalarNames.Long, new LongType() }, + { ScalarNames.Short, new ShortType() }, + { ScalarNames.String, new StringType() }, + { ScalarNames.TimeSpan, new TimeSpanType() }, + { ScalarNames.URL, new UrlType() }, + { ScalarNames.UUID, new UuidType() } + }.ToFrozenDictionary(); +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ValueNodeExtensions.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ValueNodeExtensions.cs new file mode 100644 index 00000000000..467e4a354ba --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Extensions/ValueNodeExtensions.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Nodes; +using HotChocolate.Language; +using HotChocolate.Types; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +internal static class ValueNodeExtensions +{ + public static JsonNode? ToJsonNode(this IValueNode valueNode, IType graphQLType) + { + var nullableType = graphQLType.NullableType(); + + return valueNode switch + { + BooleanValueNode booleanValueNode => JsonValue.Create(booleanValueNode.Value), + EnumValueNode enumValueNode => JsonValue.Create(enumValueNode.Value), + FloatValueNode floatValueNode => nullableType switch + { + DecimalType => JsonValue.Create(floatValueNode.ToDecimal()), + FloatType => JsonValue.Create(floatValueNode.ToDouble()), + // FIXME: Treating all unknown scalar types as strings is a temporary solution. + _ => JsonValue.Create(floatValueNode.Value) + }, + IntValueNode intValueNode => nullableType switch + { + ByteType => JsonValue.Create(intValueNode.ToByte()), + DecimalType => JsonValue.Create(intValueNode.ToDecimal()), + FloatType => JsonValue.Create(intValueNode.ToDouble()), + IntType => JsonValue.Create(intValueNode.ToInt32()), + LongType => JsonValue.Create(intValueNode.ToInt64()), + ShortType => JsonValue.Create(intValueNode.ToInt16()), + // FIXME: Treating all unknown scalar types as strings is a temporary solution. + _ => JsonValue.Create(intValueNode.Value) + }, + ListValueNode listValueNode => listValueNode.ToJsonNode(nullableType), + NullValueNode => null, + ObjectValueNode objectValueNode => objectValueNode.ToJsonNode(nullableType), + StringValueNode stringValueNode => JsonValue.Create(stringValueNode.Value), + _ => + throw new NotSupportedException( + string.Format( + ValueNodeExtensions_UnableToConvertValueNodeToJsonNode, + valueNode.GetType().Name)) + }; + } + + private static JsonArray ToJsonNode(this ListValueNode valueNode, IType listType) + { + var jsonArray = new JsonArray(); + + foreach (var item in valueNode.Items) + { + jsonArray.Add(item.ToJsonNode(listType.ElementType())); + } + + return jsonArray; + } + + private static JsonObject ToJsonNode(this ObjectValueNode valueNode, IType objectType) + { + var jsonObject = new JsonObject(); + + foreach (var field in valueNode.Fields) + { + var graphQLFieldType = objectType is InputObjectType inputObjectType + ? inputObjectType.Fields[field.Name.Value].Type + : new AnyType(); // Types like JsonType or AnyType have no schema. + + jsonObject.Add(field.Name.Value, field.Value.ToJsonNode(graphQLFieldType)); + } + + return jsonObject; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Factories/GraphQLMcpToolFactory.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Factories/GraphQLMcpToolFactory.cs new file mode 100644 index 00000000000..d9204fdfde1 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Factories/GraphQLMcpToolFactory.cs @@ -0,0 +1,364 @@ +using CaseConverter; +using HotChocolate.Execution.Processing; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Extensions; +using HotChocolate.Types; +using Json.Schema; +using ModelContextProtocol.Protocol; +using static HotChocolate.ModelContextProtocol.WellKnownArgumentNames; +using static HotChocolate.ModelContextProtocol.WellKnownDirectiveNames; + +namespace HotChocolate.ModelContextProtocol.Factories; + +internal sealed class GraphQLMcpToolFactory(ISchemaDefinition graphQLSchema) +{ + public GraphQLMcpTool CreateTool(string name, DocumentNode document) + { + var operationNode = document.Definitions.OfType().Single(); + var mcpToolDirective = operationNode.GetMcpToolDirective(); + var operationCompiler = new OperationCompiler(new InputParser()); + var operation = + operationCompiler.Compile( + new OperationCompilerRequest( + operationNode.Name!.Value, + document, + operationNode, + (ObjectType)graphQLSchema.GetOperationType(operationNode.Operation), + graphQLSchema)); + var inputSchema = CreateInputSchema(operationNode); + var outputSchema = CreateOutputSchema(operation); + + var tool = new Tool + { + Name = name, + Title = mcpToolDirective?.Title ?? operation.Name!.InsertSpaceBeforeUpperCase(), + Description = operationNode.Description?.Value, + InputSchema = inputSchema.ToJsonElement(), + OutputSchema = outputSchema.ToJsonElement(), + Annotations = new ToolAnnotations + { + DestructiveHint = GetDestructiveHint(operation), + IdempotentHint = GetIdempotentHint(operation), + OpenWorldHint = GetOpenWorldHint(operation), + ReadOnlyHint = operationNode.Operation is not OperationType.Mutation + } + }; + + return new GraphQLMcpTool(operation, tool); + } + + private JsonSchema CreateInputSchema(OperationDefinitionNode operation) + { + var properties = new Dictionary(); + var requiredProperties = new List(); + + foreach (var variableNode in operation.VariableDefinitions) + { + var graphQLType = variableNode.Type.GetGraphQLType(graphQLSchema); + var propertyBuilder = graphQLType.ToJsonSchemaBuilder(); + var variableName = variableNode.Variable.Name.Value; + + // Description. + if (variableNode.Description is not null) + { + propertyBuilder.Description(variableNode.Description.Value); + } + + // Default value. + if (variableNode.DefaultValue is not null) + { + propertyBuilder.Default(variableNode.DefaultValue.ToJsonNode(graphQLType)); + } + + // Required. + if (graphQLType.IsNonNullType() && variableNode.DefaultValue is null) + { + requiredProperties.Add(variableName); + } + + properties.Add(variableName, propertyBuilder); + } + + return + new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties(properties) + .Required(requiredProperties) + .Build(); + } + + private static JsonSchema CreateOutputSchema(IOperation operation) + { + return + new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties( + (WellKnownFieldNames.Data, CreateDataSchema(operation)), + (WellKnownFieldNames.Errors, s_errorSchema)) + .AdditionalProperties(false) + .Build(); + } + + private static JsonSchema CreateDataSchema(IOperation operation) + { + var properties = new Dictionary(); + var requiredProperties = new List(); + + foreach (var rootSelection in operation.RootSelectionSet.Selections) + { + var selectionState = rootSelection.GetSelectionState(); + + if (selectionState is SelectionState.Excluded) + { + continue; + } + + properties.Add( + rootSelection.ResponseName, + CreateOutputSchema(rootSelection, operation)); + + if (selectionState is SelectionState.Included) + { + requiredProperties.Add(rootSelection.ResponseName); + } + } + + return + new JsonSchemaBuilder() + .Type(SchemaValueType.Object | SchemaValueType.Null) + .Properties(properties) + .AdditionalProperties(false) + .Required(requiredProperties) + .Build(); + } + + private static JsonSchema CreateOutputSchema(ISelection selection, IOperation operation) + { + var schemaBuilder = selection.Field.Type.ToJsonSchemaBuilder(); + + if (selection.SelectionSet is not null) + { + var properties = new Dictionary(); + var requiredProperties = new List(); + + foreach (var type in operation.GetPossibleTypes(selection)) + { + var selectionSet = operation.GetSelectionSet(selection, type); + + foreach (var subSelection in selectionSet.Selections) + { + var selectionState = subSelection.GetSelectionState(); + + if (selectionState is SelectionState.Excluded) + { + continue; + } + + var propertyAdded = + properties.TryAdd( + subSelection.ResponseName, + CreateOutputSchema(subSelection, operation)); + + if (propertyAdded && selectionState is SelectionState.Included) + { + requiredProperties.Add(subSelection.ResponseName); + } + } + } + + if (selection.Field.Type.NullableType() is ListType listType) + { + var itemType = SchemaValueType.Object; + + if (listType.ElementType.IsNullableType()) + { + itemType |= SchemaValueType.Null; + } + + var arrayItemSchemaBuilder + = new JsonSchemaBuilder() + .Type(itemType) + .Properties(properties) + .Required(requiredProperties) + .AdditionalProperties(false); + + schemaBuilder.Items(arrayItemSchemaBuilder); + } + else + { + schemaBuilder + .Properties(properties) + .Required(requiredProperties) + .AdditionalProperties(false); + } + } + + // Description. + if (selection.Field.Description is not null) + { + schemaBuilder.Description(selection.Field.Description); + } + + return schemaBuilder.Build(); + } + + private static bool GetDestructiveHint(IOperation operation) + { + // @mcpTool operation directive. + if (operation.Definition.TryGetMcpToolDirective(out var mcpToolDirective) + && mcpToolDirective.DestructiveHint is { } destructiveHint) + { + return destructiveHint; + } + + // @mcpToolAnnotations field directive. + var destructiveHints = + operation.RootSelectionSet.Selections + .Select( + s => s + .Field.Directives[McpToolAnnotations] + .SingleOrDefault()? + .GetArgumentValue(DestructiveHint) + // Default to `true` for mutations. + ?? operation.Type is OperationType.Mutation) + .ToList(); + + // Return `true` if any of the destructive hints are `true`. + return destructiveHints.Any(d => d); + } + + private static bool GetIdempotentHint(IOperation operation) + { + // @mcpTool operation directive. + if (operation.Definition.TryGetMcpToolDirective(out var mcpToolDirective) + && mcpToolDirective.IdempotentHint is { } idempotentHint) + { + return idempotentHint; + } + + // @mcpToolAnnotations field directive. + var idempotentHints = + operation.RootSelectionSet.Selections + .Select( + s => s + .Field.Directives[McpToolAnnotations] + .SingleOrDefault()? + .GetArgumentValue(IdempotentHint) + // Default to `true` for queries and subscriptions. + ?? operation.Type is not OperationType.Mutation) + .ToList(); + + // Return `true` if all the idempotent hints are `true`. + return idempotentHints.All(i => i); + } + + private static bool GetOpenWorldHint(IOperation operation) + { + // @mcpTool operation directive. + if (operation.Definition.TryGetMcpToolDirective(out var mcpToolDirective) + && mcpToolDirective.OpenWorldHint is { } openWorldHint) + { + return openWorldHint; + } + + // @mcpToolAnnotations field directive. + List openWorldHints = []; + foreach (var rootSelection in operation.RootSelectionSet.Selections) + { + var rootOpenWorldHint = GetOpenWorldHint(rootSelection, operation); + + // Default to `true`. + openWorldHints.Add(rootOpenWorldHint ?? true); + } + + // Return `true` if any of the open world hints are `true`. + return openWorldHints.Any(i => i); + } + + private static bool? GetOpenWorldHint( + ISelection selection, + IOperation operation, + bool? parentOpenWorldHint = null) + { + var openWorldHint = + selection.Field.Directives[McpToolAnnotations] + .SingleOrDefault()? + .GetArgumentValue(OpenWorldHint) ?? parentOpenWorldHint; + + // Return early if the open world hint is explicitly set to `true`. + if (openWorldHint == true) + { + return openWorldHint; + } + + List openWorldHints = [openWorldHint]; + + if (selection.SelectionSet is not null) + { + foreach (var type in operation.GetPossibleTypes(selection)) + { + var selectionSet = operation.GetSelectionSet(selection, type); + + foreach (var subSelection in selectionSet.Selections) + { + openWorldHints.Add( + GetOpenWorldHint( + subSelection, + operation, + parentOpenWorldHint: openWorldHint)); + } + } + } + + return openWorldHints.All(o => o is null) + ? null + : openWorldHints.Any(o => o == true); + } + + private static readonly JsonSchema s_integerSchema = + new JsonSchemaBuilder() + .Type(SchemaValueType.Integer) + .Build(); + + private static readonly JsonSchema s_errorSchema = + new JsonSchemaBuilder() + .Type(SchemaValueType.Array) + .Items( + new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties( + ( + WellKnownFieldNames.Message, + new JsonSchemaBuilder().Type(SchemaValueType.String) + ), + ( + WellKnownFieldNames.Locations, + new JsonSchemaBuilder() + .Type(SchemaValueType.Array | SchemaValueType.Null) + .Items( + new JsonSchemaBuilder() + .Type(SchemaValueType.Object) + .Properties( + (WellKnownFieldNames.Line, s_integerSchema), + (WellKnownFieldNames.Column, s_integerSchema)) + .AdditionalProperties(false)) + ), + ( + WellKnownFieldNames.Path, + new JsonSchemaBuilder() + .Type(SchemaValueType.Array | SchemaValueType.Null) + .Items( + new JsonSchemaBuilder() + .Type(SchemaValueType.String | SchemaValueType.Integer)) + ), + ( + WellKnownFieldNames.Extensions, + new JsonSchemaBuilder() + .Type(SchemaValueType.Object | SchemaValueType.Null) + .AdditionalProperties(true) + )) + .Required(WellKnownFieldNames.Message) + .AdditionalProperties(false) + .Build()) + .Build(); +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/GraphQLMcpTool.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/GraphQLMcpTool.cs new file mode 100644 index 00000000000..52353cb44a7 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/GraphQLMcpTool.cs @@ -0,0 +1,9 @@ +using HotChocolate.Execution.Processing; +using ModelContextProtocol.Protocol; + +namespace HotChocolate.ModelContextProtocol; + +internal sealed record GraphQLMcpTool(IOperation Operation, Tool McpTool) +{ + public string Name => McpTool.Name; +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/CallToolHandler.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/CallToolHandler.cs new file mode 100644 index 00000000000..a187aa004fc --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/CallToolHandler.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using HotChocolate.Buffers; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Registries; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Handlers; + +internal static class CallToolHandler +{ + public static async ValueTask HandleAsync( + RequestContext context, + CancellationToken cancellationToken) + { + var registry = context.Services!.GetRequiredService(); + + if (!registry.TryGetTool(context.Params!.Name, out var graphQLMcpTool)) + { + return new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = string.Format(CallToolHandler_ToolNotFound, context.Params.Name) + } + ], + IsError = true + }; + } + + var requestExecutor = context.Services!.GetRequiredService(); + var arguments = + context.Params?.Arguments ?? Enumerable.Empty>(); + + Dictionary variableValues = []; + using var buffer = new PooledArrayWriter(); + var jsonValueParser = new JsonValueParser(buffer: buffer); + + foreach (var (name, value) in arguments) + { + variableValues.Add(name, jsonValueParser.Parse(value)); + } + + var result = + await requestExecutor.ExecuteAsync( + b => b + .SetDocument(graphQLMcpTool.Operation.Document) + .SetVariableValues(variableValues), + cancellationToken) + .ConfigureAwait(false); + + var operationResult = result.ExpectOperationResult(); + + return new CallToolResult + { + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content + // For backwards compatibility, a tool that returns structured content SHOULD + // also return functionally equivalent unstructured content. (For example, + // serialized JSON can be returned in a TextContent block.) + Content = [new TextContentBlock { Text = operationResult.ToJson() }], + StructuredContent = JsonNode.Parse(operationResult.ToJson()), + IsError = operationResult.Errors?.Any() + }; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/ListToolsHandler.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/ListToolsHandler.cs new file mode 100644 index 00000000000..256d76cbc19 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Handlers/ListToolsHandler.cs @@ -0,0 +1,19 @@ +using HotChocolate.ModelContextProtocol.Registries; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace HotChocolate.ModelContextProtocol.Handlers; + +internal static class ListToolsHandler +{ + public static ListToolsResult Handle(RequestContext context) + { + var registry = context.Services!.GetRequiredService(); + + return new ListToolsResult + { + Tools = registry.GetTools().Values.Select(t => t.McpTool).ToList() + }; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/HotChocolate.ModelContextProtocol.csproj b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/HotChocolate.ModelContextProtocol.csproj new file mode 100644 index 00000000000..a51113af55e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/HotChocolate.ModelContextProtocol.csproj @@ -0,0 +1,38 @@ + + + + HotChocolate.ModelContextProtocol + HotChocolate.ModelContextProtocol + + + + + + + + + + + + + + + + + + + + ResXFileCodeGenerator + ModelContextProtocolResources.Designer.cs + + + + + + True + True + ModelContextProtocolResources.resx + + + + diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/JsonSerializerContexts/JsonSchemaJsonSerializerContext.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/JsonSerializerContexts/JsonSchemaJsonSerializerContext.cs new file mode 100644 index 00000000000..53e8870529a --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/JsonSerializerContexts/JsonSchemaJsonSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using Json.Schema; + +namespace HotChocolate.ModelContextProtocol.JsonSerializerContexts; + +[JsonSerializable(typeof(JsonSchema))] +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization)] +internal partial class JsonSchemaJsonSerializerContext : JsonSerializerContext; diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.Designer.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.Designer.cs new file mode 100644 index 00000000000..849b2d99db8 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.Designer.cs @@ -0,0 +1,134 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace HotChocolate.ModelContextProtocol.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ModelContextProtocolResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ModelContextProtocolResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources", typeof(ModelContextProtocolResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The tool '{0}' was not found.. + /// + internal static string CallToolHandler_ToolNotFound { + get { + return ResourceManager.GetString("CallToolHandler_ToolNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A tool document must contain a single operation definition.. + /// + internal static string InMemoryMcpOperationDocumentStorage_ToolDocumentMustContainSingleOperation { + get { + return ResourceManager.GetString("InMemoryMcpOperationDocumentStorage_ToolDocumentMustContainSingleOperation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A tool document operation with the same name already exists.. + /// + internal static string InMemoryMcpOperationDocumentStorage_ToolDocumentOperationAlreadyExists { + get { + return ResourceManager.GetString("InMemoryMcpOperationDocumentStorage_ToolDocumentOperationAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A tool document operation must be named.. + /// + internal static string InMemoryMcpOperationDocumentStorage_ToolDocumentOperationMustBeNamed { + get { + return ResourceManager.GetString("InMemoryMcpOperationDocumentStorage_ToolDocumentOperationMustBeNamed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The argument `{0}` is not supported on @mcpTool.. + /// + internal static string McpToolDirectiveParser_ArgumentNotSupportedOnMcpToolDirective { + get { + return ResourceManager.GetString("McpToolDirectiveParser_ArgumentNotSupportedOnMcpToolDirective", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to determine the JSON schema value type for the GraphQL type '{0}'.. + /// + internal static string TypeExtensions_UnableToDetermineJsonSchemaValueType { + get { + return ResourceManager.GetString("TypeExtensions_UnableToDetermineJsonSchemaValueType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to find GraphQL type '{0}' in the schema or type map.. + /// + internal static string TypeNodeExtensions_UnableToFindGraphQLTypeInSchemaOrTypeMap { + get { + return ResourceManager.GetString("TypeNodeExtensions_UnableToFindGraphQLTypeInSchemaOrTypeMap", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to convert value node of type '{0}' to JSON node.. + /// + internal static string ValueNodeExtensions_UnableToConvertValueNodeToJsonNode { + get { + return ResourceManager.GetString("ValueNodeExtensions_UnableToConvertValueNodeToJsonNode", resourceCulture); + } + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.resx b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.resx new file mode 100644 index 00000000000..3484ef6556d --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Properties/ModelContextProtocolResources.resx @@ -0,0 +1,45 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The tool '{0}' was not found. + + + A tool document must contain a single operation definition. + + + A tool document operation with the same name already exists. + + + A tool document operation must be named. + + + The argument `{0}` is not supported on @mcpTool. + + + Unable to determine the JSON schema value type for the GraphQL type '{0}'. + + + Unable to find GraphQL type '{0}' in the schema or type map. + + + Unable to convert value node of type '{0}' to JSON node. + + diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/McpRequestExecutorProxy.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/McpRequestExecutorProxy.cs new file mode 100644 index 00000000000..35ac24880aa --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/McpRequestExecutorProxy.cs @@ -0,0 +1,55 @@ +using HotChocolate.Execution; +using HotChocolate.Features; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore; + +namespace HotChocolate.ModelContextProtocol.Proxies; + +internal sealed class McpRequestExecutorProxy( + IRequestExecutorProvider executorProvider, + IRequestExecutorEvents executorEvents, + string schemaName) + : RequestExecutorProxy(executorProvider, executorEvents, schemaName) +{ + private McpExecutorSession? _session; + + public McpExecutorSession GetOrCreateSession() + { + return _session + ?? Task.Factory + .StartNew(async () => await GetOrCreateSessionAsync(CancellationToken.None)) + .Unwrap() + .GetAwaiter() + .GetResult(); + } + + public async ValueTask GetOrCreateSessionAsync( + CancellationToken cancellationToken) + { + if (_session is not null) + { + return _session; + } + + var executor = await GetExecutorAsync(cancellationToken).ConfigureAwait(false); + return executor.Features.GetRequired(); + } + + protected override void OnRequestExecutorUpdated(IRequestExecutor? executor) + { + if (executor is not null) + { + var session = + new McpExecutorSession( + executor.Schema.Services.GetRequiredService(), + executor.Schema.Services.GetRequiredService()); + + executor.Features.Set(session); + _session = session; + } + } +} + +internal sealed record McpExecutorSession( + StreamableHttpHandler StreamableHttpHandler, + SseHandler SseHandler); diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/SseHandlerProxy.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/SseHandlerProxy.cs new file mode 100644 index 00000000000..f0491b85b42 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/SseHandlerProxy.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Http; + +namespace HotChocolate.ModelContextProtocol.Proxies; + +internal sealed class SseHandlerProxy +{ + private readonly McpRequestExecutorProxy _mcpRequestExecutor; + + public SseHandlerProxy(McpRequestExecutorProxy mcpRequestExecutor) + { + ArgumentNullException.ThrowIfNull(mcpRequestExecutor); + _mcpRequestExecutor = mcpRequestExecutor; + } + + public async Task HandleSseRequestAsync(HttpContext context) + { + var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + await session.SseHandler.HandleSseRequestAsync(context); + } + + public async Task HandleMessageRequestAsync(HttpContext context) + { + var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + await session.SseHandler.HandleMessageRequestAsync(context); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/StreamableHttpHandlerProxy.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/StreamableHttpHandlerProxy.cs new file mode 100644 index 00000000000..13592faa2f1 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Proxies/StreamableHttpHandlerProxy.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.AspNetCore; + +namespace HotChocolate.ModelContextProtocol.Proxies; + +internal sealed class StreamableHttpHandlerProxy +{ + private readonly McpRequestExecutorProxy _mcpRequestExecutor; + + public StreamableHttpHandlerProxy(McpRequestExecutorProxy mcpRequestExecutor) + { + ArgumentNullException.ThrowIfNull(mcpRequestExecutor); + _mcpRequestExecutor = mcpRequestExecutor; + } + + public HttpServerTransportOptions HttpServerTransportOptions + => _mcpRequestExecutor.GetOrCreateSession().StreamableHttpHandler.HttpServerTransportOptions; + + public async Task HandlePostRequestAsync(HttpContext context) + { + var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + await session.StreamableHttpHandler.HandlePostRequestAsync(context); + } + + public async Task HandleGetRequestAsync(HttpContext context) + { + var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + await session.StreamableHttpHandler.HandleGetRequestAsync(context); + } + + public async Task HandleDeleteRequestAsync(HttpContext context) + { + var session = await _mcpRequestExecutor.GetOrCreateSessionAsync(context.RequestAborted); + await session.StreamableHttpHandler.HandleDeleteRequestAsync(context); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Registries/GraphQLMcpToolRegistry.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Registries/GraphQLMcpToolRegistry.cs new file mode 100644 index 00000000000..505e754e77b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Registries/GraphQLMcpToolRegistry.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HotChocolate.ModelContextProtocol.Registries; + +internal sealed class GraphQLMcpToolRegistry +{ + private readonly Dictionary _tools = []; + + public void Add(GraphQLMcpTool tool) + { + _tools[tool.Name] = tool; + } + + public Dictionary GetTools() + { + return _tools; + } + + public bool TryGetTool(string name, [NotNullWhen(true)] out GraphQLMcpTool? tool) + { + return _tools.TryGetValue(name, out tool); + } + + public void Clear() + { + _tools.Clear(); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/IMcpOperationDocumentStorage.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/IMcpOperationDocumentStorage.cs new file mode 100644 index 00000000000..0062dd674d6 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/IMcpOperationDocumentStorage.cs @@ -0,0 +1,13 @@ +using HotChocolate.Language; + +namespace HotChocolate.ModelContextProtocol.Storage; + +public interface IMcpOperationDocumentStorage +{ + ValueTask> GetToolDocumentsAsync( + CancellationToken cancellationToken = default); + + ValueTask SaveToolDocumentAsync( + DocumentNode document, + CancellationToken cancellationToken = default); +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/InMemoryMcpOperationDocumentStorage.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/InMemoryMcpOperationDocumentStorage.cs new file mode 100644 index 00000000000..93fc629e0d1 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Storage/InMemoryMcpOperationDocumentStorage.cs @@ -0,0 +1,45 @@ +using CaseConverter; +using HotChocolate.Language; +using static HotChocolate.ModelContextProtocol.Properties.ModelContextProtocolResources; + +namespace HotChocolate.ModelContextProtocol.Storage; + +public sealed class InMemoryMcpOperationDocumentStorage : IMcpOperationDocumentStorage +{ + private readonly Dictionary _tools = []; + + public ValueTask> GetToolDocumentsAsync( + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(_tools); + } + + public ValueTask SaveToolDocumentAsync( + DocumentNode document, + CancellationToken cancellationToken = default) + { + var operationDefinitions = document.Definitions + .OfType() + .ToList(); + + if (operationDefinitions.Count != 1) + { + throw new Exception( + InMemoryMcpOperationDocumentStorage_ToolDocumentMustContainSingleOperation); + } + + if (operationDefinitions[0].Name is not { } nameNode) + { + throw new Exception( + InMemoryMcpOperationDocumentStorage_ToolDocumentOperationMustBeNamed); + } + + if (!_tools.TryAdd(nameNode.Value.ToSnakeCase(), document)) + { + throw new Exception( + InMemoryMcpOperationDocumentStorage_ToolDocumentOperationAlreadyExists); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Types/McpToolAnnotationsDirectiveType.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Types/McpToolAnnotationsDirectiveType.cs new file mode 100644 index 00000000000..54b60d58339 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/Types/McpToolAnnotationsDirectiveType.cs @@ -0,0 +1,43 @@ +using HotChocolate.ModelContextProtocol.Directives; +using HotChocolate.Types; + +namespace HotChocolate.ModelContextProtocol.Types; + +/// +/// Additional properties describing a Tool to clients. +/// +public sealed class McpToolAnnotationsDirectiveType : DirectiveType +{ + private const string DirectiveName = "mcpToolAnnotations"; + + protected override void Configure( + IDirectiveTypeDescriptor descriptor) + { + descriptor + .Name(DirectiveName) + .Description("Additional properties describing a Tool to clients.") + .Location(DirectiveLocation.FieldDefinition); + + descriptor + .Argument(d => d.DestructiveHint) + .Type() + .Description( + "If `true`, the tool may perform destructive updates to its environment. If " + + "`false`, the tool performs only additive updates."); + + descriptor + .Argument(d => d.IdempotentHint) + .Type() + .Description( + "If `true`, calling the tool repeatedly with the same arguments will have no " + + "additional effect on its environment."); + + descriptor + .Argument(d => d.OpenWorldHint) + .Type() + .Description( + "If `true`, this tool may interact with an “open world” of external entities. If " + + "`false`, the tool’s domain of interaction is closed. For example, the world of " + + "a web search tool is open, whereas that of a memory tool is not."); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownArgumentNames.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownArgumentNames.cs new file mode 100644 index 00000000000..5563cf0ef12 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownArgumentNames.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.ModelContextProtocol; + +internal static class WellKnownArgumentNames +{ + public const string If = "if"; + public const string Title = "title"; + public const string DestructiveHint = "destructiveHint"; + public const string IdempotentHint = "idempotentHint"; + public const string OpenWorldHint = "openWorldHint"; +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownDirectiveNames.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownDirectiveNames.cs new file mode 100644 index 00000000000..eedb0beca7b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownDirectiveNames.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.ModelContextProtocol; + +internal static class WellKnownDirectiveNames +{ + public const string Include = "include"; + public const string McpTool = "mcpTool"; + public const string McpToolAnnotations = "mcpToolAnnotations"; + public const string Skip = "skip"; +} diff --git a/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownFieldNames.cs b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownFieldNames.cs new file mode 100644 index 00000000000..39bd254c68c --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/WellKnownFieldNames.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.ModelContextProtocol; + +internal static class WellKnownFieldNames +{ + public const string Column = "column"; + public const string Data = "data"; + public const string Errors = "errors"; + public const string Extensions = "extensions"; + public const string Line = "line"; + public const string Locations = "locations"; + public const string Message = "message"; + public const string Path = "path"; +} diff --git a/src/HotChocolate/ModelContextProtocol/test/Directory.Build.props b/src/HotChocolate/ModelContextProtocol/test/Directory.Build.props new file mode 100644 index 00000000000..d1285228361 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/Directory.Build.props @@ -0,0 +1,33 @@ + + + + + false + false + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Extensions/TypeExtensionsTests.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Extensions/TypeExtensionsTests.cs new file mode 100644 index 00000000000..e8b29884b49 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,105 @@ +using System.Text.RegularExpressions; +using HotChocolate.Types; +using Json.Schema; + +namespace HotChocolate.ModelContextProtocol.Extensions; + +public sealed class TypeExtensionsTests +{ + [Theory] + [InlineData(typeof(ByteArrayType), "dmFsdWU=")] + // A DateTime with UTC offset (+00:00). + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108Z")] + // A DateTime with +00:00 which is the same as UTC. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108+00:00")] + // The z and t may be lower case. + [InlineData(typeof(DateTimeType), "2011-08-30t13:22:53.108z")] + // A DateTime with -3h offset. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108-03:00")] + // A DateTime with +3h 30min offset. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108+03:30")] + // A DateTime with 7 fractional digits. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.1230000+03:30")] + // A DateTime with no fractional seconds. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53+03:30")] + [InlineData(typeof(LocalDateTimeType), "2011-08-30T13:22:53")] + [InlineData(typeof(LocalTimeType), "13:22:53")] + [InlineData(typeof(TimeSpanType), "P18M")] + [InlineData(typeof(TimeSpanType), "PT1H30M")] + [InlineData(typeof(TimeSpanType), "P1Y2M3DT4H5M6S")] + [InlineData(typeof(TimeSpanType), "PT4.567S")] + public void ToJsonSchemaBuilder_ValidValues_MatchPattern(Type type, string value) + { + // arrange + var instance = (IType)Activator.CreateInstance(type)!; + + // act + var jsonSchema = instance.ToJsonSchemaBuilder().Build(); + var regex = new Regex(jsonSchema.GetPatternValue()!); + + // assert + Assert.Matches(regex, value); + } + + [Theory] + [InlineData(typeof(ByteArrayType), "invalidBase64")] + // The minutes of the offset are missing. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108-03")] + // No offset provided. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108")] + // No time provided. + [InlineData(typeof(DateTimeType), "2011-08-30")] + // Seconds are not allowed for the offset. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.108+03:30:15")] + // A DateTime with 8 fractional digits. + [InlineData(typeof(DateTimeType), "2011-08-30T13:22:53.12345678+03:30")] + [InlineData(typeof(LocalDateTimeType), "2018/06/11T08:46:14 pm")] + [InlineData(typeof(LocalDateTimeType), "abc")] + [InlineData(typeof(LocalTimeType), "08:46:14 pm")] + [InlineData(typeof(LocalTimeType), "abc")] + [InlineData(typeof(TimeSpanType), "bad")] + public void ToJsonSchemaBuilder_InvalidValues_DoNotMatchPattern(Type type, string value) + { + // arrange + var instance = (IType)Activator.CreateInstance(type)!; + + // act + var jsonSchema = instance.ToJsonSchemaBuilder().Build(); + var regex = new Regex(jsonSchema.GetPatternValue()!); + + // assert + Assert.DoesNotMatch(regex, value); + } + + [Theory] + [InlineData("-10675199.02:48:05.4775808")] // TimeSpan.MinValue. + [InlineData("10675199.02:48:05.4775807")] // TimeSpan.MaxValue. + public void ToJsonSchemaBuilder_TimeSpanTypeDotNetValidValues_MatchPattern(string value) + { + // arrange + var timeSpanType = new TimeSpanType(TimeSpanFormat.DotNet); + + // act + var jsonSchema = timeSpanType.ToJsonSchemaBuilder().Build(); + var regex = new Regex(jsonSchema.GetPatternValue()!); + + // assert + Assert.Matches(regex, value); + } + + [Theory] + [InlineData("bad")] // Invalid format. + [InlineData("+01:30:00")] // A leading plus sign is not allowed. + public void ToJsonSchemaBuilder_TimeSpanTypeDotNetInvalidValues_DoNotMatchPattern(string value) + { + // arrange + var timeSpanType = new TimeSpanType(TimeSpanFormat.DotNet); + + // act + var jsonSchema = timeSpanType.ToJsonSchemaBuilder().Build(); + var regex = new Regex(jsonSchema.GetPatternValue()!); + + // assert + Assert.DoesNotMatch(regex, value); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/GraphQLMcpToolFactoryTests.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/GraphQLMcpToolFactoryTests.cs new file mode 100644 index 00000000000..3bc59c9b819 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/GraphQLMcpToolFactoryTests.cs @@ -0,0 +1,770 @@ +using CookieCrumble; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Extensions; +using HotChocolate.Types; + +namespace HotChocolate.ModelContextProtocol.Factories; + +public sealed class GraphQLMcpToolFactoryTests +{ + [Fact] + public void CreateTool_DocumentWithNoOperations_ThrowsException() + { + // arrange & act + static GraphQLMcpTool Action() + { + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse("fragment Fragment on Type { field }"); + + return new GraphQLMcpToolFactory(schema).CreateTool("", document); + } + + // assert + Assert.Throws(Action); + } + + [Fact] + public void CreateTool_DocumentWithMultipleOperations_ThrowsException() + { + // arrange & act + static GraphQLMcpTool Action() + { + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query Operation1 { + query { + field + } + } + + query Operation2 { + query { + field + } + } + """); + + return new GraphQLMcpToolFactory(schema).CreateTool("", document); + } + + // assert + Assert.Throws(Action); + } + + [Fact] + public void CreateTool_ValidQueryDocument_ReturnsTool() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + "Get books" + query GetBooks { + books { + title + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("get_books", document); + var mcpTool = tool.McpTool; + + // assert + Assert.Equal("get_books", mcpTool.Name); + Assert.Equal("Get Books", mcpTool.Title); + Assert.Equal("Get books", mcpTool.Description); + Assert.Equal(false, mcpTool.Annotations?.DestructiveHint); + Assert.Equal(true, mcpTool.Annotations?.IdempotentHint); + Assert.Equal(true, mcpTool.Annotations?.OpenWorldHint); + Assert.Equal(true, mcpTool.Annotations?.ReadOnlyHint); + } + + [Fact] + public void CreateTool_ValidMutationDocument_ReturnsTool() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + "Add book" + mutation AddBook { + addBook { + title + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("add_book", document); + var mcpTool = tool.McpTool; + + // assert + Assert.Equal("add_book", mcpTool.Name); + Assert.Equal("Add Book", mcpTool.Title); + Assert.Equal("Add book", mcpTool.Description); + Assert.Equal(true, mcpTool.Annotations?.DestructiveHint); + Assert.Equal(false, mcpTool.Annotations?.IdempotentHint); + Assert.Equal(true, mcpTool.Annotations?.OpenWorldHint); + Assert.Equal(false, mcpTool.Annotations?.ReadOnlyHint); + } + + [Fact] + public void CreateTool_ValidSubscriptionDocument_ReturnsTool() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + "Book added" + subscription BookAdded { + bookAdded { + title + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("book_added", document); + var mcpTool = tool.McpTool; + + // assert + Assert.Equal("book_added", mcpTool.Name); + Assert.Equal("Book Added", mcpTool.Title); + Assert.Equal("Book added", mcpTool.Description); + Assert.Equal(false, mcpTool.Annotations?.DestructiveHint); + Assert.Equal(true, mcpTool.Annotations?.IdempotentHint); + Assert.Equal(true, mcpTool.Annotations?.OpenWorldHint); + Assert.Equal(true, mcpTool.Annotations?.ReadOnlyHint); + } + + [Fact] + public void CreateTool_SetTitle_ReturnsTool() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetBooks @mcpTool(title: "Custom Title") { + books { + title + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("get_books", document); + + // assert + Assert.Equal("Custom Title", tool.McpTool.Title); + } + + [Fact] + public void CreateTool_SetAnnotations_ReturnsTool() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + mutation AddBook + @mcpTool(destructiveHint: false, idempotentHint: true, openWorldHint: false) { + addBook { + title + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("add_book", document); + var mcpTool = tool.McpTool; + + // assert + Assert.Equal(false, mcpTool.Annotations?.DestructiveHint); + Assert.Equal(true, mcpTool.Annotations?.IdempotentHint); + Assert.Equal(false, mcpTool.Annotations?.OpenWorldHint); + } + + [Fact] + public void CreateTool_WithNullableVariables_CreatesCorrectSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithNullableVariables.graphql")); + + // act + var tool = + new GraphQLMcpToolFactory(schema).CreateTool("get_with_nullable_variables", document); + var mcpTool = tool.McpTool; + + // assert + mcpTool.InputSchema.MatchSnapshot(postFix: "Input", extension: ".json"); + mcpTool.OutputSchema.MatchSnapshot(postFix: "Output", extension: ".json"); + } + + [Fact] + public void CreateTool_WithNonNullableVariables_CreatesCorrectSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithNonNullableVariables.graphql")); + + // act + var tool = + new GraphQLMcpToolFactory(schema) + .CreateTool("get_with_non_nullable_variables", document); + var mcpTool = tool.McpTool; + + // assert + mcpTool.InputSchema.MatchSnapshot(postFix: "Input", extension: ".json"); + mcpTool.OutputSchema.MatchSnapshot(postFix: "Output", extension: ".json"); + } + + [Fact] + public void CreateTool_WithDefaultedVariables_CreatesCorrectSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithDefaultedVariables.graphql")); + + // act + var tool = + new GraphQLMcpToolFactory(schema).CreateTool("get_with_defaulted_variables", document); + var mcpTool = tool.McpTool; + + // assert + mcpTool.InputSchema.MatchSnapshot(postFix: "Input", extension: ".json"); + mcpTool.OutputSchema.MatchSnapshot(postFix: "Output", extension: ".json"); + } + + [Fact] + public void CreateTool_WithComplexVariables_CreatesCorrectSchema() + { + // arrange + var schema = CreateSchema(s => s.AddType(new TimeSpanType(TimeSpanFormat.DotNet))); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithComplexVariables.graphql")); + + // act + var tool = + new GraphQLMcpToolFactory(schema).CreateTool("get_with_complex_variables", document); + var mcpTool = tool.McpTool; + + // assert + mcpTool.InputSchema.MatchSnapshot(postFix: "Input", extension: ".json"); + mcpTool.OutputSchema.MatchSnapshot(postFix: "Output", extension: ".json"); + } + + [Fact] + public void CreateTool_WithVariableMinMaxValues_CreatesCorrectInputSchema() + { + // arrange + var schema = + CreateSchema( + s => s + .AddType(new ByteType(min: 1, max: 10)) + .AddType(new DecimalType(min: 1, max: 10)) + .AddType(new FloatType(min: 1.0, max: 10.0)) + .AddType(new IntType(min: 1, max: 10)) + .AddType(new LongType(min: 1, max: 10)) + .AddType(new ShortType(min: 1, max: 10))); + var document = Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/GetWithVariableMinMaxValues.graphql")); + + // act + var tool = + new GraphQLMcpToolFactory(schema) + .CreateTool("get_with_variable_min_max_values", document); + + // assert + tool.McpTool.InputSchema.MatchSnapshot(extension: ".json"); + } + + [Fact] + public void CreateTool_WithInterfaceType_CreatesCorrectOutputSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetWithInterfaceType { + withInterfaceType { + __typename + name + ... on Cat { + isPurring + } + ... on Dog { + isBarking + } + } + } + """); + + // act + var tool = + new GraphQLMcpToolFactory(schema).CreateTool("get_with_interface_type", document); + + // assert + tool.McpTool.OutputSchema.MatchSnapshot(extension: ".json"); + } + + [Fact] + public void CreateTool_WithUnionType_CreatesCorrectOutputSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetWithUnionType { + withUnionType { + __typename + ... on Cat { + isPurring + } + ... on Dog { + isBarking + } + } + } + """); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("get_with_union_type", document); + + // assert + tool.McpTool.OutputSchema.MatchSnapshot(extension: ".json"); + } + + [Fact] + public void CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema() + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse( + """ + query GetWithSkipAndInclude($skip: Boolean!, $include: Boolean!) { + withDefaultedVariables { + # Skip + skipped: int @skip(if: true) + notSkipped: int @skip(if: false) + possiblySkipped: int @skip(if: $skip) + # Include + included: int @include(if: true) + notIncluded: int @include(if: false) + possiblyIncluded: int @include(if: $include) + # Skip and Include (excluded) + skippedAndIncluded: int @skip(if: true) @include(if: true) + skippedAndNotIncluded: int @skip(if: true) @include(if: false) + skippedAndPossiblyIncluded: int @skip(if: true) @include(if: $include) + notSkippedAndNotIncluded: int @skip(if: false) @include(if: false) + possiblySkippedAndNotIncluded: int @skip(if: $skip) @include(if: false) + # Skip and Include (included) + notSkippedAndIncluded: int @skip(if: false) @include(if: true) + notSkippedAndPossiblyIncluded: int @skip(if: false) @include(if: $include) + possiblySkippedAndIncluded: int @skip(if: $skip) @include(if: true) + possiblySkippedAndPossiblyIncluded: int + @skip(if: $skip) + @include(if: $include) + # Object field (nested fields are still required) + objectFieldPossiblySkipped: object @skip(if: $skip) { + field1A { field1B { field1C } } + } + # Fragment spread + ...Fragment @skip(if: $skip) + # Inline fragment + ... @skip(if: $skip) { + inlineFragmentFieldPossiblySkipped: int + } + } + } + + fragment Fragment on Object1Defaulted { + field1A { field1B { field1C } } + } + """); + + // act + var tool = + new GraphQLMcpToolFactory(schema).CreateTool("get_with_skip_and_include", document); + + // assert + tool.McpTool.OutputSchema.MatchSnapshot(extension: ".json"); + } + + [Theory] + [InlineData("ImplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitNonDestructiveTool.graphql", false)] + public void CreateTool_McpToolAnnotationsDestructiveHintImplementationFirst_SetsCorrectHint( + string fileName, + bool destructiveHint) + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(destructiveHint, tool.McpTool.Annotations?.DestructiveHint); + } + + [Theory] + [InlineData("ImplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitNonDestructiveTool.graphql", false)] + public void CreateTool_McpToolAnnotationsCodeFirst_SetsCorrectHint( + string fileName, + bool destructiveHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddMutationType( + descriptor => + { + descriptor.Name(OperationTypeNames.Mutation); + + descriptor + .Field("implicitDestructiveMutation") + .Type(); + + descriptor + .Field("explicitDestructiveMutation") + .Type() + .McpToolAnnotations(destructiveHint: true); + + descriptor + .Field("explicitNonDestructiveMutation") + .Type() + .McpToolAnnotations(destructiveHint: false); + }) + .Use(next => next) + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(destructiveHint, tool.McpTool.Annotations?.DestructiveHint); + } + + [Theory] + [InlineData("ImplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitDestructiveTool.graphql", true)] + [InlineData("ExplicitNonDestructiveTool.graphql", false)] + public void CreateTool_McpToolAnnotationsDestructiveHintSchemaFirst_SetsCorrectHint( + string fileName, + bool destructiveHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddDocumentFromString( + """ + type Mutation { + implicitDestructiveMutation: Int + explicitDestructiveMutation: Int + @mcpToolAnnotations(destructiveHint: true) + explicitNonDestructiveMutation: Int + @mcpToolAnnotations(destructiveHint: false) + } + """) + .Use(next => next) + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(destructiveHint, tool.McpTool.Annotations?.DestructiveHint); + } + + [Theory] + [InlineData("ImplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitIdempotentTool.graphql", true)] + public void CreateTool_McpToolAnnotationsIdempotentHintImplementationFirst_SetsCorrectHint( + string fileName, + bool idempotentHint) + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(idempotentHint, tool.McpTool.Annotations?.IdempotentHint); + } + + [Theory] + [InlineData("ImplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitIdempotentTool.graphql", true)] + public void CreateTool_McpToolAnnotationsIdempotentHintCodeFirst_SetsCorrectHint( + string fileName, + bool idempotentHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddMutationType( + descriptor => + { + descriptor.Name(OperationTypeNames.Mutation); + + descriptor + .Field("implicitNonIdempotentMutation") + .Type(); + + descriptor + .Field("explicitNonIdempotentMutation") + .Type() + .McpToolAnnotations(idempotentHint: false); + + descriptor + .Field("explicitIdempotentMutation") + .Type() + .McpToolAnnotations(idempotentHint: true); + }) + .Use(next => next) + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(idempotentHint, tool.McpTool.Annotations?.IdempotentHint); + } + + [Theory] + [InlineData("ImplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitNonIdempotentTool.graphql", false)] + [InlineData("ExplicitIdempotentTool.graphql", true)] + public void CreateTool_McpToolAnnotationsIdempotentHintSchemaFirst_SetsCorrectHint( + string fileName, + bool idempotentHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddDocumentFromString( + """ + type Mutation { + implicitNonIdempotentMutation: Int + explicitNonIdempotentMutation: Int + @mcpToolAnnotations(idempotentHint: false) + explicitIdempotentMutation: Int + @mcpToolAnnotations(idempotentHint: true) + } + """) + .Use(next => next) + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(idempotentHint, tool.McpTool.Annotations?.IdempotentHint); + } + + [Theory] + [InlineData("ImplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitClosedWorldTool.graphql", false)] + [InlineData("ExplicitOpenWorldSubfieldTool.graphql", true)] + [InlineData("ImplicitClosedWorldSubfieldTool.graphql", false)] + [InlineData("ExplicitClosedWorldSubfieldTool.graphql", false)] + public void CreateTool_McpToolAnnotationsOpenWorldHintImplementationFirst_SetsCorrectHint( + string fileName, + bool openWorldHint) + { + // arrange + var schema = CreateSchema(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(openWorldHint, tool.McpTool.Annotations?.OpenWorldHint); + } + + [Theory] + [InlineData("ImplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitClosedWorldTool.graphql", false)] + [InlineData("ExplicitOpenWorldSubfieldTool.graphql", true)] + [InlineData("ImplicitClosedWorldSubfieldTool.graphql", false)] + [InlineData("ExplicitClosedWorldSubfieldTool.graphql", false)] + public void CreateTool_McpToolAnnotationsOpenWorldHintCodeFirst_SetsCorrectHint( + string fileName, + bool openWorldHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddQueryType( + descriptor => + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("implicitOpenWorldQuery") + .Type(); + + descriptor + .Field("explicitOpenWorldQuery") + .Type() + .McpToolAnnotations(openWorldHint: true); + + descriptor + .Field("explicitClosedWorldQuery") + .Type() + .McpToolAnnotations(openWorldHint: false); + + descriptor + .Field("explicitOpenWorldSubfieldQuery") + .Type(typeof(TestSchema.ExplicitOpenWorld)) + .McpToolAnnotations(openWorldHint: false); + + descriptor + .Field("implicitClosedWorldSubfieldQuery") + .Type(typeof(TestSchema.ImplicitClosedWorld)) + .McpToolAnnotations(openWorldHint: false); + + descriptor + .Field("explicitClosedWorldSubfieldQuery") + .Type(typeof(TestSchema.ExplicitClosedWorld)) + .McpToolAnnotations(openWorldHint: false); + }) + .Use(next => next) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(openWorldHint, tool.McpTool.Annotations?.OpenWorldHint); + } + + [Theory] + [InlineData("ImplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitOpenWorldTool.graphql", true)] + [InlineData("ExplicitClosedWorldTool.graphql", false)] + [InlineData("ExplicitOpenWorldSubfieldTool.graphql", true)] + [InlineData("ImplicitClosedWorldSubfieldTool.graphql", false)] + [InlineData("ExplicitClosedWorldSubfieldTool.graphql", false)] + public void CreateTool_McpToolAnnotationsOpenWorldHintSchemaFirst_SetsCorrectHint( + string fileName, + bool openWorldHint) + { + // arrange + var schema = + SchemaBuilder + .New() + .AddMcp() + .AddDocumentFromString( + """ + type Query { + implicitOpenWorldQuery: Int + explicitOpenWorldQuery: Int + @mcpToolAnnotations(openWorldHint: true) + explicitClosedWorldQuery: Int + @mcpToolAnnotations(openWorldHint: false) + explicitOpenWorldSubfieldQuery: ExplicitOpenWorld + @mcpToolAnnotations(openWorldHint: false) + implicitClosedWorldSubfieldQuery: ImplicitClosedWorld + @mcpToolAnnotations(openWorldHint: false) + explicitClosedWorldSubfieldQuery: ExplicitClosedWorld + @mcpToolAnnotations(openWorldHint: false) + } + + type ExplicitOpenWorld { + explicitOpenWorldField: Int @mcpToolAnnotations(openWorldHint: true) + } + + type ImplicitClosedWorld { + implicitClosedWorldField: Int + } + + type ExplicitClosedWorld { + explicitClosedWorldField: Int @mcpToolAnnotations(openWorldHint: false) + } + """) + .Use(next => next) + .Create(); + var document = Utf8GraphQLParser.Parse(File.ReadAllText($"__resources__/{fileName}")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + + // assert + Assert.Equal(openWorldHint, tool.McpTool.Annotations?.OpenWorldHint); + } + + [Fact] + public void CreateTool_McpToolAnnotationsWithFragment_SetsCorrectHints() + { + // arrange + var schema = CreateSchema(); + var document = + Utf8GraphQLParser.Parse( + File.ReadAllText("__resources__/AnnotationsWithFragment.graphql")); + + // act + var tool = new GraphQLMcpToolFactory(schema).CreateTool("", document); + var mcpTool = tool.McpTool; + + // assert + Assert.Equal(true, mcpTool.Annotations?.DestructiveHint); + Assert.Equal(false, mcpTool.Annotations?.IdempotentHint); + Assert.Equal(true, mcpTool.Annotations?.OpenWorldHint); + } + + private static Schema CreateSchema(Action? configure = null) + { + var schemaBuilder = + SchemaBuilder + .New() + .ModifyOptions(o => o.StripLeadingIFromInterface = true) + .AddMcp() + .AddQueryType() + .AddMutationType() + .AddSubscriptionType() + .AddInterfaceType() + .AddUnionType() + .AddObjectType() + .AddObjectType(); + + configure?.Invoke(schemaBuilder); + + return schemaBuilder.Create(); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json new file mode 100644 index 00000000000..8a878a65984 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Input.json @@ -0,0 +1,328 @@ +{ + "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" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "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" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "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" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "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", + "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", + "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", + "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 + } + } + }, + "timeSpanDotNet": { + "type": "string", + "pattern": "^-?(?:(?:\\d{1,8})\\.)?(?:[0-1]?\\d|2[0-3]):(?:[0-5]?\\d):(?:[0-5]?\\d)(?:\\.(?:\\d{1,7}))?$", + "description": "TimeSpan with DotNet format", + "default": "00:05:00" + } + }, + "required": [] +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Output.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Output.json new file mode 100644 index 00000000000..fd00ba452bc --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithComplexVariables_CreatesCorrectSchema_Output.json @@ -0,0 +1,323 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withComplexVariables": { + "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" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false + }, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + } + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false + }, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "nullDefault": { + "type": [ + "string", + "null" + ] + }, + "listWithNullDefault": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "objectWithNullDefault": { + "type": [ + "object", + "null" + ], + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false + }, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "oneOf": { + "type": "object", + "properties": { + "field1": { + "type": [ + "integer", + "null" + ], + "description": "field1 description" + }, + "field2": { + "type": [ + "string", + "null" + ], + "description": "field2 description" + } + }, + "required": [ + "field1", + "field2" + ], + "additionalProperties": false + }, + "oneOfList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field1": { + "type": [ + "integer", + "null" + ], + "description": "field1 description" + }, + "field2": { + "type": [ + "string", + "null" + ], + "description": "field2 description" + } + }, + "required": [ + "field1", + "field2" + ], + "additionalProperties": false + } + }, + "objectWithOneOfField": { + "type": "object", + "properties": { + "field": { + "type": "object", + "properties": { + "field1": { + "type": [ + "integer", + "null" + ], + "description": "field1 description" + }, + "field2": { + "type": [ + "string", + "null" + ], + "description": "field2 description" + } + }, + "required": [ + "field1", + "field2" + ], + "additionalProperties": false, + "description": "field description" + } + }, + "required": [ + "field" + ], + "additionalProperties": false + }, + "timeSpanDotNet": { + "type": "string", + "pattern": "^-?(?:(?:\\d{1,8})\\.)?(?:[0-1]?\\d|2[0-3]):(?:[0-5]?\\d):(?:[0-5]?\\d)(?:\\.(?:\\d{1,7}))?$" + } + }, + "required": [ + "list", + "object", + "nullDefault", + "listWithNullDefault", + "objectWithNullDefault", + "oneOf", + "oneOfList", + "objectWithOneOfField", + "timeSpanDotNet" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withComplexVariables" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json new file mode 100644 index 00000000000..0f205d77d9b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Input.json @@ -0,0 +1,203 @@ +{ + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ], + "description": "Any description", + "default": { + "key": "value" + } + }, + "boolean": { + "type": "boolean", + "description": "Boolean description", + "default": true + }, + "byte": { + "type": "integer", + "description": "Byte description", + "default": 1 + }, + "byteArray": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$", + "description": "ByteArray description", + "default": "ZGVmYXVsdA==" + }, + "date": { + "type": "string", + "format": "date", + "description": "Date description", + "default": "2000-01-01" + }, + "dateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$", + "description": "DateTime description", + "default": "2000-01-01T12:00:00Z" + }, + "decimal": { + "type": "number", + "description": "Decimal description", + "default": 79228162514264337593543950335 + }, + "enum": { + "type": "string", + "enum": [ + "VALUE1", + "VALUE2" + ], + "description": "Enum description", + "default": "VALUE1" + }, + "float": { + "type": "number", + "description": "Float description", + "default": 1.5 + }, + "id": { + "type": "string", + "description": "ID description", + "default": "default" + }, + "int": { + "type": "integer", + "description": "Int description", + "default": 1 + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ], + "description": "JSON description", + "default": { + "key": "value" + } + }, + "list": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List description", + "default": [ + "default" + ] + }, + "localDate": { + "type": "string", + "format": "date", + "description": "LocalDate description", + "default": "2000-01-01" + }, + "localDateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalDateTime description", + "default": "2000-01-01T12:00:00" + }, + "localTime": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalTime description", + "default": "12:00:00" + }, + "long": { + "type": "integer", + "description": "Long description", + "default": 9223372036854775807 + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "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 description", + "default": { + "field1A": { + "field1B": { + "field1C": "12:00:00" + } + } + } + }, + "short": { + "type": "integer", + "description": "Short description", + "default": 1 + }, + "string": { + "type": "string", + "description": "String description", + "default": "default" + }, + "timeSpan": { + "type": "string", + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$", + "description": "TimeSpan description", + "default": "PT5M" + }, + "unknown": { + "type": "string", + "description": "Unknown description", + "default": "default" + }, + "url": { + "type": "string", + "format": "uri-reference", + "description": "URL description", + "default": "https://example.com" + }, + "uuid": { + "type": "string", + "format": "uuid", + "description": "UUID description", + "default": "00000000-0000-0000-0000-000000000000" + } + }, + "required": [] +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Output.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Output.json new file mode 100644 index 00000000000..4e160088c8f --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithDefaultedVariables_CreatesCorrectSchema_Output.json @@ -0,0 +1,239 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withDefaultedVariables": { + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ] + }, + "boolean": { + "type": "boolean" + }, + "byte": { + "type": "integer" + }, + "byteArray": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$" + }, + "date": { + "type": "string", + "format": "date" + }, + "dateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$" + }, + "decimal": { + "type": "number" + }, + "enum": { + "type": "string", + "enum": [ + "VALUE1", + "VALUE2" + ] + }, + "float": { + "type": "number" + }, + "id": { + "type": "string" + }, + "int": { + "type": "integer" + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ] + }, + "list": { + "type": "array", + "items": { + "type": "string" + } + }, + "localDate": { + "type": "string", + "format": "date" + }, + "localDateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" + }, + "localTime": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" + }, + "long": { + "type": "integer" + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "short": { + "type": "integer" + }, + "string": { + "type": "string" + }, + "timeSpan": { + "type": "string", + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$" + }, + "unknown": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "uuid": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "any", + "boolean", + "byte", + "byteArray", + "date", + "dateTime", + "decimal", + "enum", + "float", + "id", + "int", + "json", + "list", + "localDate", + "localDateTime", + "localTime", + "long", + "object", + "short", + "string", + "timeSpan", + "unknown", + "url", + "uuid" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withDefaultedVariables" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithInterfaceType_CreatesCorrectOutputSchema.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithInterfaceType_CreatesCorrectOutputSchema.json new file mode 100644 index 00000000000..456be7196d4 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithInterfaceType_CreatesCorrectOutputSchema.json @@ -0,0 +1,95 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withInterfaceType": { + "type": "object", + "properties": { + "__typename": { + "type": "string", + "description": "The name of the current Object type at runtime." + }, + "name": { + "type": "string" + }, + "isBarking": { + "type": "boolean" + }, + "isPurring": { + "type": "boolean" + } + }, + "required": [ + "__typename", + "name", + "isBarking", + "isPurring" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withInterfaceType" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json new file mode 100644 index 00000000000..a604c727ecd --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Input.json @@ -0,0 +1,189 @@ +{ + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ], + "description": "Any description" + }, + "boolean": { + "type": "boolean", + "description": "Boolean description" + }, + "byte": { + "type": "integer", + "description": "Byte description" + }, + "byteArray": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$", + "description": "ByteArray description" + }, + "date": { + "type": "string", + "format": "date", + "description": "Date description" + }, + "dateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$", + "description": "DateTime description" + }, + "decimal": { + "type": "number", + "description": "Decimal description" + }, + "enum": { + "type": "string", + "enum": [ + "VALUE1", + "VALUE2" + ], + "description": "Enum description" + }, + "float": { + "type": "number", + "description": "Float description" + }, + "id": { + "type": "string", + "description": "ID description" + }, + "int": { + "type": "integer", + "description": "Int description" + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ], + "description": "JSON description" + }, + "list": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List description" + }, + "localDate": { + "type": "string", + "format": "date", + "description": "LocalDate description" + }, + "localDateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalDateTime description" + }, + "localTime": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalTime description" + }, + "long": { + "type": "integer", + "description": "Long description" + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "description": "Object description" + }, + "short": { + "type": "integer", + "description": "Short description" + }, + "string": { + "type": "string", + "description": "String description" + }, + "timeSpan": { + "type": "string", + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$", + "description": "TimeSpan description" + }, + "unknown": { + "type": "string", + "description": "Unknown description" + }, + "url": { + "type": "string", + "format": "uri-reference", + "description": "URL description" + }, + "uuid": { + "type": "string", + "format": "uuid", + "description": "UUID description" + } + }, + "required": [ + "any", + "boolean", + "byte", + "byteArray", + "date", + "dateTime", + "decimal", + "enum", + "float", + "id", + "int", + "json", + "list", + "localDate", + "localDateTime", + "localTime", + "long", + "object", + "short", + "string", + "timeSpan", + "unknown", + "url", + "uuid" + ] +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Output.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Output.json new file mode 100644 index 00000000000..cd2aaab579e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNonNullableVariables_CreatesCorrectSchema_Output.json @@ -0,0 +1,239 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withNonNullableVariables": { + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ] + }, + "boolean": { + "type": "boolean" + }, + "byte": { + "type": "integer" + }, + "byteArray": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$" + }, + "date": { + "type": "string", + "format": "date" + }, + "dateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$" + }, + "decimal": { + "type": "number" + }, + "enum": { + "type": "string", + "enum": [ + "VALUE1", + "VALUE2" + ] + }, + "float": { + "type": "number" + }, + "id": { + "type": "string" + }, + "int": { + "type": "integer" + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer" + ] + }, + "list": { + "type": "array", + "items": { + "type": "string" + } + }, + "localDate": { + "type": "string", + "format": "date" + }, + "localDateTime": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" + }, + "localTime": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" + }, + "long": { + "type": "integer" + }, + "object": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "short": { + "type": "integer" + }, + "string": { + "type": "string" + }, + "timeSpan": { + "type": "string", + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$" + }, + "unknown": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "uuid": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "any", + "boolean", + "byte", + "byteArray", + "date", + "dateTime", + "decimal", + "enum", + "float", + "id", + "int", + "json", + "list", + "localDate", + "localDateTime", + "localTime", + "long", + "object", + "short", + "string", + "timeSpan", + "unknown", + "url", + "uuid" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withNonNullableVariables" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json new file mode 100644 index 00000000000..403fe020c8f --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Input.json @@ -0,0 +1,239 @@ +{ + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer", + "null" + ], + "description": "Any description" + }, + "boolean": { + "type": [ + "boolean", + "null" + ], + "description": "Boolean description" + }, + "byte": { + "type": [ + "integer", + "null" + ], + "description": "Byte description" + }, + "byteArray": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$", + "description": "ByteArray description" + }, + "date": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "Date description" + }, + "dateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$", + "description": "DateTime description" + }, + "decimal": { + "type": [ + "number", + "null" + ], + "description": "Decimal description" + }, + "enum": { + "type": [ + "string", + "null" + ], + "enum": [ + "VALUE1", + "VALUE2", + null + ], + "description": "Enum description" + }, + "float": { + "type": [ + "number", + "null" + ], + "description": "Float description" + }, + "id": { + "type": [ + "string", + "null" + ], + "description": "ID description" + }, + "int": { + "type": [ + "integer", + "null" + ], + "description": "Int description" + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer", + "null" + ], + "description": "JSON description" + }, + "list": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + }, + "description": "List description" + }, + "localDate": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "LocalDate description" + }, + "localDateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalDateTime description" + }, + "localTime": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "LocalTime description" + }, + "long": { + "type": [ + "integer", + "null" + ], + "description": "Long description" + }, + "object": { + "type": [ + "object", + "null" + ], + "properties": { + "field1A": { + "type": [ + "object", + "null" + ], + "properties": { + "field1B": { + "type": [ + "object", + "null" + ], + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [], + "description": "field1B description" + } + }, + "required": [], + "description": "field1A description" + } + }, + "required": [], + "description": "Object description" + }, + "short": { + "type": [ + "integer", + "null" + ], + "description": "Short description" + }, + "string": { + "type": [ + "string", + "null" + ], + "description": "String description" + }, + "timeSpan": { + "type": [ + "string", + "null" + ], + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$", + "description": "TimeSpan description" + }, + "unknown": { + "type": [ + "string", + "null" + ], + "description": "Unknown description" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri-reference", + "description": "URL description" + }, + "uuid": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "UUID description" + } + }, + "required": [] +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Output.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Output.json new file mode 100644 index 00000000000..fe9067d8276 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithNullableVariables_CreatesCorrectSchema_Output.json @@ -0,0 +1,320 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withNullableVariables": { + "type": "object", + "properties": { + "any": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer", + "null" + ] + }, + "boolean": { + "type": [ + "boolean", + "null" + ] + }, + "byte": { + "type": [ + "integer", + "null" + ] + }, + "byteArray": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=)?$" + }, + "date": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "dateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,7})?(?:[Zz]|[+-]\\d{2}:\\d{2})$" + }, + "decimal": { + "type": [ + "number", + "null" + ] + }, + "enum": { + "type": [ + "string", + "null" + ], + "enum": [ + "VALUE1", + "VALUE2", + null + ] + }, + "float": { + "type": [ + "number", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "int": { + "type": [ + "integer", + "null" + ] + }, + "json": { + "type": [ + "object", + "array", + "boolean", + "string", + "number", + "integer", + "null" + ] + }, + "list": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "localDate": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "localDateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" + }, + "localTime": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" + }, + "long": { + "type": [ + "integer", + "null" + ] + }, + "object": { + "type": [ + "object", + "null" + ], + "properties": { + "field1A": { + "type": [ + "object", + "null" + ], + "properties": { + "field1B": { + "type": [ + "object", + "null" + ], + "properties": { + "field1C": { + "type": [ + "string", + "null" + ], + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "short": { + "type": [ + "integer", + "null" + ] + }, + "string": { + "type": [ + "string", + "null" + ] + }, + "timeSpan": { + "type": [ + "string", + "null" + ], + "pattern": "^-?P(?:\\d+W|(?=\\d|T(?:\\d|$))(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:\\.\\d+)?S)?)?)$" + }, + "unknown": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri-reference" + }, + "uuid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + "required": [ + "any", + "boolean", + "byte", + "byteArray", + "date", + "dateTime", + "decimal", + "enum", + "float", + "id", + "int", + "json", + "list", + "localDate", + "localDateTime", + "localTime", + "long", + "object", + "short", + "string", + "timeSpan", + "unknown", + "url", + "uuid" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withNullableVariables" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema.json new file mode 100644 index 00000000000..dafcd671e21 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithSkipAndInclude_CreatesCorrectOutputSchema.json @@ -0,0 +1,144 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withDefaultedVariables": { + "type": "object", + "properties": { + "notSkipped": { + "type": "integer" + }, + "possiblySkipped": { + "type": "integer" + }, + "included": { + "type": "integer" + }, + "possiblyIncluded": { + "type": "integer" + }, + "notSkippedAndIncluded": { + "type": "integer" + }, + "notSkippedAndPossiblyIncluded": { + "type": "integer" + }, + "possiblySkippedAndIncluded": { + "type": "integer" + }, + "possiblySkippedAndPossiblyIncluded": { + "type": "integer" + }, + "objectFieldPossiblySkipped": { + "type": "object", + "properties": { + "field1A": { + "type": "object", + "properties": { + "field1B": { + "type": "object", + "properties": { + "field1C": { + "type": "string", + "format": "time", + "pattern": "^\\d{2}:\\d{2}:\\d{2}$", + "description": "field1C description" + } + }, + "required": [ + "field1C" + ], + "additionalProperties": false, + "description": "field1B description" + } + }, + "required": [ + "field1B" + ], + "additionalProperties": false, + "description": "field1A description" + } + }, + "required": [ + "field1A" + ], + "additionalProperties": false + }, + "inlineFragmentFieldPossiblySkipped": { + "type": "integer" + } + }, + "required": [ + "notSkipped", + "included", + "notSkippedAndIncluded", + "inlineFragmentFieldPossiblySkipped" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withDefaultedVariables" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithUnionType_CreatesCorrectOutputSchema.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithUnionType_CreatesCorrectOutputSchema.json new file mode 100644 index 00000000000..912c920edce --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithUnionType_CreatesCorrectOutputSchema.json @@ -0,0 +1,91 @@ +{ + "type": "object", + "properties": { + "data": { + "type": [ + "object", + "null" + ], + "properties": { + "withUnionType": { + "type": "object", + "properties": { + "__typename": { + "type": "string", + "description": "The name of the current Object type at runtime." + }, + "isBarking": { + "type": "boolean" + }, + "isPurring": { + "type": "boolean" + } + }, + "required": [ + "__typename", + "isBarking", + "isPurring" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "withUnionType" + ] + }, + "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 +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithVariableMinMaxValues_CreatesCorrectInputSchema.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithVariableMinMaxValues_CreatesCorrectInputSchema.json new file mode 100644 index 00000000000..e3992362eb2 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Factories/__snapshots__/GraphQLMcpToolFactoryTests.CreateTool_WithVariableMinMaxValues_CreatesCorrectInputSchema.json @@ -0,0 +1,43 @@ +{ + "type": "object", + "properties": { + "byte": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "decimal": { + "type": "number", + "minimum": 1, + "maximum": 10 + }, + "float": { + "type": "number", + "minimum": 1, + "maximum": 10 + }, + "int": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "long": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "short": { + "type": "integer", + "minimum": 1, + "maximum": 10 + } + }, + "required": [ + "byte", + "decimal", + "float", + "int", + "long", + "short" + ] +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Handlers/CallToolHandlerTests.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Handlers/CallToolHandlerTests.cs new file mode 100644 index 00000000000..1cf2df36760 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Handlers/CallToolHandlerTests.cs @@ -0,0 +1,59 @@ +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Extensions; +using HotChocolate.ModelContextProtocol.Storage; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; + +namespace HotChocolate.ModelContextProtocol.Handlers; + +public sealed class CallToolHandlerTests +{ + [Fact] + public async Task HandleAsync_MissingTool_ReturnsCallToolResultWithError() + { + // arrange + var context = await CreateRequestContextAsync("unknown"); + + // act + var result = await CallToolHandler.HandleAsync(context, CancellationToken.None); + + // assert + Assert.True(result.IsError); + var textContentBlock = Assert.IsType(result.Content[0]); + Assert.Equal("The tool 'unknown' was not found.", textContentBlock.Text); + } + + private static async Task> CreateRequestContextAsync( + string toolName) + { + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithNullableVariables.graphql"))); + var services = new ServiceCollection().AddSingleton(storage); + services + .AddGraphQL() + .AddMcp() + .AddQueryType() + .AddInterfaceType() + .AddUnionType() + .AddObjectType() + .AddObjectType(); + var serviceProvider = services.BuildServiceProvider(); + var executorProvider = serviceProvider.GetRequiredService(); + var executor = await executorProvider.GetExecutorAsync(); + Mock mockServer = new(); + mockServer.SetupGet(s => s.Services).Returns(executor.Schema.Services); + + return new RequestContext(mockServer.Object) + { + Params = new CallToolRequestParams + { + Name = toolName + } + }; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/HotChocolate.ModelContextProtocol.Tests.csproj b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/HotChocolate.ModelContextProtocol.Tests.csproj new file mode 100644 index 00000000000..738ffb1ef84 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/HotChocolate.ModelContextProtocol.Tests.csproj @@ -0,0 +1,22 @@ + + + + HotChocolate.ModelContextProtocol.Tests + HotChocolate.ModelContextProtocol + + + + + + + + + + + + + Always + + + + diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/IntegrationTests.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/IntegrationTests.cs new file mode 100644 index 00000000000..daaed8be050 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/IntegrationTests.cs @@ -0,0 +1,293 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using CookieCrumble; +using HotChocolate.Execution.Configuration; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Extensions; +using HotChocolate.ModelContextProtocol.Storage; +using HotChocolate.Types; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; + +namespace HotChocolate.ModelContextProtocol; + +public sealed class IntegrationTests +{ + [Fact] + public async Task ListTools_Valid_ReturnsTools() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithNullableVariables.graphql"))); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithNonNullableVariables.graphql"))); + var server = CreateTestServer(b => b.AddMcpOperationDocumentStorage(storage)); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var tools = await mcpClient.ListToolsAsync(); + + // assert + JsonSerializer.Serialize( + tools.Select( + t => + new + { + t.Name, + t.Title, + t.Description + }), + s_jsonSerializerOptions) + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task CallTool_GetWithNullableVariables_ReturnsExpectedResult() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithNullableVariables.graphql"))); + var server = CreateTestServer(b => b.AddMcpOperationDocumentStorage(storage)); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync( + "get_with_nullable_variables", + new Dictionary + { + { "any", null }, + { "boolean", null }, + { "byte", null }, + { "byteArray", null }, + { "date", null }, + { "dateTime", null }, + { "decimal", null }, + { "enum", null }, + { "float", null }, + { "id", null }, + { "int", null }, + { "json", null }, + { "list", null }, + { "localDate", null }, + { "localDateTime", null }, + { "localTime", null }, + { "long", null }, + { "object", null }, + { "short", null }, + { "string", null }, + { "timeSpan", null }, + { "unknown", null }, + { "url", null }, + { "uuid", null } + }, + serializerOptions: JsonSerializerOptions.Default); + + // assert + result.StructuredContent! + .ToString() + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task CallTool_GetWithNonNullableVariables_ReturnsExpectedResult() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithNonNullableVariables.graphql"))); + var server = CreateTestServer(b => b.AddMcpOperationDocumentStorage(storage)); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync( + "get_with_non_nullable_variables", + // JSON values. + new Dictionary + { + { "any", new { key = "value" } }, + { "boolean", true }, + { "byte", 1 }, + { "byteArray", "dGVzdA==" }, + { "date", "2000-01-01" }, + { "dateTime", "2000-01-01T12:00:00Z" }, + { "decimal", 79228162514264337593543950335m }, + { "enum", "VALUE1" }, + { "float", 1.5 }, + { "id", "test" }, + { "int", 1 }, + { "json", new { key = "value" } }, + { "list", s_list }, + { "localDate", "2000-01-01" }, + { "localDateTime", "2000-01-01T12:00:00" }, + { "localTime", "12:00:00" }, + { "long", 9223372036854775807 }, + { "object", new { field1A = new { field1B = new { field1C = "12:00:00" } } } }, + { "short", 1 }, + { "string", "test" }, + { "timeSpan", "PT5M" }, + { "unknown", "test" }, + { "url", "https://example.com" }, + { "uuid", "00000000-0000-0000-0000-000000000000" } + }, + serializerOptions: JsonSerializerOptions.Default); + + // assert + result.StructuredContent! + .ToString() + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task CallTool_GetWithDefaultedVariables_ReturnsExpectedResult() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithDefaultedVariables.graphql"))); + var server = CreateTestServer(b => b.AddMcpOperationDocumentStorage(storage)); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync("get_with_defaulted_variables"); + + // assert + result.StructuredContent! + .ToString() + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task CallTool_GetWithComplexVariables_ReturnsExpectedResult() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync("__resources__/GetWithComplexVariables.graphql"))); + var server = + CreateTestServer( + b => b + .AddMcpOperationDocumentStorage(storage) + .AddType(new TimeSpanType(TimeSpanFormat.DotNet))); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync( + "get_with_complex_variables", + // JSON values. + new Dictionary + { + { + "list", + new[] + { + new { field1A = new { field1B = new[] { new { field1C = "12:00:00" } } } } + } + }, + { + "object", + new { field1A = new { field1B = new[] { new { field1C = "12:00:00" } } } } + }, + { "nullDefault", null }, + { "listWithNullDefault", new string?[] { null } }, + { + "objectWithNullDefault", + new { field1A = new { field1B = new[] { new { field1C = (string?)null } } } } + }, + { "oneOf", new { field1 = 1 } }, + { "oneOfList", new object[] { new { field1 = 1 }, new { field2 = "test" } } }, + { "objectWithOneOfField", new { field = new { field1 = 1 } } }, + { "timeSpanDotNet", "00:05:00" } + }, + serializerOptions: JsonSerializerOptions.Default); + + // assert + result.StructuredContent! + .ToString() + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task CallTool_GetWithErrors_ReturnsExpectedResult() + { + // arrange + var storage = new InMemoryMcpOperationDocumentStorage(); + await storage.SaveToolDocumentAsync( + Utf8GraphQLParser.Parse("query GetWithErrors { withErrors }")); + var server = CreateTestServer(b => b.AddMcpOperationDocumentStorage(storage)); + var mcpClient = await CreateMcpClient(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync("get_with_errors"); + + // assert + result.StructuredContent! + .ToString() + .ReplaceLineEndings("\n") + .MatchSnapshot(extension: ".json"); + } + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = + new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true + }; + + private static readonly string[] s_list = ["test"]; + + private static TestServer CreateTestServer( + Action? configureRequestExecutor = null) + { + var builder = new WebHostBuilder() + .ConfigureServices( + services => + { + var executor = + services + .AddRouting() + .AddGraphQL() + .AddMcp() + .AddQueryType() + .AddInterfaceType() + .AddUnionType() + .AddObjectType() + .AddObjectType(); + + configureRequestExecutor?.Invoke(executor); + }) + .Configure( + app => app + .UseRouting() + .UseEndpoints(endpoints => endpoints.MapGraphQLMcp())); + + return new TestServer(builder); + } + + private static async Task CreateMcpClient(HttpClient httpClient) + { + return + await McpClientFactory.CreateAsync( + new SseClientTransport( + new SseClientTransportOptions + { + Endpoint = new Uri(httpClient.BaseAddress!, "/graphql/mcp") + }, + httpClient)); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Storage/InMemoryMcpOperationDocumentStorageTests.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Storage/InMemoryMcpOperationDocumentStorageTests.cs new file mode 100644 index 00000000000..bc9afbc2530 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/Storage/InMemoryMcpOperationDocumentStorageTests.cs @@ -0,0 +1,84 @@ +using HotChocolate.Language; + +namespace HotChocolate.ModelContextProtocol.Storage; + +public sealed class InMemoryMcpOperationDocumentStorageTests +{ + [Fact] + public async Task SaveToolDocumentAsync_DocumentWithNoOperations_ThrowsException() + { + // arrange & act + static async Task Action() + { + var storage = new InMemoryMcpOperationDocumentStorage(); + var document = Utf8GraphQLParser.Parse("fragment Fragment on Type { field }"); + + await storage.SaveToolDocumentAsync(document); + } + + // assert + Assert.Equal( + "A tool document must contain a single operation definition.", + (await Assert.ThrowsAsync(Action)).Message); + } + + [Fact] + public async Task SaveToolDocumentAsync_DocumentWithMultipleOperations_ThrowsException() + { + // arrange & act + static async Task Action() + { + var storage = new InMemoryMcpOperationDocumentStorage(); + var document = Utf8GraphQLParser.Parse( + """ + query Operation1 { query { field } } + query Operation2 { query { field } } + """); + + await storage.SaveToolDocumentAsync(document); + } + + // assert + Assert.Equal( + "A tool document must contain a single operation definition.", + (await Assert.ThrowsAsync(Action)).Message); + } + + [Fact] + public async Task SaveToolDocumentAsync_DocumentWithUnnamedOperation_ThrowsException() + { + // arrange & act + static async Task Action() + { + var storage = new InMemoryMcpOperationDocumentStorage(); + var document = Utf8GraphQLParser.Parse("query { query { field } }"); + + await storage.SaveToolDocumentAsync(document); + } + + // assert + Assert.Equal( + "A tool document operation must be named.", + (await Assert.ThrowsAsync(Action)).Message); + } + + [Fact] + public async Task SaveToolDocumentAsync_DocumentWithNonUniqueOperationName_ThrowsException() + { + // arrange & act + static async Task Action() + { + var storage = new InMemoryMcpOperationDocumentStorage(); + var document1 = Utf8GraphQLParser.Parse("query Operation1 { query1 { field } }"); + var document2 = Utf8GraphQLParser.Parse("query Operation1 { query2 { field } }"); + + await storage.SaveToolDocumentAsync(document1); + await storage.SaveToolDocumentAsync(document2); + } + + // assert + Assert.Equal( + "A tool document operation with the same name already exists.", + (await Assert.ThrowsAsync(Action)).Message); + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/TestSchema.cs b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/TestSchema.cs new file mode 100644 index 00000000000..a5754584277 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/TestSchema.cs @@ -0,0 +1,459 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedMember.Global +using System.Text.Json; +using HotChocolate.Language; +using HotChocolate.ModelContextProtocol.Attributes; +using HotChocolate.Types; + +namespace HotChocolate.ModelContextProtocol; + +public sealed class TestSchema +{ + public sealed class Query + { + public Book[] GetBooks() => [new("Title")]; + + public ResultNullable GetWithNullableVariables( + [GraphQLType] object? any, + bool? boolean, + byte? @byte, + [GraphQLType] byte[]? byteArray, + [GraphQLType] DateOnly? date, + DateTimeOffset? dateTime, + decimal? @decimal, + TestEnum? @enum, + float? @float, + [GraphQLType] string? id, + int? @int, + JsonElement? json, + string?[]? list, + DateOnly? localDate, + [GraphQLType] DateTime? localDateTime, + TimeOnly? localTime, + long? @long, + Object1Nullable? @object, + short? @short, + string? @string, + TimeSpan? timeSpan, + [GraphQLType] string? unknown, + Uri? url, + Guid? uuid) + => + new( + any, + boolean, + @byte, + byteArray, + date, + dateTime, + @decimal, + @enum, + @float, + id, + @int, + json, + list, + localDate, + localDateTime, + localTime, + @long, + @object, + @short, + @string, + timeSpan, + unknown, + url, + uuid); + + public ResultNonNullable GetWithNonNullableVariables( + [GraphQLType>] object any, + bool boolean, + byte @byte, + [GraphQLType>] byte[] byteArray, + [GraphQLType>] DateOnly date, + DateTimeOffset dateTime, + decimal @decimal, + TestEnum @enum, + float @float, + [GraphQLType>] string id, + int @int, + JsonElement json, + string[] list, + DateOnly localDate, + [GraphQLType>] DateTime localDateTime, + TimeOnly localTime, + long @long, + Object1NonNullable @object, + short @short, + string @string, + TimeSpan timeSpan, + [GraphQLType>] string unknown, + Uri url, + Guid uuid) + => + new( + any, + boolean, + @byte, + byteArray, + date, + dateTime, + @decimal, + @enum, + @float, + id, + @int, + json, + list, + localDate, + localDateTime, + localTime, + @long, + @object, + @short, + @string, + timeSpan, + unknown, + url, + uuid); + + public ResultDefaulted GetWithDefaultedVariables( + [GraphQLType>] object any, + bool boolean, + byte @byte, + [GraphQLType>] byte[] byteArray, + [GraphQLType>] DateOnly date, + DateTimeOffset dateTime, + decimal @decimal, + TestEnum @enum, + float @float, + [GraphQLType>] string id, + int @int, + JsonElement json, + string[] list, + DateOnly localDate, + [GraphQLType>] DateTime localDateTime, + TimeOnly localTime, + long @long, + Object1Defaulted @object, + short @short, + string @string, + TimeSpan timeSpan, + [GraphQLType>] string unknown, + Uri url, + Guid uuid) + => + new( + any, + boolean, + @byte, + byteArray, + date, + dateTime, + @decimal, + @enum, + @float, + id, + @int, + json, + list, + localDate, + localDateTime, + localTime, + @long, + @object, + @short, + @string, + timeSpan, + unknown, + url, + uuid); + + public ResultComplex GetWithComplexVariables( + Object1Complex[] list, + Object1Complex @object, + string? nullDefault, + string?[]? listWithNullDefault, + Object1Complex? objectWithNullDefault, + OneOf oneOf, + OneOf[] oneOfList, + ObjectWithOneOfField objectWithOneOfField, + TimeSpan timeSpanDotNet) + => + new( + list, + @object, + nullDefault, + listWithNullDefault, + objectWithNullDefault, + oneOf, + oneOfList, + objectWithOneOfField, + timeSpanDotNet); + + public int GetWithVariableMinMaxValues() => 1; + + public IPet GetWithInterfaceType() => new Cat(Name: "Whiskers", IsPurring: true); + + [GraphQLType("PetUnion!")] + public IPet GetWithUnionType() => new Cat(Name: "Whiskers", IsPurring: true); + + // Implicitly open-world by default, unless annotated otherwise. + public int ImplicitOpenWorldQuery() => 1; + + [McpToolAnnotations(OpenWorldHint = true)] + public int ExplicitOpenWorldQuery() => 1; + + [McpToolAnnotations(OpenWorldHint = false)] + public int ExplicitClosedWorldQuery() => 1; + + // The query field is closed-world, but the subfield is (explicitly) open-world. + [McpToolAnnotations(OpenWorldHint = false)] + public ExplicitOpenWorld ExplicitOpenWorldSubfieldQuery() => new(); + + // The query field is closed-world, and the subfield is also (implicitly) closed-world. + [McpToolAnnotations(OpenWorldHint = false)] + public ImplicitClosedWorld ImplicitClosedWorldSubfieldQuery() => new(); + + // The query field is closed-world, and the subfield is also (explicitly) closed-world. + [McpToolAnnotations(OpenWorldHint = false)] + public ExplicitClosedWorld ExplicitClosedWorldSubfieldQuery() => new(); + + public int GetWithErrors() + { + throw new GraphQLException( + ErrorBuilder + .New() + .SetMessage("Error 1") + .SetCode("Code 1") + .SetException(new Exception("Exception 1")) + .Build(), + ErrorBuilder + .New() + .SetMessage("Error 2") + .SetCode("Code 2") + .SetException(new Exception("Exception 2")) + .Build()); + } + } + + public sealed class Mutation + { + public Book AddBook() => new("Title"); + + // Destructive by default, unless annotated otherwise. + public int ImplicitDestructiveMutation() => 1; + + [McpToolAnnotations(DestructiveHint = true)] + public int ExplicitDestructiveMutation() => 1; + + [McpToolAnnotations(DestructiveHint = false)] + public int ExplicitNonDestructiveMutation() => 1; + + // Non-idempotent by default, unless annotated otherwise. + public int ImplicitNonIdempotentMutation() => 1; + + [McpToolAnnotations(IdempotentHint = false)] + public int ExplicitNonIdempotentMutation() => 1; + + [McpToolAnnotations(IdempotentHint = true)] + public int ExplicitIdempotentMutation() => 1; + + [McpToolAnnotations(OpenWorldHint = true)] + public int ExplicitOpenWorldMutation() => 1; + + [McpToolAnnotations(OpenWorldHint = false)] + public int ExplicitClosedWorldMutation() => 1; + } + + public sealed class Subscription + { + public Book BookAdded() => new("Title"); + } + + public sealed record Book(string Title); + + public enum TestEnum + { + Value1, + Value2 + } + + public sealed record Object1Nullable( + [property: GraphQLDescription("field1A description")] Object2Nullable? Field1A); + public sealed record Object2Nullable( + [property: GraphQLDescription("field1B description")] Object3Nullable? Field1B); + public sealed record Object3Nullable( + [property: GraphQLDescription("field1C description")] TimeOnly? Field1C); + + public sealed record Object1NonNullable( + [property: GraphQLDescription("field1A description")] Object2NonNullable Field1A); + public sealed record Object2NonNullable( + [property: GraphQLDescription("field1B description")] Object3NonNullable Field1B); + public sealed record Object3NonNullable( + [property: GraphQLDescription("field1C description")] TimeOnly Field1C); + + public sealed record Object1Defaulted( + [property: GraphQLDescription("field1A description")] + [property: DefaultValueSyntax("""{ field1B: { field1C: "12:00:00" } }""")] + Object2Defaulted Field1A); + public sealed record Object2Defaulted( + [property: GraphQLDescription("field1B description")] + [property: DefaultValueSyntax("""{ field1C: "12:00:00" }""")] + Object3Defaulted Field1B); + public sealed record Object3Defaulted( + [property: GraphQLDescription("field1C description")] + [property: DefaultValueSyntax("\"12:00:00\"")] + TimeOnly Field1C); + + public sealed record Object1Complex( + [property: GraphQLDescription("field1A description")] + [property: DefaultValueSyntax("""{ field1B: [{ field1C: "12:00:00" }] }""")] + Object2Complex Field1A); + public sealed record Object2Complex( + [property: GraphQLDescription("field1B description")] + [property: DefaultValueSyntax("""[{ field1C: "12:00:00" }]""")] + Object3Complex[] Field1B); + public sealed record Object3Complex( + [property: GraphQLDescription("field1C description")] + [property: DefaultValueSyntax("\"12:00:00\"")] + TimeOnly? Field1C); + + [OneOf] + public sealed record OneOf( + [property: GraphQLDescription("field1 description")] int? Field1, + [property: GraphQLDescription("field2 description")] string? Field2); + + public sealed record ObjectWithOneOfField( + [property: GraphQLDescription("field description")] + [property: DefaultValueSyntax("{ field1: 1 }")] + OneOf Field); + + public sealed record ResultNullable( + [property: GraphQLType] object? Any, + bool? Boolean, + byte? Byte, + [property: GraphQLType] byte[]? ByteArray, + [property: GraphQLType] DateOnly? Date, + DateTimeOffset? DateTime, + decimal? Decimal, + TestEnum? Enum, + float? Float, + [property: GraphQLType] string? Id, + int? Int, + JsonElement? Json, + string?[]? List, + DateOnly? LocalDate, + [property: GraphQLType] DateTime? LocalDateTime, + TimeOnly? LocalTime, + long? Long, + Object1Nullable? Object, + short? Short, + string? String, + TimeSpan? TimeSpan, + [property: GraphQLType] string? Unknown, + Uri? Url, + Guid? Uuid); + + public sealed record ResultNonNullable( + [property: GraphQLType>] object Any, + bool Boolean, + byte Byte, + [property: GraphQLType>] byte[] ByteArray, + [property: GraphQLType>] DateOnly Date, + DateTimeOffset DateTime, + decimal Decimal, + TestEnum Enum, + float Float, + [property: GraphQLType>] string Id, + int Int, + JsonElement Json, + string[] List, + DateOnly LocalDate, + [property: GraphQLType>] DateTime LocalDateTime, + TimeOnly LocalTime, + long Long, + Object1NonNullable Object, + short Short, + string String, + TimeSpan TimeSpan, + [property: GraphQLType>] string Unknown, + Uri Url, + Guid Uuid); + + public sealed record ResultDefaulted( + [property: GraphQLType>] object Any, + bool Boolean, + byte Byte, + [property: GraphQLType>] byte[] ByteArray, + [property: GraphQLType>] DateOnly Date, + DateTimeOffset DateTime, + decimal Decimal, + TestEnum Enum, + float Float, + [property: GraphQLType>] string Id, + int Int, + JsonElement Json, + string[] List, + DateOnly LocalDate, + [property: GraphQLType>] DateTime LocalDateTime, + TimeOnly LocalTime, + long Long, + Object1Defaulted Object, + short Short, + string String, + TimeSpan TimeSpan, + [property: GraphQLType>] string Unknown, + Uri Url, + Guid Uuid); + + public sealed record ResultComplex( + Object1Complex[] List, + Object1Complex Object, + string? NullDefault, + string?[]? ListWithNullDefault, + Object1Complex? ObjectWithNullDefault, + OneOf OneOf, + OneOf[] OneOfList, + ObjectWithOneOfField ObjectWithOneOfField, + TimeSpan TimeSpanDotNet); + + [UnionType(name: "PetUnion")] + public interface IPet + { + string Name { get; } + } + + public sealed record Cat(string Name, bool IsPurring) : IPet; + public sealed record Dog(string Name, bool IsBarking) : IPet; + + private sealed class UnknownType() : ScalarType("Unknown") + { + public override IValueNode ParseResult(object? resultValue) + => throw new NotImplementedException(); + + protected override string ParseLiteral(StringValueNode valueSyntax) + => valueSyntax.Value; + + protected override StringValueNode ParseValue(string runtimeValue) + => throw new NotImplementedException(); + } + + public sealed class ExplicitOpenWorld + { + [McpToolAnnotations(OpenWorldHint = true)] + public int ExplicitOpenWorldField() => 1; + } + + public sealed class ImplicitClosedWorld + { + // Defaults to closed-world, because the parent (query) field is closed-world. + public int ImplicitClosedWorldField() => 1; + } + + public sealed class ExplicitClosedWorld + { + [McpToolAnnotations(OpenWorldHint = false)] + public int ExplicitClosedWorldField() => 1; + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/AnnotationsWithFragment.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/AnnotationsWithFragment.graphql new file mode 100644 index 00000000000..37684e3e4a4 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/AnnotationsWithFragment.graphql @@ -0,0 +1,12 @@ +mutation AnnotationsWithFragment { + explicitNonDestructiveMutation + explicitIdempotentMutation + explicitClosedWorldMutation + ...Fragment +} + +fragment Fragment on Mutation { + explicitDestructiveMutation + explicitNonIdempotentMutation + explicitOpenWorldMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldSubfieldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldSubfieldTool.graphql new file mode 100644 index 00000000000..041a040d9ba --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldSubfieldTool.graphql @@ -0,0 +1,5 @@ +query ExplicitClosedWorldSubfieldTool { + explicitClosedWorldSubfieldQuery { + explicitClosedWorldField + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldTool.graphql new file mode 100644 index 00000000000..4a3de3dc2d8 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitClosedWorldTool.graphql @@ -0,0 +1,4 @@ +query ExplicitClosedWorldTool { + explicitClosedWorldQuery + another: explicitClosedWorldQuery +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitDestructiveTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitDestructiveTool.graphql new file mode 100644 index 00000000000..8bd43ba1e94 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitDestructiveTool.graphql @@ -0,0 +1,4 @@ +mutation ExplicitDestructiveTool { + explicitNonDestructiveMutation + explicitDestructiveMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitIdempotentTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitIdempotentTool.graphql new file mode 100644 index 00000000000..7a2c66728a7 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitIdempotentTool.graphql @@ -0,0 +1,4 @@ +mutation ExplicitIdempotentTool { + explicitIdempotentMutation + another: explicitIdempotentMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonDestructiveTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonDestructiveTool.graphql new file mode 100644 index 00000000000..7b994c943d4 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonDestructiveTool.graphql @@ -0,0 +1,4 @@ +mutation ExplicitNonDestructiveTool { + explicitNonDestructiveMutation + another: explicitNonDestructiveMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonIdempotentTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonIdempotentTool.graphql new file mode 100644 index 00000000000..ee632965f77 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitNonIdempotentTool.graphql @@ -0,0 +1,4 @@ +mutation ExplicitNonIdempotentTool { + explicitIdempotentMutation + explicitNonIdempotentMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldSubfieldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldSubfieldTool.graphql new file mode 100644 index 00000000000..c412f569334 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldSubfieldTool.graphql @@ -0,0 +1,5 @@ +query ExplicitOpenWorldSubfieldTool { + explicitOpenWorldSubfieldQuery { + explicitOpenWorldField + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldTool.graphql new file mode 100644 index 00000000000..325e3a8df3b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ExplicitOpenWorldTool.graphql @@ -0,0 +1,4 @@ +query ExplicitOpenWorldTool { + explicitClosedWorldQuery + explicitOpenWorldQuery +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithComplexVariables.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithComplexVariables.graphql new file mode 100644 index 00000000000..af79f2301bf --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithComplexVariables.graphql @@ -0,0 +1,72 @@ +"GetWithComplexVariables description" +query GetWithComplexVariables( + "Complex list" + $list: [Object1ComplexInput!]! = [{ field1A: { field1B: [{ field1C: "12:00:00" }] } }] + "Complex object" + $object: Object1ComplexInput! = { field1A: { field1B: [{ field1C: "12:00:00" }] } } + "Null default" + $nullDefault: String = null + "List with null default" + $listWithNullDefault: [String] = [null] + "Object with null default" + $objectWithNullDefault: Object1ComplexInput = { field1A: { field1B: [{ field1C: null }] } } + "OneOf" + $oneOf: OneOfInput! = { field1: 1 } + "OneOf list" + $oneOfList: [OneOfInput!]! = [{ field1: 1 }, { field2: "default" }] + "Object with OneOf field" + $objectWithOneOfField: ObjectWithOneOfFieldInput! = { field: { field1: 1 } } + "TimeSpan with DotNet format" + $timeSpanDotNet: TimeSpan! = "00:05:00" +) { + withComplexVariables( + list: $list + object: $object + nullDefault: $nullDefault + listWithNullDefault: $listWithNullDefault + objectWithNullDefault: $objectWithNullDefault + oneOf: $oneOf + oneOfList: $oneOfList + objectWithOneOfField: $objectWithOneOfField + timeSpanDotNet: $timeSpanDotNet + ) { + list { + field1A { + field1B { + field1C + } + } + } + object { + field1A { + field1B { + field1C + } + } + } + nullDefault + listWithNullDefault + objectWithNullDefault { + field1A { + field1B { + field1C + } + } + } + oneOf { + field1 + field2 + } + oneOfList { + field1 + field2 + } + objectWithOneOfField { + field { + field1 + field2 + } + } + timeSpanDotNet + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithDefaultedVariables.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithDefaultedVariables.graphql new file mode 100644 index 00000000000..fea12117b67 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithDefaultedVariables.graphql @@ -0,0 +1,109 @@ +"GetWithDefaultedVariables description" +query GetWithDefaultedVariables( + "Any description" + $any: Any! = { key: "value" } + "Boolean description" + $boolean: Boolean! = true + "Byte description" + $byte: Byte! = 1 + "ByteArray description" + $byteArray: ByteArray! = "ZGVmYXVsdA==" + "Date description" + $date: Date! = "2000-01-01" + "DateTime description" + $dateTime: DateTime! = "2000-01-01T12:00:00Z" + "Decimal description" + $decimal: Decimal! = 79228162514264337593543950335 + "Enum description" + $enum: TestEnum! = VALUE1 + "Float description" + $float: Float! = 1.5 + "ID description" + $id: ID! = "default" + "Int description" + $int: Int! = 1 + "JSON description" + $json: JSON! = { key: "value" } + "List description" + $list: [String!]! = ["default"] + "LocalDate description" + $localDate: LocalDate! = "2000-01-01" + "LocalDateTime description" + $localDateTime: LocalDateTime! = "2000-01-01T12:00:00" + "LocalTime description" + $localTime: LocalTime! = "12:00:00" + "Long description" + $long: Long! = 9223372036854775807 + "Object description" + $object: Object1DefaultedInput! = { field1A: { field1B: { field1C: "12:00:00" } } } + "Short description" + $short: Short! = 1 + "String description" + $string: String! = "default" + "TimeSpan description" + $timeSpan: TimeSpan! = "PT5M" + "Unknown description" + $unknown: Unknown! = "default" + "URL description" + $url: URL! = "https://example.com" + "UUID description" + $uuid: UUID! = "00000000-0000-0000-0000-000000000000" +) { + withDefaultedVariables( + any: $any + boolean: $boolean + byte: $byte + byteArray: $byteArray + date: $date + dateTime: $dateTime + decimal: $decimal + enum: $enum + float: $float + id: $id + int: $int + json: $json + list: $list + localDate: $localDate + localDateTime: $localDateTime + localTime: $localTime + long: $long + object: $object + short: $short + string: $string + timeSpan: $timeSpan + unknown: $unknown + url: $url + uuid: $uuid + ) { + any + boolean + byte + byteArray + date + dateTime + decimal + enum + float + id + int + json + list + localDate + localDateTime + localTime + long + object { + field1A { + field1B { + field1C + } + } + } + short + string + timeSpan + unknown + url + uuid + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNonNullableVariables.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNonNullableVariables.graphql new file mode 100644 index 00000000000..3288de3c94d --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNonNullableVariables.graphql @@ -0,0 +1,85 @@ +"GetWithNonNullableVariables description" +query GetWithNonNullableVariables( + "Any description" $any: Any! + "Boolean description" $boolean: Boolean! + "Byte description" $byte: Byte! + "ByteArray description" $byteArray: ByteArray! + "Date description" $date: Date! + "DateTime description" $dateTime: DateTime! + "Decimal description" $decimal: Decimal! + "Enum description" $enum: TestEnum! + "Float description" $float: Float! + "ID description" $id: ID! + "Int description" $int: Int! + "JSON description" $json: JSON! + "List description" $list: [String!]! + "LocalDate description" $localDate: LocalDate! + "LocalDateTime description" $localDateTime: LocalDateTime! + "LocalTime description" $localTime: LocalTime! + "Long description" $long: Long! + "Object description" $object: Object1NonNullableInput! + "Short description" $short: Short! + "String description" $string: String! + "TimeSpan description" $timeSpan: TimeSpan! + "Unknown description" $unknown: Unknown! + "URL description" $url: URL! + "UUID description" $uuid: UUID! +) { + withNonNullableVariables( + any: $any + boolean: $boolean + byte: $byte + byteArray: $byteArray + date: $date + dateTime: $dateTime + decimal: $decimal + enum: $enum + float: $float + id: $id + int: $int + json: $json + list: $list + localDate: $localDate + localDateTime: $localDateTime + localTime: $localTime + long: $long + object: $object + short: $short + string: $string + timeSpan: $timeSpan + unknown: $unknown + url: $url + uuid: $uuid + ) { + any + boolean + byte + byteArray + date + dateTime + decimal + enum + float + id + int + json + list + localDate + localDateTime + localTime + long + object { + field1A { + field1B { + field1C + } + } + } + short + string + timeSpan + unknown + url + uuid + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNullableVariables.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNullableVariables.graphql new file mode 100644 index 00000000000..4fcd98e4d0e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithNullableVariables.graphql @@ -0,0 +1,85 @@ +"GetWithNullableVariables description" +query GetWithNullableVariables( + "Any description" $any: Any + "Boolean description" $boolean: Boolean + "Byte description" $byte: Byte + "ByteArray description" $byteArray: ByteArray + "Date description" $date: Date + "DateTime description" $dateTime: DateTime + "Decimal description" $decimal: Decimal + "Enum description" $enum: TestEnum + "Float description" $float: Float + "ID description" $id: ID + "Int description" $int: Int + "JSON description" $json: JSON + "List description" $list: [String] + "LocalDate description" $localDate: LocalDate + "LocalDateTime description" $localDateTime: LocalDateTime + "LocalTime description" $localTime: LocalTime + "Long description" $long: Long + "Object description" $object: Object1NullableInput + "Short description" $short: Short + "String description" $string: String + "TimeSpan description" $timeSpan: TimeSpan + "Unknown description" $unknown: Unknown + "URL description" $url: URL + "UUID description" $uuid: UUID +) { + withNullableVariables( + any: $any + boolean: $boolean + byte: $byte + byteArray: $byteArray + date: $date + dateTime: $dateTime + decimal: $decimal + enum: $enum + float: $float + id: $id + int: $int + json: $json + list: $list + localDate: $localDate + localDateTime: $localDateTime + localTime: $localTime + long: $long + object: $object + short: $short + string: $string + timeSpan: $timeSpan + unknown: $unknown + url: $url + uuid: $uuid + ) { + any + boolean + byte + byteArray + date + dateTime + decimal + enum + float + id + int + json + list + localDate + localDateTime + localTime + long + object { + field1A { + field1B { + field1C + } + } + } + short + string + timeSpan + unknown + url + uuid + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithVariableMinMaxValues.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithVariableMinMaxValues.graphql new file mode 100644 index 00000000000..0f4e70d84be --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/GetWithVariableMinMaxValues.graphql @@ -0,0 +1,11 @@ +"GetWithVariableMinMaxValues description" +query GetWithVariableMinMaxValues( + $byte: Byte! + $decimal: Decimal! + $float: Float! + $int: Int! + $long: Long! + $short: Short! +) { + withVariableMinMaxValues +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitClosedWorldSubfieldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitClosedWorldSubfieldTool.graphql new file mode 100644 index 00000000000..1b26467885e --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitClosedWorldSubfieldTool.graphql @@ -0,0 +1,5 @@ +query ImplicitClosedWorldSubfieldTool { + implicitClosedWorldSubfieldQuery { + implicitClosedWorldField + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitDestructiveTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitDestructiveTool.graphql new file mode 100644 index 00000000000..0accb393237 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitDestructiveTool.graphql @@ -0,0 +1,4 @@ +mutation ImplicitDestructiveTool { + explicitNonDestructiveMutation + implicitDestructiveMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitNonIdempotentTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitNonIdempotentTool.graphql new file mode 100644 index 00000000000..5f62f0eb5a9 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitNonIdempotentTool.graphql @@ -0,0 +1,4 @@ +mutation ImplicitNonIdempotentTool { + explicitIdempotentMutation + implicitNonIdempotentMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitOpenWorldTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitOpenWorldTool.graphql new file mode 100644 index 00000000000..9265519775b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/ImplicitOpenWorldTool.graphql @@ -0,0 +1,4 @@ +query ImplicitOpenWorldTool { + explicitClosedWorldQuery + implicitOpenWorldQuery +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/NonDestructiveFragmentTool.graphql b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/NonDestructiveFragmentTool.graphql new file mode 100644 index 00000000000..d73d66ad0a0 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__resources__/NonDestructiveFragmentTool.graphql @@ -0,0 +1,8 @@ +mutation NonDestructiveFragmentTool { + nonDestructiveMutation + ...NonDestructiveFragment +} + +fragment NonDestructiveFragment on Mutation { + another: nonDestructiveMutation +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithComplexVariables_ReturnsExpectedResult.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithComplexVariables_ReturnsExpectedResult.json new file mode 100644 index 00000000000..b63918668e6 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithComplexVariables_ReturnsExpectedResult.json @@ -0,0 +1,60 @@ +{ + "data": { + "withComplexVariables": { + "list": [ + { + "field1A": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + } + ], + "object": { + "field1A": { + "field1B": [ + { + "field1C": "12:00:00" + } + ] + } + }, + "nullDefault": null, + "listWithNullDefault": [ + null + ], + "objectWithNullDefault": { + "field1A": { + "field1B": [ + { + "field1C": null + } + ] + } + }, + "oneOf": { + "field1": 1, + "field2": null + }, + "oneOfList": [ + { + "field1": 1, + "field2": null + }, + { + "field1": null, + "field2": "test" + } + ], + "objectWithOneOfField": { + "field": { + "field1": 1, + "field2": null + } + }, + "timeSpanDotNet": "00:05:00" + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithDefaultedVariables_ReturnsExpectedResult.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithDefaultedVariables_ReturnsExpectedResult.json new file mode 100644 index 00000000000..5cea0549974 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithDefaultedVariables_ReturnsExpectedResult.json @@ -0,0 +1,42 @@ +{ + "data": { + "withDefaultedVariables": { + "any": { + "key": "value" + }, + "boolean": true, + "byte": 1, + "byteArray": "ZGVmYXVsdA==", + "date": "2000-01-01", + "dateTime": "2000-01-01T12:00:00.000Z", + "decimal": 79228162514264337593543950335, + "enum": "VALUE1", + "float": 1.5, + "id": "default", + "int": 1, + "json": { + "key": "value" + }, + "list": [ + "default" + ], + "localDate": "2000-01-01", + "localDateTime": "2000-01-01T12:00:00", + "localTime": "12:00:00", + "long": 9223372036854775807, + "object": { + "field1A": { + "field1B": { + "field1C": "12:00:00" + } + } + }, + "short": 1, + "string": "default", + "timeSpan": "PT5M", + "unknown": "default", + "url": "https://example.com/", + "uuid": "00000000-0000-0000-0000-000000000000" + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithErrors_ReturnsExpectedResult.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithErrors_ReturnsExpectedResult.json new file mode 100644 index 00000000000..17b675208a0 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithErrors_ReturnsExpectedResult.json @@ -0,0 +1,35 @@ +{ + "errors": [ + { + "message": "Error 1", + "locations": [ + { + "line": 1, + "column": 23 + } + ], + "path": [ + "withErrors" + ], + "extensions": { + "code": "Code 1" + } + }, + { + "message": "Error 2", + "locations": [ + { + "line": 1, + "column": 23 + } + ], + "path": [ + "withErrors" + ], + "extensions": { + "code": "Code 2" + } + } + ], + "data": null +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNonNullableVariables_ReturnsExpectedResult.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNonNullableVariables_ReturnsExpectedResult.json new file mode 100644 index 00000000000..a8a85ba6340 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNonNullableVariables_ReturnsExpectedResult.json @@ -0,0 +1,42 @@ +{ + "data": { + "withNonNullableVariables": { + "any": { + "key": "value" + }, + "boolean": true, + "byte": 1, + "byteArray": "dGVzdA==", + "date": "2000-01-01", + "dateTime": "2000-01-01T12:00:00.000Z", + "decimal": 79228162514264337593543950335, + "enum": "VALUE1", + "float": 1.5, + "id": "test", + "int": 1, + "json": { + "key": "value" + }, + "list": [ + "test" + ], + "localDate": "2000-01-01", + "localDateTime": "2000-01-01T12:00:00", + "localTime": "12:00:00", + "long": 9223372036854775807, + "object": { + "field1A": { + "field1B": { + "field1C": "12:00:00" + } + } + }, + "short": 1, + "string": "test", + "timeSpan": "PT5M", + "unknown": "test", + "url": "https://example.com/", + "uuid": "00000000-0000-0000-0000-000000000000" + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNullableVariables_ReturnsExpectedResult.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNullableVariables_ReturnsExpectedResult.json new file mode 100644 index 00000000000..a91766e774b --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.CallTool_GetWithNullableVariables_ReturnsExpectedResult.json @@ -0,0 +1,30 @@ +{ + "data": { + "withNullableVariables": { + "any": null, + "boolean": null, + "byte": null, + "byteArray": null, + "date": null, + "dateTime": null, + "decimal": null, + "enum": null, + "float": null, + "id": null, + "int": null, + "json": null, + "list": null, + "localDate": null, + "localDateTime": null, + "localTime": null, + "long": null, + "object": null, + "short": null, + "string": null, + "timeSpan": null, + "unknown": null, + "url": null, + "uuid": null + } + } +} diff --git a/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.ListTools_Valid_ReturnsTools.json b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.ListTools_Valid_ReturnsTools.json new file mode 100644 index 00000000000..49180fb6314 --- /dev/null +++ b/src/HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/__snapshots__/IntegrationTests.ListTools_Valid_ReturnsTools.json @@ -0,0 +1,12 @@ +[ + { + "Name": "get_with_nullable_variables", + "Title": "Get With Nullable Variables", + "Description": "GetWithNullableVariables description" + }, + { + "Name": "get_with_non_nullable_variables", + "Title": "Get With Non Nullable Variables", + "Description": "GetWithNonNullableVariables description" + } +]