458 lines
No EOL
17 KiB
C#
458 lines
No EOL
17 KiB
C#
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ModelContextProtocol.Protocol;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace ModelContextProtocol.Server;
|
|
|
|
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
|
|
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|
{
|
|
private readonly bool _structuredOutputRequiresWrapping;
|
|
private readonly IReadOnlyList<object> _metadata;
|
|
|
|
/// <summary>
|
|
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
|
|
/// </summary>
|
|
public static new AIFunctionMcpServerTool Create(
|
|
Delegate method,
|
|
McpServerToolCreateOptions? options)
|
|
{
|
|
Throw.IfNull(method);
|
|
|
|
options = DeriveOptions(method.Method, options);
|
|
|
|
return Create(method.Method, method.Target, options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
|
|
/// </summary>
|
|
public static new AIFunctionMcpServerTool Create(
|
|
MethodInfo method,
|
|
object? target,
|
|
McpServerToolCreateOptions? options)
|
|
{
|
|
Throw.IfNull(method);
|
|
|
|
options = DeriveOptions(method, options);
|
|
|
|
return Create(
|
|
AIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)),
|
|
options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="MethodInfo"/> instance.
|
|
/// </summary>
|
|
public static new AIFunctionMcpServerTool Create(
|
|
MethodInfo method,
|
|
Func<RequestContext<CallToolRequestParams>, object> createTargetFunc,
|
|
McpServerToolCreateOptions? options)
|
|
{
|
|
Throw.IfNull(method);
|
|
Throw.IfNull(createTargetFunc);
|
|
|
|
options = DeriveOptions(method, options);
|
|
|
|
return Create(
|
|
AIFunctionFactory.Create(method, args =>
|
|
{
|
|
Debug.Assert(args.Services is RequestServiceProvider<CallToolRequestParams>, $"The service provider should be a {nameof(RequestServiceProvider<CallToolRequestParams>)} for this method to work correctly.");
|
|
return createTargetFunc(((RequestServiceProvider<CallToolRequestParams>)args.Services!).Request);
|
|
}, CreateAIFunctionFactoryOptions(method, options)),
|
|
options);
|
|
}
|
|
|
|
private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
|
|
MethodInfo method, McpServerToolCreateOptions? options) =>
|
|
new()
|
|
{
|
|
Name = options?.Name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name ?? DeriveName(method),
|
|
Description = options?.Description,
|
|
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
|
|
SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
|
|
JsonSchemaCreateOptions = options?.SchemaCreateOptions,
|
|
ConfigureParameterBinding = pi =>
|
|
{
|
|
if (RequestServiceProvider<CallToolRequestParams>.IsAugmentedWith(pi.ParameterType) ||
|
|
(options?.Services?.GetService<IServiceProviderIsService>() is { } ispis &&
|
|
ispis.IsService(pi.ParameterType)))
|
|
{
|
|
return new()
|
|
{
|
|
ExcludeFromSchema = true,
|
|
BindParameter = (pi, args) =>
|
|
args.Services?.GetService(pi.ParameterType) ??
|
|
(pi.HasDefaultValue ? null :
|
|
throw new ArgumentException("No service of the requested type was found.")),
|
|
};
|
|
}
|
|
|
|
if (pi.GetCustomAttribute<FromKeyedServicesAttribute>() is { } keyedAttr)
|
|
{
|
|
return new()
|
|
{
|
|
ExcludeFromSchema = true,
|
|
BindParameter = (pi, args) =>
|
|
(args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
|
|
(pi.HasDefaultValue ? null :
|
|
throw new ArgumentException("No service of the requested type was found.")),
|
|
};
|
|
}
|
|
|
|
return default;
|
|
},
|
|
};
|
|
|
|
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
|
|
public static new AIFunctionMcpServerTool Create(AIFunction function, McpServerToolCreateOptions? options)
|
|
{
|
|
Throw.IfNull(function);
|
|
|
|
Tool tool = new()
|
|
{
|
|
Name = options?.Name ?? function.Name,
|
|
Description = options?.Description ?? function.Description,
|
|
InputSchema = function.JsonSchema,
|
|
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
|
|
};
|
|
|
|
if (options is not null)
|
|
{
|
|
if (options.Title is not null ||
|
|
options.Idempotent is not null ||
|
|
options.Destructive is not null ||
|
|
options.OpenWorld is not null ||
|
|
options.ReadOnly is not null)
|
|
{
|
|
tool.Title = options.Title;
|
|
|
|
tool.Annotations = new()
|
|
{
|
|
Title = options.Title,
|
|
IdempotentHint = options.Idempotent,
|
|
DestructiveHint = options.Destructive,
|
|
OpenWorldHint = options.OpenWorld,
|
|
ReadOnlyHint = options.ReadOnly,
|
|
};
|
|
}
|
|
}
|
|
|
|
return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []);
|
|
}
|
|
|
|
private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
|
|
{
|
|
McpServerToolCreateOptions newOptions = options?.Clone() ?? new();
|
|
|
|
if (method.GetCustomAttribute<McpServerToolAttribute>() is { } toolAttr)
|
|
{
|
|
newOptions.Name ??= toolAttr.Name;
|
|
newOptions.Title ??= toolAttr.Title;
|
|
|
|
if (toolAttr._destructive is bool destructive)
|
|
{
|
|
newOptions.Destructive ??= destructive;
|
|
}
|
|
|
|
if (toolAttr._idempotent is bool idempotent)
|
|
{
|
|
newOptions.Idempotent ??= idempotent;
|
|
}
|
|
|
|
if (toolAttr._openWorld is bool openWorld)
|
|
{
|
|
newOptions.OpenWorld ??= openWorld;
|
|
}
|
|
|
|
if (toolAttr._readOnly is bool readOnly)
|
|
{
|
|
newOptions.ReadOnly ??= readOnly;
|
|
}
|
|
|
|
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
|
|
}
|
|
|
|
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
|
|
{
|
|
newOptions.Description ??= descAttr.Description;
|
|
}
|
|
|
|
// Set metadata if not already provided
|
|
newOptions.Metadata ??= CreateMetadata(method);
|
|
|
|
return newOptions;
|
|
}
|
|
|
|
/// <summary>Gets the <see cref="AIFunction"/> wrapped by this tool.</summary>
|
|
internal AIFunction AIFunction { get; }
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
|
|
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList<object> metadata)
|
|
{
|
|
AIFunction = function;
|
|
ProtocolTool = tool;
|
|
ProtocolTool.McpServerTool = this;
|
|
|
|
_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
|
|
_metadata = metadata;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Tool ProtocolTool { get; }
|
|
|
|
/// <inheritdoc />
|
|
public override IReadOnlyList<object> Metadata => _metadata;
|
|
|
|
/// <inheritdoc />
|
|
public override async ValueTask<CallToolResult> InvokeAsync(
|
|
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
|
|
{
|
|
Throw.IfNull(request);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
request.Services = new RequestServiceProvider<CallToolRequestParams>(request);
|
|
AIFunctionArguments arguments = new() { Services = request.Services };
|
|
|
|
if (request.Params?.Arguments is { } argDict)
|
|
{
|
|
foreach (var kvp in argDict)
|
|
{
|
|
arguments[kvp.Key] = kvp.Value;
|
|
}
|
|
}
|
|
|
|
object? result;
|
|
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
|
|
|
JsonNode? structuredContent = CreateStructuredResponse(result);
|
|
return result switch
|
|
{
|
|
AIContent aiContent => new()
|
|
{
|
|
Content = [aiContent.ToContent()],
|
|
StructuredContent = structuredContent,
|
|
IsError = aiContent is ErrorContent
|
|
},
|
|
|
|
null => new()
|
|
{
|
|
Content = [],
|
|
StructuredContent = structuredContent,
|
|
},
|
|
|
|
string text => new()
|
|
{
|
|
Content = [new TextContentBlock { Text = text }],
|
|
StructuredContent = structuredContent,
|
|
},
|
|
|
|
ContentBlock content => new()
|
|
{
|
|
Content = [content],
|
|
StructuredContent = structuredContent,
|
|
},
|
|
|
|
IEnumerable<AIContent> contentItems => ConvertAIContentEnumerableToCallToolResult(contentItems, structuredContent),
|
|
|
|
IEnumerable<ContentBlock> contents => new()
|
|
{
|
|
Content = [.. contents],
|
|
StructuredContent = structuredContent,
|
|
},
|
|
|
|
CallToolResult callToolResponse => callToolResponse,
|
|
|
|
_ => new()
|
|
{
|
|
Content = [new TextContentBlock { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))) }],
|
|
StructuredContent = structuredContent,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// <summary>Creates a name to use based on the supplied method and naming policy.</summary>
|
|
internal static string DeriveName(MethodInfo method, JsonNamingPolicy? policy = null)
|
|
{
|
|
string name = method.Name;
|
|
|
|
// Remove any "Async" suffix if the method is an async method and if the method name isn't just "Async".
|
|
const string AsyncSuffix = "Async";
|
|
if (IsAsyncMethod(method) &&
|
|
name.EndsWith(AsyncSuffix, StringComparison.Ordinal) &&
|
|
name.Length > AsyncSuffix.Length)
|
|
{
|
|
name = name.Substring(0, name.Length - AsyncSuffix.Length);
|
|
}
|
|
|
|
// Replace anything other than ASCII letters or digits with underscores, trim off any leading or trailing underscores.
|
|
name = NonAsciiLetterDigitsRegex().Replace(name, "_").Trim('_');
|
|
|
|
// If after all our transformations the name is empty, just use the original method name.
|
|
if (name.Length == 0)
|
|
{
|
|
name = method.Name;
|
|
}
|
|
|
|
// Case the name based on the provided naming policy.
|
|
return (policy ?? JsonNamingPolicy.SnakeCaseLower).ConvertName(name) ?? name;
|
|
|
|
static bool IsAsyncMethod(MethodInfo method)
|
|
{
|
|
Type t = method.ReturnType;
|
|
|
|
if (t == typeof(Task) || t == typeof(ValueTask))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (t.IsGenericType)
|
|
{
|
|
t = t.GetGenericTypeDefinition();
|
|
if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item.</summary>
|
|
internal static IReadOnlyList<object> CreateMetadata(MethodInfo method)
|
|
{
|
|
// Add the MethodInfo to the start of the metadata similar to what RouteEndpointDataSource does for minimal endpoints.
|
|
List<object> metadata = [method];
|
|
|
|
// Add class-level attributes first, since those are less specific.
|
|
if (method.DeclaringType is not null)
|
|
{
|
|
metadata.AddRange(method.DeclaringType.GetCustomAttributes());
|
|
}
|
|
|
|
// Add method-level attributes second, since those are more specific.
|
|
// When metadata conflicts, later metadata usually takes precedence with exceptions for metadata like
|
|
// IAllowAnonymous which always take precedence over IAuthorizeData no matter the order.
|
|
metadata.AddRange(method.GetCustomAttributes());
|
|
|
|
return metadata.AsReadOnly();
|
|
}
|
|
|
|
/// <summary>Regex that flags runs of characters other than ASCII digits or letters.</summary>
|
|
#if NET
|
|
[GeneratedRegex("[^0-9A-Za-z]+")]
|
|
private static partial Regex NonAsciiLetterDigitsRegex();
|
|
#else
|
|
private static Regex NonAsciiLetterDigitsRegex() => _nonAsciiLetterDigits;
|
|
private static readonly Regex _nonAsciiLetterDigits = new("[^0-9A-Za-z]+", RegexOptions.Compiled);
|
|
#endif
|
|
|
|
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
|
|
{
|
|
structuredOutputRequiresWrapping = false;
|
|
|
|
if (toolCreateOptions?.UseStructuredContent is not true)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (function.ReturnJsonSchema is not JsonElement outputSchema)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (outputSchema.ValueKind is not JsonValueKind.Object ||
|
|
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
|
|
typeProperty.ValueKind is not JsonValueKind.String ||
|
|
typeProperty.GetString() is not "object")
|
|
{
|
|
// If the output schema is not an object, need to modify to be a valid MCP output schema.
|
|
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
|
|
|
|
if (schemaNode is JsonObject objSchema &&
|
|
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
|
|
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
|
|
{
|
|
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
|
|
objSchema["type"] = "object";
|
|
}
|
|
else
|
|
{
|
|
// For anything else, wrap the schema in an envelope with a "result" property.
|
|
schemaNode = new JsonObject
|
|
{
|
|
["type"] = "object",
|
|
["properties"] = new JsonObject
|
|
{
|
|
["result"] = schemaNode
|
|
},
|
|
["required"] = new JsonArray { (JsonNode)"result" }
|
|
};
|
|
|
|
structuredOutputRequiresWrapping = true;
|
|
}
|
|
|
|
outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
|
|
}
|
|
|
|
return outputSchema;
|
|
}
|
|
|
|
private JsonNode? CreateStructuredResponse(object? aiFunctionResult)
|
|
{
|
|
if (ProtocolTool.OutputSchema is null)
|
|
{
|
|
// Only provide structured responses if the tool has an output schema defined.
|
|
return null;
|
|
}
|
|
|
|
JsonNode? nodeResult = aiFunctionResult switch
|
|
{
|
|
JsonNode node => node,
|
|
JsonElement jsonElement => JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement),
|
|
_ => JsonSerializer.SerializeToNode(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
|
|
};
|
|
|
|
if (_structuredOutputRequiresWrapping)
|
|
{
|
|
return new JsonObject
|
|
{
|
|
["result"] = nodeResult
|
|
};
|
|
}
|
|
|
|
return nodeResult;
|
|
}
|
|
|
|
private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable<AIContent> contentItems, JsonNode? structuredContent)
|
|
{
|
|
List<ContentBlock> contentList = [];
|
|
bool allErrorContent = true;
|
|
bool hasAny = false;
|
|
|
|
foreach (var item in contentItems)
|
|
{
|
|
contentList.Add(item.ToContent());
|
|
hasAny = true;
|
|
|
|
if (allErrorContent && item is not ErrorContent)
|
|
{
|
|
allErrorContent = false;
|
|
}
|
|
}
|
|
|
|
return new()
|
|
{
|
|
Content = contentList,
|
|
StructuredContent = structuredContent,
|
|
IsError = allErrorContent && hasAny
|
|
};
|
|
}
|
|
} |