Update M.E.AI to latest version (#241)

Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
This commit is contained in:
Stephen Toub 2025-04-09 04:03:24 -04:00 • committed by GitHub
commit 0c502c43af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 76 additions and 1147 deletions

View file

@ -2,14 +2,14 @@
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<System10Version>10.0.0-preview.2.25163.2</System10Version>
<MicrosoftExtensionsAIVersion>9.3.0-preview.1.25161.3</MicrosoftExtensionsAIVersion>
<MicrosoftExtensionsAIVersion>9.4.0-preview.1.25207.5</MicrosoftExtensionsAIVersion>
</PropertyGroup>
<!-- Product dependencies netstandard -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageVersion Include="Microsoft.Bcl.Memory" Version="9.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
<PackageVersion Include="System.Threading.Channels" Version="8.0.0" />
@ -18,15 +18,15 @@
<!-- Product dependencies LTS -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
</ItemGroup>
<!-- Product dependencies .NET 9 -->
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="System.IO.Pipelines" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
<PackageVersion Include="System.IO.Pipelines" Version="9.0.4" />
</ItemGroup>
<!-- Product dependencies shared -->
@ -49,11 +49,11 @@
</PackageVersion>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />

View file

@ -25,7 +25,7 @@ Console.WriteLine();
// Create an IChatClient. (This shows using OpenAIClient, but it could be any other IChatClient implementation.)
// Provide your own OPENAI_API_KEY via an environment variable.
using IChatClient chatClient =
new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")).AsChatClient("gpt-4o-mini")
new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")).GetChatClient("gpt-4o-mini").AsIChatClient()
.AsBuilder().UseFunctionInvocation().Build();
// Have a conversation, making all tools available to the LLM.

View file

@ -2,12 +2,20 @@
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Text.Json;
using System.Collections.ObjectModel;
namespace ModelContextProtocol.Client;
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
public sealed class McpClientTool : AIFunction
{
/// <summary>Additional properties exposed from tools.</summary>
private static readonly ReadOnlyDictionary<string, object?> s_additionalProperties =
new(new Dictionary<string, object?>()
{
["Strict"] = false, // some MCP schemas may not meet "strict" requirements
});
private readonly IMcpClient _client;
private readonly string _name;
private readonly string _description;
@ -62,14 +70,13 @@ public sealed class McpClientTool : AIFunction
public override JsonSerializerOptions JsonSerializerOptions { get; }
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments, CancellationToken cancellationToken)
{
IReadOnlyDictionary<string, object?> argDict =
arguments as IReadOnlyDictionary<string, object?> ??
arguments.ToDictionary();
public override IReadOnlyDictionary<string, object?> AdditionalProperties => s_additionalProperties;
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, argDict, JsonSerializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
/// <inheritdoc/>
protected async override ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments, CancellationToken cancellationToken)
{
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, arguments, JsonSerializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
}
}

View file

@ -30,14 +30,10 @@
<!-- Dependencies needed by all -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Net.ServerSentEvents" />
<!-- Temporarily removed until new version can be picked up that
has reduced dependencies:
<PackageReference Include="Microsoft.Extensions.AI" />
-->
</ItemGroup>
<ItemGroup>

View file

@ -2,7 +2,6 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
@ -12,10 +11,6 @@ namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerPrompt"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
{
/// <summary>Key used temporarily for flowing request context into an AIFunction.</summary>
/// <remarks>This will be replaced with use of AIFunctionArguments.Context.</remarks>
internal const string RequestContextKey = "__temporary_RequestContext";
/// <summary>
/// Creates an <see cref="McpServerPrompt"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
@ -40,17 +35,10 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
{
Throw.IfNull(method);
// TODO: Once this repo consumes a new build of Microsoft.Extensions.AI containing
// https://github.com/dotnet/extensions/pull/6158,
// https://github.com/dotnet/extensions/pull/6162, and
// https://github.com/dotnet/extensions/pull/6175, switch over to using the real
// AIFunctionFactory, delete the TemporaryXx types, and fix-up the mechanism by
// which the arguments are passed.
options = DeriveOptions(method, options);
return Create(
TemporaryAIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)),
AIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)),
options);
}
@ -67,17 +55,17 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
options = DeriveOptions(method, options);
return Create(
TemporaryAIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, options)),
AIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, options)),
options);
}
private static TemporaryAIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
MethodInfo method, McpServerPromptCreateOptions? options) =>
new()
{
Name = options?.Name ?? method.GetCustomAttribute<McpServerPromptAttribute>()?.Name,
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => Task.FromResult(result),
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<GetPromptRequestParams>))
@ -129,9 +117,9 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
return default;
static RequestContext<GetPromptRequestParams>? GetRequestContext(IReadOnlyDictionary<string, object?> args)
static RequestContext<GetPromptRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.TryGetValue(RequestContextKey, out var orc) &&
if (args.Context?.TryGetValue(typeof(RequestContext<GetPromptRequestParams>), out var orc) is true &&
orc is RequestContext<GetPromptRequestParams> requestContext)
{
return requestContext;
@ -204,14 +192,22 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
RequestContext<GetPromptRequestParams> request, CancellationToken cancellationToken = default)
{
Throw.IfNull(request);
cancellationToken.ThrowIfCancellationRequested();
// TODO: Once we shift to the real AIFunctionFactory, the request should be passed via AIFunctionArguments.Context.
Dictionary<string, object?> arguments = request.Params?.Arguments is { } paramArgs ?
paramArgs.ToDictionary(entry => entry.Key, entry => entry.Value.AsObject()) :
[];
arguments[RequestContextKey] = request;
AIFunctionArguments arguments = new()
{
Services = request.Server?.Services,
Context = new Dictionary<object, object?>() { [typeof(RequestContext<GetPromptRequestParams>)] = request }
};
var argDict = request.Params?.Arguments;
if (argDict is not null)
{
foreach (var kvp in argDict)
{
arguments[kvp.Key] = kvp.Value;
}
}
object? result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);

View file

@ -12,10 +12,6 @@ namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed class AIFunctionMcpServerTool : McpServerTool
{
/// <summary>Key used temporarily for flowing request context into an AIFunction.</summary>
/// <remarks>This will be replaced with use of AIFunctionArguments.Context.</remarks>
internal const string RequestContextKey = "__temporary_RequestContext";
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
@ -40,17 +36,10 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
{
Throw.IfNull(method);
// TODO: Once this repo consumes a new build of Microsoft.Extensions.AI containing
// https://github.com/dotnet/extensions/pull/6158,
// https://github.com/dotnet/extensions/pull/6162, and
// https://github.com/dotnet/extensions/pull/6175, switch over to using the real
// AIFunctionFactory, delete the TemporaryXx types, and fix-up the mechanism by
// which the arguments are passed.
options = DeriveOptions(method, options);
return Create(
TemporaryAIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)),
AIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, options)),
options);
}
@ -67,17 +56,17 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
options = DeriveOptions(method, options);
return Create(
TemporaryAIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, options)),
AIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, options)),
options);
}
private static TemporaryAIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
MethodInfo method, McpServerToolCreateOptions? options) =>
new()
{
Name = options?.Name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name,
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => Task.FromResult(result),
MarshalResult = static (result, _, cancellationToken) => new ValueTask<object?>(result),
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<CallToolRequestParams>))
@ -150,9 +139,9 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
return default;
static RequestContext<CallToolRequestParams>? GetRequestContext(IReadOnlyDictionary<string, object?> args)
static RequestContext<CallToolRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.TryGetValue(RequestContextKey, out var orc) &&
if (args.Context?.TryGetValue(typeof(RequestContext<CallToolRequestParams>), out var orc) is true &&
orc is RequestContext<CallToolRequestParams> requestContext)
{
return requestContext;
@ -251,14 +240,22 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
{
Throw.IfNull(request);
cancellationToken.ThrowIfCancellationRequested();
// TODO: Once we shift to the real AIFunctionFactory, the request should be passed via AIFunctionArguments.Context.
Dictionary<string, object?> arguments = request.Params?.Arguments is { } paramArgs ?
paramArgs.ToDictionary(entry => entry.Key, entry => entry.Value.AsObject()) :
[];
arguments[RequestContextKey] = request;
AIFunctionArguments arguments = new()
{
Services = request.Server?.Services,
Context = new Dictionary<object, object?>() { [typeof(RequestContext<CallToolRequestParams>)] = request }
};
var argDict = request.Params?.Arguments;
if (argDict is not null)
{
foreach (var kvp in argDict)
{
arguments[kvp.Key] = kvp.Value;
}
}
object? result;
try
@ -313,8 +310,6 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
CallToolResponse callToolResponse => callToolResponse,
// TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69:
// Add specialization for annotations.
_ => new()
{
Content = [new()

View file

@ -1,135 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using ModelContextProtocol.Utils;
using System.Buffers;
using System.Reflection;
using System.Text.RegularExpressions;
namespace Microsoft.Extensions.AI;
internal static partial class TemporaryAIFunctionFactory
{
/// <summary>
/// Removes characters from a .NET member name that shouldn't be used in an AI function name.
/// </summary>
/// <param name="memberName">The .NET member name that should be sanitized.</param>
/// <returns>
/// Replaces non-alphanumeric characters in the identifier with the underscore character.
/// Primarily intended to remove characters produced by compiler-generated method name mangling.
/// </returns>
internal static string SanitizeMemberName(string memberName)
{
Throw.IfNull(memberName);
return InvalidNameCharsRegex().Replace(memberName, "_");
}
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#if NET
[GeneratedRegex("[^0-9A-Za-z_]")]
private static partial Regex InvalidNameCharsRegex();
#else
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
#endif
/// <summary>Invokes the MethodInfo with the specified target object and arguments.</summary>
private static object? ReflectionInvoke(MethodInfo method, object? target, object?[]? arguments)
{
#if NET
return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null);
#else
try
{
return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null);
}
catch (TargetInvocationException e) when (e.InnerException is not null)
{
// If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions
// is ignored, the original exception will be wrapped in a TargetInvocationException.
// Unwrap it and throw that original exception, maintaining its stack information.
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw();
throw;
}
#endif
}
/// <summary>
/// Implements a simple write-only memory stream that uses pooled buffers.
/// </summary>
private sealed class PooledMemoryStream : Stream
{
private const int DefaultBufferSize = 4096;
private byte[] _buffer;
private int _position;
public PooledMemoryStream(int initialCapacity = DefaultBufferSize)
{
_buffer = ArrayPool<byte>.Shared.Rent(initialCapacity);
_position = 0;
}
public ReadOnlySpan<byte> GetBuffer() => _buffer.AsSpan(0, _position);
public override bool CanWrite => true;
public override bool CanRead => false;
public override bool CanSeek => false;
public override long Length => _position;
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
EnsureNotDisposed();
EnsureCapacity(_position + count);
Buffer.BlockCopy(buffer, offset, _buffer, _position, count);
_position += count;
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (_buffer is not null)
{
ArrayPool<byte>.Shared.Return(_buffer);
_buffer = null!;
}
base.Dispose(disposing);
}
private void EnsureCapacity(int requiredCapacity)
{
if (requiredCapacity <= _buffer.Length)
{
return;
}
int newCapacity = Math.Max(requiredCapacity, _buffer.Length * 2);
byte[] newBuffer = ArrayPool<byte>.Shared.Rent(newCapacity);
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, _position);
ArrayPool<byte>.Shared.Return(_buffer);
_buffer = newBuffer;
}
private void EnsureNotDisposed()
{
if (_buffer is null)
{
Throw();
static void Throw() => throw new ObjectDisposedException(nameof(PooledMemoryStream));
}
}
}
}

View file

@ -1,791 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
#if !NET
using System.Linq;
#endif
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using static ModelContextProtocol.Utils.Json.McpJsonUtilities;
#pragma warning disable CA1031 // Do not catch general exception types
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
#pragma warning disable SA1118 // Parameter should not span multiple lines
#pragma warning disable SA1500 // Braces for multi-line statements should not share line
namespace Microsoft.Extensions.AI;
/// <summary>Provides factory methods for creating commonly used implementations of <see cref="AIFunction"/>.</summary>
internal static partial class TemporaryAIFunctionFactory
{
/// <summary>Holds the default options instance used when creating function.</summary>
private static readonly TemporaryAIFunctionFactoryOptions _defaultOptions = new();
/// <summary>Creates an <see cref="AIFunction"/> instance for a method, specified via a delegate.</summary>
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="method"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are
/// marshaled to the expected type via JSON and using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>,
/// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown,
/// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(Delegate method, TemporaryAIFunctionFactoryOptions? options)
{
Throw.IfNull(method);
return ReflectionAIFunction.Build(method.Method, method.Target, options ?? _defaultOptions);
}
/// <summary>Creates an <see cref="AIFunction"/> instance for a method, specified via a delegate.</summary>
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="name">The name to use for the <see cref="AIFunction"/>.</param>
/// <param name="description">The description to use for the <see cref="AIFunction"/>.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> used to marshal function parameters and any return value.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="serializerOptions"/>.
/// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using
/// <paramref name="serializerOptions"/>. If the argument is a <see cref="JsonElement"/>, <see cref="JsonDocument"/>,
/// or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown, it is
/// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(method);
TemporaryAIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null
? _defaultOptions
: new()
{
Name = name,
Description = description,
SerializerOptions = serializerOptions,
};
return ReflectionAIFunction.Build(method.Method, method.Target, createOptions);
}
/// <summary>
/// Creates an <see cref="AIFunction"/> instance for a method, specified via an <see cref="MethodInfo"/> instance
/// and an optional target object if the method is an instance method.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="target">
/// The target object for the <paramref name="method"/> if it represents an instance method.
/// This should be <see langword="null"/> if and only if <paramref name="method"/> is a static method.
/// </param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="method"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are
/// marshaled to the expected type via JSON and using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>,
/// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown,
/// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(MethodInfo method, object? target, TemporaryAIFunctionFactoryOptions? options)
{
Throw.IfNull(method);
return ReflectionAIFunction.Build(method, target, options ?? _defaultOptions);
}
/// <summary>
/// Creates an <see cref="AIFunction"/> instance for a method, specified via an <see cref="MethodInfo"/> instance
/// and an optional target object if the method is an instance method.
/// </summary>
/// <param name="method">The instance method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="targetType">
/// The <see cref="Type"/> to construct an instance of on which to invoke <paramref name="method"/> when
/// the resulting <see cref="AIFunction"/> is invoked. If services are provided,
/// ActivatorUtilities.CreateInstance will be used to construct the instance using those services; otherwise,
/// <see cref="Activator.CreateInstance(Type)"/> is used, utilizing the type's public parameterless constructor.
/// If an instance can't be constructed, an exception is thrown during the function's invocation.
/// </param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="method"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are
/// marshaled to the expected type via JSON and using <paramref name="options"/>'s
/// <see cref="TemporaryAIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>,
/// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown,
/// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions? options = null)
{
Throw.IfNull(method);
Throw.IfNull(targetType);
return ReflectionAIFunction.Build(method, targetType, options ?? _defaultOptions);
}
/// <summary>
/// Creates an <see cref="AIFunction"/> instance for a method, specified via an <see cref="MethodInfo"/> instance
/// and an optional target object if the method is an instance method.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="target">
/// The target object for the <paramref name="method"/> if it represents an instance method.
/// This should be <see langword="null"/> if and only if <paramref name="method"/> is a static method.
/// </param>
/// <param name="name">The name to use for the <see cref="AIFunction"/>.</param>
/// <param name="description">The description to use for the <see cref="AIFunction"/>.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> used to marshal function parameters and return value.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="serializerOptions"/>.
/// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using
/// <paramref name="serializerOptions"/>. If the argument is a <see cref="JsonElement"/>, <see cref="JsonDocument"/>,
/// or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown, it is
/// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(method);
TemporaryAIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null
? _defaultOptions
: new()
{
Name = name,
Description = description,
SerializerOptions = serializerOptions,
};
return ReflectionAIFunction.Build(method, target, createOptions);
}
private sealed class ReflectionAIFunction : AIFunction
{
public static ReflectionAIFunction Build(MethodInfo method, object? target, TemporaryAIFunctionFactoryOptions options)
{
Throw.IfNull(method);
if (method.ContainsGenericParameters)
{
throw new ArgumentException("Open generic methods are not supported", nameof(method));
}
if (method.IsStatic)
{
if (target is not null)
{
throw new ArgumentException("The specified method is a static method but the specified target is non-null.", nameof(target));
}
}
else if (target is null)
{
throw new ArgumentNullException(nameof(target), "The specified method is an instance method but the specified target is null.");
}
var functionDescriptor = ReflectionAIFunctionDescriptor.GetOrCreate(method, options);
if (target is null && options.AdditionalProperties is null)
{
// We can use a cached value for static methods not specifying additional properties.
return functionDescriptor.CachedDefaultInstance ??= new(functionDescriptor, target, options);
}
return new(functionDescriptor, target, options);
}
public static ReflectionAIFunction Build(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions options)
{
Throw.IfNull(method);
if (method.ContainsGenericParameters)
{
throw new ArgumentException("Open generic methods are not supported", nameof(method));
}
if (method.IsStatic)
{
throw new ArgumentException("The method must be an instance method.", nameof(method));
}
if (method.DeclaringType is { } declaringType &&
!declaringType.IsAssignableFrom(targetType))
{
throw new ArgumentException("The target type must be assignable to the method's declaring type.", nameof(targetType));
}
return new(ReflectionAIFunctionDescriptor.GetOrCreate(method, options), targetType, options);
}
private ReflectionAIFunction(ReflectionAIFunctionDescriptor functionDescriptor, object? target, TemporaryAIFunctionFactoryOptions options)
{
FunctionDescriptor = functionDescriptor;
Target = target;
AdditionalProperties = options.AdditionalProperties ?? new Dictionary<string, object?>();
}
private ReflectionAIFunction(
ReflectionAIFunctionDescriptor functionDescriptor,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions options)
{
FunctionDescriptor = functionDescriptor;
TargetType = targetType;
AdditionalProperties = options.AdditionalProperties ?? new Dictionary<string, object?>();
}
public ReflectionAIFunctionDescriptor FunctionDescriptor { get; }
public object? Target { get; }
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? TargetType { get; }
public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
public override string Name => FunctionDescriptor.Name;
public override string Description => FunctionDescriptor.Description;
public override MethodInfo UnderlyingMethod => FunctionDescriptor.Method;
public override JsonElement JsonSchema => FunctionDescriptor.JsonSchema;
public override JsonSerializerOptions JsonSerializerOptions => FunctionDescriptor.JsonSerializerOptions;
protected override async Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments,
CancellationToken cancellationToken)
{
Dictionary<string, object?> argumentsDictionary = arguments.ToDictionary();
bool disposeTarget = false;
object? target = Target;
try
{
if (TargetType is { } targetType)
{
Debug.Assert(target is null, "Expected target to be null when we have a non-null target type");
Debug.Assert(!FunctionDescriptor.Method.IsStatic, "Expected an instance method");
if (argumentsDictionary.TryGetValue(AIFunctionMcpServerTool.RequestContextKey, out object? value) &&
value is RequestContext<CallToolRequestParams> requestContext &&
requestContext.Server?.Services is { } services)
{
target = ActivatorUtilities.CreateInstance(services, targetType!);
}
else
{
target = Activator.CreateInstance(targetType);
}
disposeTarget = true;
}
var paramMarshallers = FunctionDescriptor.ParameterMarshallers;
object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : [];
for (int i = 0; i < args.Length; i++)
{
args[i] = paramMarshallers[i](argumentsDictionary, cancellationToken);
}
return await FunctionDescriptor.ReturnParameterMarshaller(
ReflectionInvoke(FunctionDescriptor.Method, target, args), cancellationToken).ConfigureAwait(false);
}
finally
{
if (disposeTarget)
{
if (target is IAsyncDisposable ad)
{
await ad.DisposeAsync().ConfigureAwait(false);
}
else if (target is IDisposable d)
{
d.Dispose();
}
}
}
}
}
/// <summary>
/// A descriptor for a .NET method-backed AIFunction that precomputes its marshalling delegates and JSON schema.
/// </summary>
private sealed class ReflectionAIFunctionDescriptor
{
private const int InnerCacheSoftLimit = 512;
private static readonly ConditionalWeakTable<JsonSerializerOptions, ConcurrentDictionary<DescriptorKey, ReflectionAIFunctionDescriptor>> _descriptorCache = new();
/// <summary>A boxed <see cref="CancellationToken.None"/>.</summary>
private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken);
/// <summary>
/// Gets or creates a descriptors using the specified method and options.
/// </summary>
public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, TemporaryAIFunctionFactoryOptions options)
{
JsonSerializerOptions serializerOptions = options.SerializerOptions ?? AIJsonUtilities.DefaultOptions;
AIJsonSchemaCreateOptions schemaOptions = options.JsonSchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default;
serializerOptions.MakeReadOnly();
ConcurrentDictionary<DescriptorKey, ReflectionAIFunctionDescriptor> innerCache = _descriptorCache.GetOrCreateValue(serializerOptions);
DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, schemaOptions);
if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor))
{
return descriptor;
}
descriptor = new(key, serializerOptions);
return innerCache.Count < InnerCacheSoftLimit
? innerCache.GetOrAdd(key, descriptor)
: descriptor;
}
private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions serializerOptions)
{
ParameterInfo[] parameters = key.Method.GetParameters();
// Determine how each parameter should be bound.
Dictionary<ParameterInfo, TemporaryAIFunctionFactoryOptions.ParameterBindingOptions>? boundParameters = null;
if (parameters.Length != 0 && key.GetBindParameterOptions is not null)
{
boundParameters = new(parameters.Length);
for (int i = 0; i < parameters.Length; i++)
{
boundParameters[parameters[i]] = key.GetBindParameterOptions(parameters[i]);
}
}
// Get marshaling delegates for parameters.
ParameterMarshallers = parameters.Length > 0 ? new Func<IReadOnlyDictionary<string, object?>, CancellationToken, object?>[parameters.Length] : [];
for (int i = 0; i < parameters.Length; i++)
{
if (boundParameters?.TryGetValue(parameters[i], out TemporaryAIFunctionFactoryOptions.ParameterBindingOptions options) is not true)
{
options = default;
}
ParameterMarshallers[i] = GetParameterMarshaller(serializerOptions, options, parameters[i]);
}
// Get a marshaling delegate for the return value.
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions);
Method = key.Method;
Name = key.Name ?? GetFunctionName(key.Method);
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
JsonSerializerOptions = serializerOptions;
JsonSchema = CreateFunctionJsonSchema(
key.Method,
Name,
Description,
serializerOptions,
parameterInfo =>
{
// AIFunctionArguments and IServiceProvider parameters are always excluded from the schema.
if (parameterInfo.ParameterType == typeof(IServiceProvider))
{
return false;
}
// If the parameter is marked as excluded by GetBindParameterOptions, exclude it.
if (boundParameters?.TryGetValue(parameterInfo, out var options) is true &&
options.ExcludeFromSchema)
{
return false;
}
// Everything else is included.
return true;
});
}
/// <summary>
/// Determines a JSON schema for the provided method.
/// </summary>
/// <param name="method">The method from which to extract schema information.</param>
/// <param name="title">The title keyword used by the method schema.</param>
/// <param name="description">The description keyword used by the method schema.</param>
/// <param name="serializerOptions">The options used to extract the schema from the specified type.</param>
/// <param name="includeParameter">Delegate controlling whether to include a parameter in the schema.</param>
/// <returns>A JSON schema document encoded as a <see cref="JsonElement"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
private static JsonElement CreateFunctionJsonSchema(
MethodBase method,
string? title = null,
string? description = null,
JsonSerializerOptions? serializerOptions = null,
Func<ParameterInfo, bool>? includeParameter = null)
{
Throw.IfNull(method);
serializerOptions ??= DefaultOptions;
title ??= method.Name;
description ??= method.GetCustomAttribute<DescriptionAttribute>()?.Description;
JsonObject parameterSchemas = new();
JsonArray? requiredProperties = null;
foreach (ParameterInfo parameter in method.GetParameters())
{
if (string.IsNullOrWhiteSpace(parameter.Name))
{
throw new ArgumentException("Parameter is missing a name.", nameof(parameter));
}
if (parameter.ParameterType == typeof(CancellationToken))
{
// CancellationToken is a special case that, by convention, we don't want to include in the schema.
// Invocations of methods that include a CancellationToken argument should also special-case CancellationToken
// to pass along what relevant token into the method's invocation.
continue;
}
if (includeParameter?.Invoke(parameter) is false)
{
continue;
}
JsonNode? parameterSchema = JsonSerializer.SerializeToNode(AIJsonUtilities.CreateJsonSchema(
type: parameter.ParameterType,
description: parameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description,
hasDefaultValue: parameter.HasDefaultValue,
defaultValue: parameter.HasDefaultValue ? parameter.DefaultValue : null,
serializerOptions), McpJsonUtilities.JsonContext.Default.JsonElement);
parameterSchemas.Add(parameter.Name, parameterSchema);
if (!parameter.IsOptional)
{
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
}
}
JsonObject schema = new();
if (!string.IsNullOrWhiteSpace(title))
{
schema["title"] = title;
}
if (!string.IsNullOrWhiteSpace(description))
{
schema["description"] = description;
}
schema["type"] = "object"; // Method schemas always hardcode the type as "object".
schema["properties"] = parameterSchemas;
if (requiredProperties is not null)
{
schema["required"] = requiredProperties;
}
return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode);
}
public string Name { get; }
public string Description { get; }
public MethodInfo Method { get; }
public JsonSerializerOptions JsonSerializerOptions { get; }
public JsonElement JsonSchema { get; }
public Func<IReadOnlyDictionary<string, object?>, CancellationToken, object?>[] ParameterMarshallers { get; }
public Func<object?, CancellationToken, Task<object?>> ReturnParameterMarshaller { get; }
public ReflectionAIFunction? CachedDefaultInstance { get; set; }
private static string GetFunctionName(MethodInfo method)
{
// Get the function name to use.
string name = SanitizeMemberName(method.Name);
const string AsyncSuffix = "Async";
if (IsAsyncMethod(method) &&
name.EndsWith(AsyncSuffix, StringComparison.Ordinal) &&
name.Length > AsyncSuffix.Length)
{
name = name.Substring(0, name.Length - AsyncSuffix.Length);
}
return 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>
/// Gets a delegate for handling the marshaling of a parameter.
/// </summary>
private static Func<IReadOnlyDictionary<string, object?>, CancellationToken, object?> GetParameterMarshaller(
JsonSerializerOptions serializerOptions,
TemporaryAIFunctionFactoryOptions.ParameterBindingOptions bindingOptions,
ParameterInfo parameter)
{
if (string.IsNullOrWhiteSpace(parameter.Name))
{
throw new ArgumentException("Parameter is missing a name.", nameof(parameter));
}
Type parameterType = parameter.ParameterType;
// For CancellationToken parameters, we always bind to the token passed directly to InvokeAsync.
if (parameterType == typeof(CancellationToken))
{
return static (_, cancellationToken) =>
cancellationToken == default ? _boxedDefaultCancellationToken : // optimize common case of a default CT to avoid boxing
cancellationToken;
}
// CancellationToken is the only parameter type that's handled exclusively by the implementation.
// Now that it's been processed, check to see if the parameter should be handled via BindParameter.
if (bindingOptions.BindParameter is { } bindParameter)
{
return (arguments, _) => bindParameter(parameter, arguments);
}
// We're now into default handling of everything else.
// For IServiceProvider parameters, we bind to the services passed directly to InvokeAsync via AIFunctionArguments.
if (parameterType == typeof(IServiceProvider))
{
return (arguments, _) =>
{
arguments.TryGetValue("__temporary_IServiceProvider", out object? objServices);
IServiceProvider? services = objServices as IServiceProvider;
if (services is null && !parameter.HasDefaultValue)
{
throw new ArgumentException($"An {nameof(IServiceProvider)} was not provided for the {parameter.Name} parameter.", nameof(arguments));
}
return services;
};
}
// For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary.
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType);
return (arguments, _) =>
{
// If the parameter has an argument specified in the dictionary, return that argument.
if (arguments.TryGetValue(parameter.Name, out object? value))
{
return value switch
{
null => null, // Return as-is if null -- if the parameter is a struct this will be handled by MethodInfo.Invoke
_ when parameterType.IsInstanceOfType(value) => value, // Do nothing if value is assignable to parameter type
JsonElement element => JsonSerializer.Deserialize(element, typeInfo),
JsonDocument doc => JsonSerializer.Deserialize(doc, typeInfo),
JsonNode node => JsonSerializer.Deserialize(node, typeInfo),
_ => MarshallViaJsonRoundtrip(value),
};
object? MarshallViaJsonRoundtrip(object value)
{
try
{
string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType()));
return JsonSerializer.Deserialize(json, typeInfo);
}
catch
{
// Eat any exceptions and fall back to the original value to force a cast exception later on.
return value;
}
}
}
// If the parameter is required and there's no argument specified for it, throw.
if (!parameter.HasDefaultValue)
{
throw new ArgumentException($"Missing required parameter '{parameter.Name}' for method '{parameter.Member.Name}'.", nameof(arguments));
}
// Otherwise, use the optional parameter's default value.
return parameter.DefaultValue;
};
}
/// <summary>
/// Gets a delegate for handling the result value of a method, converting it into the <see cref="Task{FunctionResult}"/> to return from the invocation.
/// </summary>
private static Func<object?, CancellationToken, Task<object?>> GetReturnParameterMarshaller(
DescriptorKey key, JsonSerializerOptions serializerOptions)
{
Type returnType = key.Method.ReturnType;
JsonTypeInfo returnTypeInfo;
Func<object?, Type?, CancellationToken, Task<object?>>? marshalResult = key.MarshalResult;
// Void
if (returnType == typeof(void))
{
if (marshalResult is not null)
{
return (result, cancellationToken) => marshalResult(null, null, cancellationToken);
}
return static (_, _) => Task.FromResult<object?>((object?)null);
}
// Task
if (returnType == typeof(Task))
{
if (marshalResult is not null)
{
return async (result, cancellationToken) =>
{
await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false);
return await marshalResult(null, null, cancellationToken).ConfigureAwait(false);
};
}
return async static (result, _) =>
{
await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false);
return null;
};
}
// ValueTask
if (returnType == typeof(ValueTask))
{
if (marshalResult is not null)
{
return async (result, cancellationToken) =>
{
await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false);
return await marshalResult(null, null, cancellationToken).ConfigureAwait(false);
};
}
return async static (result, _) =>
{
await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false);
return null;
};
}
if (returnType.IsGenericType)
{
// Task<T>
if (returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult);
returnTypeInfo = serializerOptions.GetTypeInfo(taskResultGetter.ReturnType);
return async (taskObj, cancellationToken) =>
{
await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(false);
object? result = ReflectionInvoke(taskResultGetter, taskObj, null);
return marshalResult is not null ?
await marshalResult(result, returnTypeInfo.Type, cancellationToken).ConfigureAwait(false) :
await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false);
};
}
// ValueTask<T>
if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))
{
MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask);
MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult);
returnTypeInfo = serializerOptions.GetTypeInfo(asTaskResultGetter.ReturnType);
return async (taskObj, cancellationToken) =>
{
var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!;
await task.ConfigureAwait(false);
object? result = ReflectionInvoke(asTaskResultGetter, task, null);
return marshalResult is not null ?
await marshalResult(result, returnTypeInfo.Type, cancellationToken).ConfigureAwait(false) :
await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false);
};
}
}
// For everything else, just serialize the result as-is.
returnTypeInfo = serializerOptions.GetTypeInfo(returnType);
return marshalResult is not null ?
(result, cancellationToken) => marshalResult(result, returnTypeInfo.Type, cancellationToken) :
(result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken);
static async Task<object?> SerializeResultAsync(object? result, JsonTypeInfo returnTypeInfo, CancellationToken cancellationToken)
{
if (returnTypeInfo.Kind is JsonTypeInfoKind.None)
{
// Special-case trivial contracts to avoid the more expensive general-purpose serialization path.
return JsonSerializer.SerializeToElement(result, returnTypeInfo);
}
// Serialize asynchronously to support potential IAsyncEnumerable responses.
using PooledMemoryStream stream = new();
await JsonSerializer.SerializeAsync(stream, result, returnTypeInfo, cancellationToken).ConfigureAwait(false);
Utf8JsonReader reader = new(stream.GetBuffer());
return JsonElement.ParseValue(ref reader);
}
// Throws an exception if a result is found to be null unexpectedly
static object ThrowIfNullResult(object? result) => result ?? throw new InvalidOperationException("Function returned null unexpectedly.");
}
private static readonly MethodInfo _taskGetResult = typeof(Task<>).GetProperty(nameof(Task<int>.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!;
private static readonly MethodInfo _valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask<int>.AsTask), BindingFlags.Instance | BindingFlags.Public)!;
private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedType, MethodInfo genericMethodDefinition)
{
Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type.");
#if NET
return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition);
#else
const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken);
#endif
}
private record struct DescriptorKey(
MethodInfo Method,
string? Name,
string? Description,
Func<ParameterInfo, TemporaryAIFunctionFactoryOptions.ParameterBindingOptions>? GetBindParameterOptions,
Func<object?, Type?, CancellationToken, Task<object?>>? MarshalResult,
AIJsonSchemaCreateOptions SchemaOptions);
}
}

View file

@ -1,138 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Represents options that can be provided when creating an <see cref="AIFunction"/> from a method.
/// </summary>
internal sealed class TemporaryAIFunctionFactoryOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="TemporaryAIFunctionFactoryOptions"/> class.
/// </summary>
public TemporaryAIFunctionFactoryOptions()
{
}
/// <summary>Gets or sets the <see cref="JsonSerializerOptions"/> used to marshal .NET values being passed to the underlying delegate.</summary>
/// <remarks>
/// If no value has been specified, the <see cref="AIJsonUtilities.DefaultOptions"/> instance will be used.
/// </remarks>
public JsonSerializerOptions? SerializerOptions { get; set; }
/// <summary>
/// Gets or sets the <see cref="AIJsonSchemaCreateOptions"/> governing the generation of JSON schemas for the function.
/// </summary>
/// <remarks>
/// If no value has been specified, the <see cref="AIJsonSchemaCreateOptions.Default"/> instance will be used.
/// </remarks>
public AIJsonSchemaCreateOptions? JsonSchemaCreateOptions { get; set; }
/// <summary>Gets or sets the name to use for the function.</summary>
/// <value>
/// The name to use for the function. The default value is a name derived from the method represented by the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </value>
public string? Name { get; set; }
/// <summary>Gets or sets the description to use for the function.</summary>
/// <value>
/// The description for the function. The default value is a description derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>, if possible
/// (for example, via a <see cref="DescriptionAttribute"/> on the method).
/// </value>
public string? Description { get; set; }
/// <summary>
/// Gets or sets additional values to store on the resulting <see cref="AITool.AdditionalProperties" /> property.
/// </summary>
/// <remarks>
/// This property can be used to provide arbitrary information about the function.
/// </remarks>
public IReadOnlyDictionary<string, object?>? AdditionalProperties { get; set; }
/// <summary>Gets or sets a delegate used to determine how a particular parameter to the function should be bound.</summary>
/// <remarks>
/// <para>
/// If <see langword="null"/>, the default parameter binding logic will be used. If non-<see langword="null"/> value,
/// this delegate will be invoked once for each parameter in the function as part of creating the <see cref="AIFunction"/> instance.
/// It is not invoked for parameters of type <see cref="CancellationToken"/>, which are invariably bound to the token
/// provided to the <see cref="AIFunction.InvokeAsync"/> invocation.
/// </para>
/// <para>
/// Returning a default <see cref="ParameterBindingOptions"/> results in the same behavior as if
/// <see cref="ConfigureParameterBinding"/> is <see langword="null"/>.
/// </para>
/// </remarks>
public Func<ParameterInfo, ParameterBindingOptions>? ConfigureParameterBinding { get; set; }
/// <summary>Gets or sets a delegate used to determine the <see cref="object"/> returned by <see cref="AIFunction.InvokeAsync"/>.</summary>
/// <remarks>
/// <para>
/// By default, the return value of invoking the method wrapped into an <see cref="AIFunction"/> by <see cref="TemporaryAIFunctionFactory"/>
/// is then JSON serialized, with the resulting <see cref="JsonElement"/> returned from the <see cref="AIFunction.InvokeAsync"/> method.
/// This default behavior is ideal for the common case where the result will be passed back to an AI service. However, if the caller
/// requires more control over the result's marshaling, the <see cref="MarshalResult"/> property may be set to a delegate that is
/// then provided with complete control over the result's marshaling. The delegate is invoked with the value returned by the method,
/// and its return value is then returned from the <see cref="AIFunction.InvokeAsync"/> method.
/// </para>
/// <para>
/// When set, the delegate is invoked even for <see langword="void"/>-returning methods, in which case it is invoked with
/// a <see langword="null"/> argument. By default, <see langword="null"/> is returned from the <see cref="AIFunction.InvokeAsync"/>
/// method for <see cref="AIFunction"/> instances produced by <see cref="TemporaryAIFunctionFactory"/> to wrap
/// <see langword="void"/>-returning methods).
/// </para>
/// <para>
/// Methods strongly-typed to return types of <see cref="Task"/>, <see cref="Task{TResult}"/>, <see cref="ValueTask"/>,
/// and <see cref="ValueTask{TResult}"/> are special-cased. For methods typed to return <see cref="Task"/> or <see cref="ValueTask"/>,
/// <see cref="MarshalResult"/> will be invoked with the <see langword="null"/> value after the returned task has successfully completed.
/// For methods typed to return <see cref="Task{TResult}"/> or <see cref="ValueTask{TResult}"/>, the delegate will be invoked with the
/// task's result value after the task has successfully completed.These behaviors keep synchronous and asynchronous methods consistent.
/// </para>
/// <para>
/// In addition to the returned value, which is provided to the delegate as the first argument, the delegate is also provided with
/// a <see cref="Type"/> represented the declared return type of the method. This can be used to determine how to marshal the result.
/// This may be different than the actual type of the object (<see cref="object.GetType"/>) if the method returns a derived type
/// or <see langword="null"/>. If the method is typed to return <see cref="Task"/>, <see cref="ValueTask"/>, or <see langword="void"/>,
/// the <see cref="Type"/> argument will be <see langword="null"/>.
/// </para>
/// </remarks>
public Func<object?, Type?, CancellationToken, Task<object?>>? MarshalResult { get; set; }
/// <summary>Provides configuration options produced by the <see cref="ConfigureParameterBinding"/> delegate.</summary>
public readonly record struct ParameterBindingOptions
{
/// <summary>Gets a delegate used to determine the value for a bound parameter.</summary>
/// <remarks>
/// <para>
/// The default value is <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="null"/>, the default binding semantics are used for the parameter.
/// If non- <see langword="null"/>, each time the <see cref="AIFunction"/> is invoked, this delegate will be invoked
/// to select the argument value to use for the parameter. The return value of the delegate will be used for the parameter's
/// value.
/// </para>
/// </remarks>
public Func<ParameterInfo, IReadOnlyDictionary<string, object?>, object?>? BindParameter { get; init; }
/// <summary>Gets a value indicating whether the parameter should be excluded from the generated schema.</summary>
/// <remarks>
/// <para>
/// The default value is <see langword="false"/>.
/// </para>
/// <para>
/// Typically, this property is set to <see langword="true"/> if and only if <see cref="BindParameter"/> is also set to
/// non-<see langword="null"/>. While it's possible to exclude the schema when <see cref="BindParameter"/> is <see langword="null"/>,
/// doing so means that default marshaling will be used but the AI service won't be aware of the parameter or able to generate
/// an argument for it. This is likely to result in invocation errors, as the parameter information is unlikely to be available.
/// It, however, is permissible for cases where invocation of the <see cref="AIFunction"/> is tightly controlled, and the caller
/// is expected to augment the argument dictionary with the parameter value.
/// </para>
/// </remarks>
public bool ExcludeFromSchema { get; init; }
}
}

View file

@ -213,7 +213,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(12, tools.Count);
var echo = tools.Single(t => t.Name == "Method4");
var result = await echo.InvokeAsync(new Dictionary<string, object?>() { ["i"] = 42 }, TestContext.Current.CancellationToken);
var result = await echo.InvokeAsync(new() { ["i"] = 42 }, TestContext.Current.CancellationToken);
Assert.Contains("Method4 Result 42", result?.ToString());
var valuesSetViaAttr = tools.Single(t => t.Name == "ValuesSetViaAttr");
@ -240,7 +240,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
{
if (tool.Name == "Method4")
{
var result = await tool.InvokeAsync(new Dictionary<string, object?>() { ["i"] = 42 }, TestContext.Current.CancellationToken);
var result = await tool.InvokeAsync(new() { ["i"] = 42 }, TestContext.Current.CancellationToken);
Assert.Contains("Method4 Result 42", result?.ToString());
return;
}
@ -277,7 +277,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
IMcpClient client = await CreateMcpClientForServer();
var tool = (await client.ListToolsAsync(emptyOptions, TestContext.Current.CancellationToken)).First();
await Assert.ThrowsAsync<NotSupportedException>(() => tool.InvokeAsync(new Dictionary<string, object?> { ["i"] = 42 }, TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<NotSupportedException>(async () => await tool.InvokeAsync(new() { ["i"] = 42 }, TestContext.Current.CancellationToken));
}
[Fact]

View file

@ -105,7 +105,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
await using var client = await _fixture.CreateClientAsync(clientId);
var aiFunctions = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var echo = aiFunctions.Single(t => t.Name == "echo");
var result = await echo.InvokeAsync([new KeyValuePair<string, object?>("message", "Hello MCP!")], TestContext.Current.CancellationToken);
var result = await echo.InvokeAsync(new() { ["message"] = "Hello MCP!" }, TestContext.Current.CancellationToken);
// assert
Assert.NotNull(result);
@ -495,7 +495,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
var mappedTools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Create the chat client.
using IChatClient chatClient = new OpenAIClient(s_openAIKey).AsChatClient("gpt-4o-mini")
using IChatClient chatClient = new OpenAIClient(s_openAIKey).GetChatClient("gpt-4o-mini").AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
@ -518,8 +518,8 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
[Fact(Skip = "Requires OpenAI API Key", SkipWhen = nameof(NoOpenAIKeySet))]
public async Task SamplingViaChatClient_RequestResponseProperlyPropagated()
{
var samplingHandler = new OpenAIClient(s_openAIKey)
.AsChatClient("gpt-4o-mini")
var samplingHandler = new OpenAIClient(s_openAIKey).GetChatClient("gpt-4o-mini")
.AsIChatClient()
.CreateSamplingHandler();
await using var client = await McpClientFactory.CreateAsync(new StdioClientTransport(_fixture.EverythingServerTransportOptions), new()
{

View file

@ -27,7 +27,7 @@ public class DiagnosticTests
Assert.NotEmpty(tools);
var tool = tools.First(t => t.Name == "DoubleValue");
await tool.InvokeAsync(new Dictionary<string, object?>() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
await tool.InvokeAsync(new() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
});
}

View file

@ -23,7 +23,6 @@ public class McpServerToolTests
Assert.NotNull(McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!, new DisposableToolType()));
Assert.NotNull(McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.StaticMethod))!));
Assert.Throws<ArgumentNullException>("target", () => McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!, target: null!));
Assert.Throws<ArgumentException>("target", () => McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.StaticMethod))!, new DisposableToolType()));
}
[Fact]