Skip to content

Added support for Model Context Protocol #8480

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/All.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@
<Project Path="HotChocolate/Marten/test/Data.Marten.Filters.Tests/Data.Marten.Filters.Tests.csproj" />
<Project Path="HotChocolate/Marten/test/Data.Marten.Sorting.Tests/Data.Marten.Sorting.Tests.csproj" />
</Folder>
<Folder Name="/HotChocolate/ModelContextProtocol/" />
<Folder Name="/HotChocolate/ModelContextProtocol/src/">
<Project Path="HotChocolate/ModelContextProtocol/src/HotChocolate.ModelContextProtocol/HotChocolate.ModelContextProtocol.csproj" />
</Folder>
<Folder Name="/HotChocolate/ModelContextProtocol/test/">
<Project Path="HotChocolate/ModelContextProtocol/test/HotChocolate.ModelContextProtocol.Tests/HotChocolate.ModelContextProtocol.Tests.csproj" />
</Folder>
<Folder Name="/HotChocolate/MongoDb/" />
<Folder Name="/HotChocolate/MongoDb/src/">
<Project Path="HotChocolate/MongoDb/src/Data/HotChocolate.Data.MongoDb.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
<PackageVersion Include="Basic.Reference.Assemblies.Net80" Version="1.8.2" />
<PackageVersion Include="Basic.Reference.Assemblies.Net90" Version="1.8.2" />
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.2" />
<PackageVersion Include="CaseConverter" Version="2.0.1" />
<PackageVersion Include="ChilliCream.ModelContextProtocol.AspNetCore" Version="0.0.1-p.2" />
<PackageVersion Include="ChilliCream.Nitro.App" Version="$(NitroVersion)" />
<PackageVersion Include="ChilliCream.Testing.Utilities" Version="0.2.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="DiffPlex" Version="1.8.0" />
<PackageVersion Include="Glob" Version="1.1.9" />
<PackageVersion Include="IdentityModel" Version="4.1.1" />
<PackageVersion Include="JsonPointer.Net" Version="5.0.0" />
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
<PackageVersion Include="Marten" Version="7.33.0" />
<PackageVersion Include="McMaster.Extensions.CommandLineUtils" Version="4.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
Expand Down
14 changes: 7 additions & 7 deletions src/HotChocolate/Core/src/Types/Types/Scalars/TimeSpanType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace HotChocolate.Types;
public class TimeSpanType
: ScalarType<TimeSpan, StringValueNode>
{
private readonly TimeSpanFormat _format;
public TimeSpanFormat Format { get; }

public TimeSpanType(
TimeSpanFormat format = TimeSpanFormat.Iso8601,
Expand All @@ -27,7 +27,7 @@ public TimeSpanType(
BindingBehavior bind = BindingBehavior.Explicit)
: base(name, bind)
{
_format = format;
Format = format;
Description = description;
}

Expand All @@ -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;
Expand All @@ -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"));
}
Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/HotChocolate/ModelContextProtocol/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/HotChocolate.ModelContextProtocol/HotChocolate.ModelContextProtocol.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/HotChocolate.ModelContextProtocol.Tests/HotChocolate.ModelContextProtocol.Tests.csproj" />
</Folder>
</Solution>
12 changes: 12 additions & 0 deletions src/HotChocolate/ModelContextProtocol/src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />

<PropertyGroup>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>

<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Reflection;
using HotChocolate.ModelContextProtocol.Directives;
using HotChocolate.Types;
using HotChocolate.Types.Descriptors;

namespace HotChocolate.ModelContextProtocol.Attributes;

/// <summary>
/// Additional properties describing a Tool to clients.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class McpToolAnnotationsAttribute : DescriptorAttribute
{
private readonly bool? _destructiveHint;
private readonly bool? _idempotentHint;
private readonly bool? _openWorldHint;

/// <summary>
/// If <c>true</c>, the tool may perform destructive updates to its environment. If
/// <c>false</c>, the tool performs only additive updates.
/// </summary>
public bool DestructiveHint
{
get => _destructiveHint ?? throw new InvalidOperationException();
init => _destructiveHint = value;
}

/// <summary>
/// If <c>true</c>, calling the tool repeatedly with the same arguments will have no additional
/// effect on its environment.
/// </summary>
public bool IdempotentHint
{
get => _idempotentHint ?? throw new InvalidOperationException();
init => _idempotentHint = value;
}

/// <summary>
/// If <c>true</c>, this tool may interact with an β€œopen world” of external entities. If
/// <c>false</c>, 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.
/// </summary>
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
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace HotChocolate.ModelContextProtocol.Directives;

/// <summary>
/// Additional properties describing a Tool to clients.
/// </summary>
public sealed class McpToolAnnotationsDirective
{
/// <summary>
/// If <c>true</c>, the tool may perform destructive updates to its environment. If
/// <c>false</c>, the tool performs only additive updates.
/// </summary>
public bool? DestructiveHint { get; init; }

/// <summary>
/// If <c>true</c>, calling the tool repeatedly with the same arguments will have no additional
/// effect on its environment.
/// </summary>
public bool? IdempotentHint { get; init; }

/// <summary>
/// If <c>true</c>, this tool may interact with an β€œopen world” of external entities. If
/// <c>false</c>, 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.
/// </summary>
public bool? OpenWorldHint { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace HotChocolate.ModelContextProtocol.Directives;

internal sealed class McpToolDirective
{
public string? Title { get; init; }

/// <summary>
/// If <c>true</c>, the tool may perform destructive updates to its environment. If
/// <c>false</c>, the tool performs only additive updates.
/// </summary>
public bool? DestructiveHint { get; init; }

/// <summary>
/// If <c>true</c>, calling the tool repeatedly with the same arguments will have no additional
/// effect on its environment.
/// </summary>
public bool? IdempotentHint { get; init; }

/// <summary>
/// If <c>true</c>, this tool may interact with an β€œopen world” of external entities. If
/// <c>false</c>, 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.
/// </summary>
public bool? OpenWorldHint { get; init; }
}
Original file line number Diff line number Diff line change
@@ -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
};
}
}
Original file line number Diff line number Diff line change
@@ -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<StreamableHttpHandlerProxy>(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<SseHandlerProxy>(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;
}
}
Loading
Loading