Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace HotChocolate.Adapters.Mcp.Configuration;

/// <summary>
/// Holds the per-schema options that control how MCP tools are generated from operations.
/// </summary>
public sealed class McpToolOptions
{
/// <summary>
/// Gets or sets a value indicating whether named input object types are emitted in a tool's
/// input schema as JSON Schema definitions (<c>$defs</c>) referenced through <c>$ref</c>.
/// <para>
/// When <c>true</c> (the default), each named input object type is emitted once under
/// <c>$defs</c> and referenced by <c>$ref</c>. This is the only way to represent recursive
/// input types (such as filter inputs that reference themselves through <c>and</c>/<c>or</c>)
/// in a finite schema.
/// </para>
/// <para>
/// When <c>false</c>, input object types are inlined. This suits MCP clients that do not
/// support JSON Schema references. Where a type refers to itself, that point is collapsed to
/// a generic object (<c>{ "type": "object" }</c>) and the rest of the schema keeps its full
/// structure.
/// </para>
/// </summary>
public bool UseJsonSchemaReferences { get; set; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ namespace HotChocolate.Adapters.Mcp.Extensions;

internal static class InputValueDefinitionExtensions
{
public static JsonSchema ToJsonSchema(this IInputValueDefinition inputField)
public static JsonSchema ToJsonSchema(
this IInputValueDefinition inputField,
JsonSchemaContext? context = null)
{
var type = inputField.Type;
var schemaBuilder =
type.ToJsonSchemaBuilder(
isOneOf: inputField.DeclaringMember is IInputObjectTypeDefinition
{
IsOneOf: true
});
},
context);

// Description.
if (inputField.Description is not null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Json.Schema;

namespace HotChocolate.Adapters.Mcp.Extensions;

/// <summary>
/// Carries the state shared across a single JSON schema generation walk: the accumulated
/// definitions, the set of input object types currently being expanded (for cycle detection),
/// and whether references are used.
/// </summary>
internal sealed class JsonSchemaContext
{
/// <summary>
/// Gets the named input object type definitions discovered during the walk, keyed by type
/// name. These are emitted at the root of the schema under <c>$defs</c>.
/// </summary>
public Dictionary<string, JsonSchema> Defs { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Gets the names of the input object types currently being expanded on the active path.
/// Used to detect cycles so the walk terminates on self-referencing types.
/// </summary>
public HashSet<string> Building { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Gets a value indicating whether named input object types are emitted as <c>$defs</c>
/// referenced through <c>$ref</c> (<c>true</c>) or inlined (<c>false</c>).
/// </summary>
public bool UseReferences { get; init; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,25 @@ public static void AddMcpSchemaServices(
var mcpManager = applicationServices.GetRequiredService<McpManager>();
var setup = mcpManager.GetSetup(schemaName);

services
.AddLogging()
.TryAddSingleton(
static sp => new OperationToolFactory(sp.GetRequiredService<ISchemaDefinition>()));
services.AddLogging();

services.TryAddSingleton(
static sp =>
{
var toolOptions = new McpToolOptions();

foreach (var configure in sp.GetServices<Action<McpToolOptions>>())
{
configure(toolOptions);
}

return toolOptions;
});

services.TryAddSingleton(
static sp => new OperationToolFactory(
sp.GetRequiredService<ISchemaDefinition>(),
sp.GetRequiredService<McpToolOptions>()));

services.TryAddSingleton<IMcpDiagnosticEvents>(sp =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,30 @@ namespace HotChocolate.Adapters.Mcp.Extensions;

internal static class TypeExtensions
{
public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneOf = false)
public static JsonSchemaBuilder ToJsonSchemaBuilder(
this IType type,
bool isOneOf = false,
JsonSchemaContext? context = null)
{
var namedType = type.NullableType();
var isNullable = !type.IsNonNullType() && !isOneOf;

// Named input object types are emitted once under $defs and referenced through $ref.
// This is the only way to represent a self-referencing input type (such as a filter
// input) in a finite schema.
if (context is { UseReferences: true }
&& namedType is IInputObjectTypeDefinition inputObjectReference)
{
RegisterInputObjectDef(inputObjectReference, context);
return RefSchema(inputObjectReference.Name, isNullable);
}

var schemaBuilder = new JsonSchemaBuilder();

// Type.
var jsonType = type.GetJsonSchemaValueType();

if (!type.IsNonNullType() && !isOneOf)
if (isNullable)
{
// Nullability.
jsonType |= SchemaValueType.Null;
Expand Down Expand Up @@ -47,7 +63,7 @@ public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneO
schemaBuilder.Pattern(pattern);
}

switch (type.NullableType())
switch (namedType)
{
case IEnumTypeDefinition enumType:
// Enum values.
Expand All @@ -67,58 +83,109 @@ public static JsonSchemaBuilder ToJsonSchemaBuilder(this IType type, bool isOneO
break;

case IInputObjectTypeDefinition inputObjectType:
// Object properties.
var objectProperties = new Dictionary<string, JsonSchema>();
var requiredObjectProperties = new List<string>();

foreach (var field in inputObjectType.Fields)
// References are disabled, so the type is inlined. The cycle guard keeps the walk
// finite by collapsing a self-reference to the generic object schema already set.
// A false result means the type is already being expanded on the current path.
if (context?.Building.Add(inputObjectType.Name) == false)
{
var fieldSchema = field.ToJsonSchema();

objectProperties.Add(field.Name, fieldSchema);

if (field.Type.IsNonNullType() && field.DefaultValue is null)
{
requiredObjectProperties.Add(field.Name);
}
break;
}

// OneOf.
if (inputObjectType.IsOneOf)
{
List<JsonSchema> oneOfSchemas = [];

foreach (var (propertyName, propertySchema) in objectProperties)
{
var oneOfSchema = new JsonSchemaBuilder();

oneOfSchema
.Type(SchemaValueType.Object)
.Properties((propertyName, propertySchema))
.Required(propertyName);

oneOfSchemas.Add(oneOfSchema.Build());
}

schemaBuilder.OneOf(oneOfSchemas);
}
else
{
schemaBuilder.Properties(objectProperties);
schemaBuilder.Required(requiredObjectProperties);
}
PopulateInputObjectMembers(schemaBuilder, inputObjectType, context);

context?.Building.Remove(inputObjectType.Name);
break;

case ListType listType:
// Array items.
schemaBuilder.Items(listType.ElementType().ToJsonSchemaBuilder());
schemaBuilder.Items(listType.ElementType().ToJsonSchemaBuilder(context: context));
break;
}

return schemaBuilder;
}

private static void RegisterInputObjectDef(
IInputObjectTypeDefinition inputObjectType,
JsonSchemaContext context)
{
var name = inputObjectType.Name;

// The definition name is reserved before its members are expanded so that a field
// referencing the same type resolves to a $ref instead of recursing.
if (context.Defs.ContainsKey(name) || !context.Building.Add(name))
{
return;
}

var defBuilder = new JsonSchemaBuilder().Type(SchemaValueType.Object);
PopulateInputObjectMembers(defBuilder, inputObjectType, context);

context.Defs[name] = defBuilder;
context.Building.Remove(name);
}

private static JsonSchemaBuilder RefSchema(string typeName, bool isNullable)
{
var reference = new JsonSchemaBuilder().Ref("#/$defs/" + typeName);

if (!isNullable)
{
return reference;
}

// A bare $ref cannot also permit null, so a nullable reference is expressed as an anyOf.
return new JsonSchemaBuilder()
.AnyOf(reference, new JsonSchemaBuilder().Type(SchemaValueType.Null));
}

private static void PopulateInputObjectMembers(
JsonSchemaBuilder schemaBuilder,
IInputObjectTypeDefinition inputObjectType,
JsonSchemaContext? context)
{
// Object properties.
var objectProperties = new Dictionary<string, JsonSchema>();
var requiredObjectProperties = new List<string>();

foreach (var field in inputObjectType.Fields)
{
var fieldSchema = field.ToJsonSchema(context);

objectProperties.Add(field.Name, fieldSchema);

if (field.Type.IsNonNullType() && field.DefaultValue is null)
{
requiredObjectProperties.Add(field.Name);
}
}

// OneOf.
if (inputObjectType.IsOneOf)
{
List<JsonSchema> oneOfSchemas = [];

foreach (var (propertyName, propertySchema) in objectProperties)
{
var oneOfSchema = new JsonSchemaBuilder();

oneOfSchema
.Type(SchemaValueType.Object)
.Properties((propertyName, propertySchema))
.Required(propertyName);

oneOfSchemas.Add(oneOfSchema.Build());
}

schemaBuilder.OneOf(oneOfSchemas);
}
else
{
schemaBuilder.Properties(objectProperties);
schemaBuilder.Required(requiredObjectProperties);
}
}

private static SchemaValueType GetJsonSchemaValueType(this IType type)
{
return type switch
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Frozen;
using System.Text;
using System.Text.Json.Nodes;
using HotChocolate.Adapters.Mcp.Configuration;
using HotChocolate.Adapters.Mcp.Extensions;
using HotChocolate.Adapters.Mcp.Storage;
using HotChocolate.Language;
Expand All @@ -13,7 +14,7 @@

namespace HotChocolate.Adapters.Mcp;

internal sealed class OperationToolFactory(ISchemaDefinition schema)
internal sealed class OperationToolFactory(ISchemaDefinition schema, McpToolOptions options)
{
private static readonly Walker s_walker = new();

Expand Down Expand Up @@ -137,11 +138,12 @@ private JsonSchema CreateInputSchema(OperationDefinitionNode operation)
{
var properties = new Dictionary<string, JsonSchema>();
var requiredProperties = new List<string>();
var context = new JsonSchemaContext { UseReferences = options.UseJsonSchemaReferences };

foreach (var variableNode in operation.VariableDefinitions)
{
var type = variableNode.Type.ToType(schema);
var propertyBuilder = type.ToJsonSchemaBuilder();
var propertyBuilder = type.ToJsonSchemaBuilder(context: context);
var variableName = variableNode.Variable.Name.Value;

// Description.
Expand All @@ -165,12 +167,22 @@ private JsonSchema CreateInputSchema(OperationDefinitionNode operation)
properties.Add(variableName, propertyBuilder);
}

return
var schemaBuilder =
new JsonSchemaBuilder()
.Type(SchemaValueType.Object)
.Properties(properties)
.Required(requiredProperties)
.Build();
.Required(requiredProperties);

// Definitions, emitted in a stable order so the schema is deterministic.
if (context.Defs.Count > 0)
{
schemaBuilder.Defs(
context.Defs
.OrderBy(definition => definition.Key, StringComparer.Ordinal)
.ToDictionary());
}

return schemaBuilder.Build();
}

private static JsonSchema CreateOutputSchema(JsonSchema dataSchema)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@ public static IRequestExecutorBuilder AddMcp(
return builder;
}

/// <summary>
/// Modifies the options that control how MCP tools are generated from operations.
/// </summary>
/// <param name="builder">
/// The <see cref="IRequestExecutorBuilder"/>.
/// </param>
/// <param name="configure">
/// A delegate to modify the <see cref="McpToolOptions"/>.
/// </param>
/// <returns>
/// Returns the <see cref="IRequestExecutorBuilder"/> so that configuration can be chained.
/// </returns>
/// <exception cref="ArgumentNullException">
/// The <paramref name="builder"/> is <c>null</c>.
/// </exception>
/// <exception cref="ArgumentNullException">
/// The <paramref name="configure"/> is <c>null</c>.
/// </exception>
public static IRequestExecutorBuilder ModifyMcpToolOptions(
this IRequestExecutorBuilder builder,
Action<McpToolOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);

builder.ConfigureSchemaServices(services => services.AddSingleton(configure));

return builder;
}

/// <summary>
/// Adds an MCP storage to the schema.
/// </summary>
Expand Down
Loading
Loading