Overhaul tool handling (#89)
* Overhaul tool handling
- Renames [McpTool{Type}] to [McpServerTool{Type}], in order to distinguish representations of tools on the server from tools on the client.
- Enables [McpServerTool] methods to have arguments injected from DI, as well as a specific set of types from the implementation directly, like IMcpServer.
- Renames WithTools to WithToolsFromAssembly.
- All of the WithToolsXx methods now publish each individual McpServerTool into DI; other code can do so as well. The options setup code gathers all of the tools from DI and combines them into a collection which is then stored in McpServerOptions and used to construct the server.
- The server tools specified via DI as well as manually-provided handlers, using CallToolHandler as a fallback in case the requested tool doesn't exist in the tools collection, and ListToolsHandler augments the information for whatever tools exist.
- The tools are stored in McpServerOptions in a new McpServerToolCollection type, which is a thread-safe container that exposes add/removal notification. Adding/removing tools triggers a change notification that in turn sends a notification to the client about a tools update.
- The ServerOptions are exposed from the server instance.
- Removed cursor-based APIs from McpClientExtensions.
- Changed McpClientExtensions APIs to return `Task<IList<...>` rather than `IAsyncEnumerable<...>`.
I'm sure this will need to evolve further before we're "done", but it's a significant improvement from where we are now. One area I'm not super happy with is the notifying collection; that might work ok for a stdio server, where you can grab the created collection from the server's options and mutate it, but I don't know how well that's going to work for sse servers.
* Make McpServerTool{Collection} extensible
* Fix README.md example
* Add notifications test
* Address feedback
This commit is contained in:
parent
aafa9d929d
commit
caca2ae61d
14 changed files with 2703 additions and 686 deletions
231
src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
Normal file
231
src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
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>
|
||||
private const string RequestContextKey = "__temporary_RequestContext";
|
||||
|
||||
/// <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,
|
||||
string? name,
|
||||
string? description,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(method);
|
||||
|
||||
return Create(method.Method, method.Target, name, description, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
|
||||
/// </summary>
|
||||
public static new AIFunctionMcpServerTool Create(
|
||||
MethodInfo method,
|
||||
object? target,
|
||||
string? name,
|
||||
string? description,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
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.
|
||||
|
||||
return Create(TemporaryAIFunctionFactory.Create(method, target, new TemporaryAIFunctionFactoryOptions()
|
||||
{
|
||||
Name = name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name,
|
||||
Description = description,
|
||||
MarshalResult = static (result, _, cancellationToken) => Task.FromResult(result),
|
||||
ConfigureParameterBinding = pi =>
|
||||
{
|
||||
if (pi.ParameterType == typeof(RequestContext<CallToolRequestParams>))
|
||||
{
|
||||
return new()
|
||||
{
|
||||
ExcludeFromSchema = true,
|
||||
BindParameter = (pi, args) => GetRequestContext(args),
|
||||
};
|
||||
}
|
||||
|
||||
if (pi.ParameterType == typeof(IMcpServer))
|
||||
{
|
||||
return new()
|
||||
{
|
||||
ExcludeFromSchema = true,
|
||||
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
|
||||
};
|
||||
}
|
||||
|
||||
// We assume that if the services used to create the tool support a particular type,
|
||||
// so too do the services associated with the server. This is the same basic assumption
|
||||
// made in ASP.NET.
|
||||
if (services is not null &&
|
||||
services.GetService<IServiceProviderIsService>() is { } ispis &&
|
||||
ispis.IsService(pi.ParameterType))
|
||||
{
|
||||
return new()
|
||||
{
|
||||
ExcludeFromSchema = true,
|
||||
BindParameter = (pi, args) =>
|
||||
GetRequestContext(args)?.Server?.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) =>
|
||||
(GetRequestContext(args)?.Server?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
|
||||
(pi.HasDefaultValue ? null :
|
||||
throw new ArgumentException("No service of the requested type was found.")),
|
||||
};
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
static RequestContext<CallToolRequestParams>? GetRequestContext(IReadOnlyDictionary<string, object?> args)
|
||||
{
|
||||
if (args.TryGetValue(RequestContextKey, out var orc) &&
|
||||
orc is RequestContext<CallToolRequestParams> requestContext)
|
||||
{
|
||||
return requestContext;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
|
||||
public static new AIFunctionMcpServerTool Create(AIFunction function)
|
||||
{
|
||||
Throw.IfNull(function);
|
||||
|
||||
return new AIFunctionMcpServerTool(function);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
AIFunction = function;
|
||||
ProtocolTool = new()
|
||||
{
|
||||
Name = function.Name,
|
||||
Description = function.Description,
|
||||
InputSchema = function.JsonSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => AIFunction.ToString();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Tool ProtocolTool { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<CallToolResponse> InvokeAsync(
|
||||
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 IDictionary<string, object?> existingArgs ?
|
||||
new(existingArgs) :
|
||||
[];
|
||||
arguments[RequestContextKey] = request;
|
||||
|
||||
object? result;
|
||||
try
|
||||
{
|
||||
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
return new CallToolResponse()
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new() { Text = e.Message, Type = "text" }],
|
||||
};
|
||||
}
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case null:
|
||||
return new()
|
||||
{
|
||||
Content = []
|
||||
};
|
||||
|
||||
case string text:
|
||||
return new()
|
||||
{
|
||||
Content = [new() { Text = text, Type = "text" }]
|
||||
};
|
||||
|
||||
case TextContent textContent:
|
||||
return new()
|
||||
{
|
||||
Content = [new() { Text = textContent.Text, Type = "text" }]
|
||||
};
|
||||
|
||||
case DataContent dataContent:
|
||||
return new()
|
||||
{
|
||||
Content = [new()
|
||||
{
|
||||
Data = dataContent.GetBase64Data(),
|
||||
MimeType = dataContent.MediaType,
|
||||
Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource",
|
||||
}]
|
||||
};
|
||||
|
||||
case string[] texts:
|
||||
return new()
|
||||
{
|
||||
Content = texts
|
||||
.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
// TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69:
|
||||
// Add specialization for annotations.
|
||||
|
||||
default:
|
||||
return new()
|
||||
{
|
||||
Content = [new()
|
||||
{
|
||||
Text = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
|
||||
Type = "text"
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/ModelContextProtocol/Server/DelegatingMcpServerTool.cs
Normal file
34
src/ModelContextProtocol/Server/DelegatingMcpServerTool.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Utils;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>Provides an <see cref="McpServerTool"/> that delegates all operations to an inner <see cref="McpServerTool"/>.</summary>
|
||||
/// <remarks>
|
||||
/// This is recommended as a base type when building tools that can be chained around an underlying <see cref="McpServerTool"/>.
|
||||
/// The default implementation simply passes each call to the inner tool instance.
|
||||
/// </remarks>
|
||||
public abstract class DelegatingMcpServerTool : McpServerTool
|
||||
{
|
||||
private readonly McpServerTool _innerTool;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DelegatingMcpServerTool"/> class around the specified <paramref name="innerTool"/>.</summary>
|
||||
/// <param name="innerTool">The inner tool wrapped by this delegating tool.</param>
|
||||
protected DelegatingMcpServerTool(McpServerTool innerTool)
|
||||
{
|
||||
Throw.IfNull(innerTool);
|
||||
_innerTool = innerTool;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Tool ProtocolTool => _innerTool.ProtocolTool;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<CallToolResponse> InvokeAsync(
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_innerTool.InvokeAsync(request, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => _innerTool.ToString();
|
||||
}
|
||||
|
|
@ -23,10 +23,13 @@ public interface IMcpServer : IAsyncDisposable
|
|||
/// </summary>
|
||||
Implementation? ClientInfo { get; }
|
||||
|
||||
/// <summary>Gets the options used to construct this server.</summary>
|
||||
McpServerOptions ServerOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service provider for the server.
|
||||
/// </summary>
|
||||
IServiceProvider? ServiceProvider { get; }
|
||||
IServiceProvider? Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds a handler for client notifications of a specific method.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
using ModelContextProtocol.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Logging;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Shared;
|
||||
using ModelContextProtocol.Utils;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
|
@ -15,9 +14,9 @@ namespace ModelContextProtocol.Server;
|
|||
internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
||||
{
|
||||
private readonly IServerTransport? _serverTransport;
|
||||
private readonly McpServerOptions _options;
|
||||
private volatile bool _isInitializing;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _serverDescription;
|
||||
private volatile bool _isInitializing;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="McpServer"/>.
|
||||
|
|
@ -34,10 +33,10 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
Throw.IfNull(options);
|
||||
|
||||
_serverTransport = transport as IServerTransport;
|
||||
_options = options;
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<McpServer>() ?? NullLogger.Instance;
|
||||
ServerInstructions = options.ServerInstructions;
|
||||
ServiceProvider = serviceProvider;
|
||||
Services = serviceProvider;
|
||||
_serverDescription = $"{options.ServerInfo.Name} {options.ServerInfo.Version}";
|
||||
|
||||
AddNotificationHandler("notifications/initialized", _ =>
|
||||
{
|
||||
|
|
@ -45,13 +44,16 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
SetToolsHandler(ref options);
|
||||
|
||||
SetInitializeHandler(options);
|
||||
SetCompletionHandler(options);
|
||||
SetPingHandler();
|
||||
SetToolsHandler(options);
|
||||
SetPromptsHandler(options);
|
||||
SetResourcesHandler(options);
|
||||
SetSetLoggingLevelHandler(options);
|
||||
|
||||
ServerOptions = options;
|
||||
}
|
||||
|
||||
public ClientCapabilities? ClientCapabilities { get; set; }
|
||||
|
|
@ -63,11 +65,14 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
public string? ServerInstructions { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IServiceProvider? ServiceProvider { get; }
|
||||
public McpServerOptions ServerOptions { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IServiceProvider? Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string EndpointName =>
|
||||
$"Server ({_options.ServerInfo.Name} {_options.ServerInfo.Version}), Client ({ClientInfo?.Name} {ClientInfo?.Version})";
|
||||
$"Server ({_serverDescription}), Client ({ClientInfo?.Name} {ClientInfo?.Version})";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
|
|
@ -125,7 +130,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
{
|
||||
ProtocolVersion = options.ProtocolVersion,
|
||||
Instructions = ServerInstructions,
|
||||
ServerInfo = _options.ServerInfo,
|
||||
ServerInfo = options.ServerInfo,
|
||||
Capabilities = options.Capabilities ?? new ServerCapabilities(),
|
||||
});
|
||||
});
|
||||
|
|
@ -195,17 +200,110 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct));
|
||||
}
|
||||
|
||||
private void SetToolsHandler(McpServerOptions options)
|
||||
private void SetToolsHandler(ref McpServerOptions options)
|
||||
{
|
||||
if (options.Capabilities?.Tools is not { } toolsCapability)
|
||||
ToolsCapability? toolsCapability = options.Capabilities?.Tools;
|
||||
var listToolsHandler = toolsCapability?.ListToolsHandler;
|
||||
var callToolHandler = toolsCapability?.CallToolHandler;
|
||||
var tools = toolsCapability?.ToolCollection;
|
||||
|
||||
if (listToolsHandler is null != callToolHandler is null)
|
||||
{
|
||||
return;
|
||||
throw new McpServerException("ListTools and CallTool handlers should be specified together.");
|
||||
}
|
||||
|
||||
if (toolsCapability.ListToolsHandler is not { } listToolsHandler ||
|
||||
toolsCapability.CallToolHandler is not { } callToolHandler)
|
||||
// Handle tools provided via DI.
|
||||
if (tools is { IsEmpty: false })
|
||||
{
|
||||
throw new McpServerException("ListTools and/or CallTool handlers were specified but the Tools capability was not enabled.");
|
||||
var originalListToolsHandler = listToolsHandler;
|
||||
var originalCallToolHandler = callToolHandler;
|
||||
|
||||
// Synthesize the handlers, making sure a ToolsCapability is specified.
|
||||
listToolsHandler = async (request, cancellationToken) =>
|
||||
{
|
||||
ListToolsResult result = new();
|
||||
foreach (McpServerTool tool in tools)
|
||||
{
|
||||
result.Tools.Add(tool.ProtocolTool);
|
||||
}
|
||||
|
||||
if (originalListToolsHandler is not null)
|
||||
{
|
||||
string? nextCursor = null;
|
||||
do
|
||||
{
|
||||
ListToolsResult extraResults = await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
result.Tools.AddRange(extraResults.Tools);
|
||||
|
||||
nextCursor = extraResults.NextCursor;
|
||||
if (nextCursor is not null)
|
||||
{
|
||||
request = request with { Params = new() { Cursor = nextCursor } };
|
||||
}
|
||||
}
|
||||
while (nextCursor is not null);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
callToolHandler = (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params is null ||
|
||||
!tools.TryGetTool(request.Params.Name, out var tool))
|
||||
{
|
||||
if (originalCallToolHandler is not null)
|
||||
{
|
||||
return originalCallToolHandler(request, cancellationToken);
|
||||
}
|
||||
|
||||
throw new McpServerException($"Unknown tool '{request.Params?.Name}'");
|
||||
}
|
||||
|
||||
return tool.InvokeAsync(request, cancellationToken);
|
||||
};
|
||||
|
||||
toolsCapability = toolsCapability is null ?
|
||||
new()
|
||||
{
|
||||
CallToolHandler = callToolHandler,
|
||||
ListToolsHandler = listToolsHandler,
|
||||
ToolCollection = tools,
|
||||
ListChanged = true,
|
||||
} :
|
||||
toolsCapability with
|
||||
{
|
||||
CallToolHandler = callToolHandler,
|
||||
ListToolsHandler = listToolsHandler,
|
||||
ToolCollection = tools,
|
||||
ListChanged = true,
|
||||
};
|
||||
|
||||
options.Capabilities = options.Capabilities is null ?
|
||||
new() { Tools = toolsCapability } :
|
||||
options.Capabilities with { Tools = toolsCapability };
|
||||
|
||||
tools.Changed += delegate
|
||||
{
|
||||
_ = SendMessageAsync(new JsonRpcNotification()
|
||||
{
|
||||
Method = NotificationMethods.ToolListChangedNotification,
|
||||
});
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
if (toolsCapability is null)
|
||||
{
|
||||
// No tools, and no tools capability was declared, so nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the handlers are provided if the capability is enabled.
|
||||
if (listToolsHandler is null || callToolHandler is null)
|
||||
{
|
||||
throw new McpServerException("ListTools and/or CallTool handlers were not specified but the Tools capability was enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
|
||||
|
|
@ -226,4 +324,4 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
|
|||
|
||||
SetRequestHandler<SetLevelRequestParams, EmptyResult>("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,12 +96,7 @@ public static class McpServerExtensions
|
|||
{
|
||||
Type = "image",
|
||||
MimeType = dataContent.MediaType,
|
||||
Data = Convert.ToBase64String(dataContent.Data.
|
||||
#if NET
|
||||
Span),
|
||||
#else
|
||||
ToArray()),
|
||||
#endif
|
||||
Data = dataContent.GetBase64Data(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
|
@ -126,33 +121,7 @@ public static class McpServerExtensions
|
|||
ModelPreferences = modelPreferences,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage responseMessage = new()
|
||||
{
|
||||
Role = result.Role == "user" ? ChatRole.User : ChatRole.Assistant
|
||||
};
|
||||
|
||||
if (result.Content is { Type: "text" })
|
||||
{
|
||||
responseMessage.Contents.Add(new TextContent(result.Content.Text));
|
||||
}
|
||||
else if (result.Content is { Type: "image", MimeType: not null, Data: not null })
|
||||
{
|
||||
responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(result.Content.Data), result.Content.MimeType));
|
||||
}
|
||||
else if (result.Content is { Type: "resource" } && result.Content.Resource is { } resourceContents)
|
||||
{
|
||||
if (resourceContents.Text is not null)
|
||||
{
|
||||
responseMessage.Contents.Add(new TextContent(resourceContents.Text));
|
||||
}
|
||||
|
||||
if (resourceContents.Blob is not null && resourceContents.MimeType is not null)
|
||||
{
|
||||
responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(resourceContents.Blob), resourceContents.MimeType));
|
||||
}
|
||||
}
|
||||
|
||||
return new(responseMessage)
|
||||
return new(new ChatMessage(new(result.Role), [result.Content.ToAIContent()]))
|
||||
{
|
||||
ModelId = result.Model,
|
||||
FinishReason = result.StopReason switch
|
||||
|
|
|
|||
97
src/ModelContextProtocol/Server/McpServerTool.cs
Normal file
97
src/ModelContextProtocol/Server/McpServerTool.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>Represents an invocable tool used by Model Context Protocol clients and servers.</summary>
|
||||
public abstract class McpServerTool
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
|
||||
protected McpServerTool()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Gets the protocol <see cref="Tool"/> type for this instance.</summary>
|
||||
public abstract Tool ProtocolTool { get; }
|
||||
|
||||
/// <summary>Invokes the <see cref="McpServerTool"/>.</summary>
|
||||
/// <param name="request">The request information resulting in the invocation of this tool.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The call response from invoking the tool.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
|
||||
public abstract Task<CallToolResponse> InvokeAsync(
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="method">The method to be represented via the created <see cref="McpServerTool"/>.</param>
|
||||
/// <param name="name">
|
||||
/// The name to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but an <see cref="McpServerToolAttribute"/>
|
||||
/// is applied to <paramref name="method"/>, the name from the attribute will be used. If that's not present, the name based
|
||||
/// on <paramref name="method"/>'s name will be used.
|
||||
/// </param>
|
||||
/// <param name="description">
|
||||
/// The description to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but a <see cref="DescriptionAttribute"/>
|
||||
/// is applied to <paramref name="method"/>, the description from that attribute will be used.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
|
||||
/// used to determine which parameters should be satisifed from dependency injection, and so what services
|
||||
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
|
||||
/// </param>
|
||||
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
|
||||
public static McpServerTool Create(
|
||||
Delegate method,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IServiceProvider? services = null) =>
|
||||
AIFunctionMcpServerTool.Create(method, name, description, services);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="method">The method to be represented via the created <see cref="McpServerTool"/>.</param>
|
||||
/// <param name="target">The instance if <paramref name="method"/> is an instance method; otherwise, <see langword="null"/>.</param>
|
||||
/// <param name="name">
|
||||
/// The name to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but an <see cref="McpServerToolAttribute"/>
|
||||
/// is applied to <paramref name="method"/>, the name from the attribute will be used. If that's not present, the name based
|
||||
/// on <paramref name="method"/>'s name will be used.
|
||||
/// </param>
|
||||
/// <param name="description">
|
||||
/// The description to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but a <see cref="DescriptionAttribute"/>
|
||||
/// is applied to <paramref name="method"/>, the description from that attribute will be used.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
|
||||
/// used to determine which parameters should be satisifed from dependency injection, and so what services
|
||||
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
|
||||
/// </param>
|
||||
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="method"/> is an instance method but <paramref name="target"/> is <see langword="null"/>.</exception>
|
||||
public static McpServerTool Create(
|
||||
MethodInfo method,
|
||||
object? target = null,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IServiceProvider? services = null) =>
|
||||
AIFunctionMcpServerTool.Create(method, target, name, description, services);
|
||||
|
||||
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
|
||||
/// <param name="function">The function to wrap.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// Unlike the other overloads of Create, the <see cref="McpServerTool"/> created by <see cref="Create(AIFunction)"/>
|
||||
/// does not provide all of the special parameter handling for MCP-specific concepts, like <see cref="IMcpServer"/>.
|
||||
/// </remarks>
|
||||
public static McpServerTool Create(AIFunction function) =>
|
||||
AIFunctionMcpServerTool.Create(function);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => ProtocolTool.Name;
|
||||
}
|
||||
18
src/ModelContextProtocol/Server/McpServerToolAttribute.cs
Normal file
18
src/ModelContextProtocol/Server/McpServerToolAttribute.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Used to mark a public method as an MCP tool.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class McpServerToolAttribute : Attribute
|
||||
{
|
||||
/// <summary>Gets the name of the tool.</summary>
|
||||
/// <remarks>If <see langword="null"/>, the method name will be used.</remarks>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpServerToolTypeAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the tool. If <see langword="null"/>, the method name will be used.</param>
|
||||
public McpServerToolAttribute(string? name = null) => Name = name;
|
||||
}
|
||||
164
src/ModelContextProtocol/Server/McpServerToolCollection.cs
Normal file
164
src/ModelContextProtocol/Server/McpServerToolCollection.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using ModelContextProtocol.Utils;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>Provides a thread-safe collection of <see cref="McpServerTool"/> instances, indexed by their names.</summary>
|
||||
public class McpServerToolCollection : ICollection<McpServerTool>, IReadOnlyCollection<McpServerTool>
|
||||
{
|
||||
/// <summary>Concurrent dictionary of tools, indexed by their names.</summary>
|
||||
private readonly ConcurrentDictionary<string, McpServerTool> _tools = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpServerToolCollection"/> class.
|
||||
/// </summary>
|
||||
public McpServerToolCollection()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Occurs when the collection is changed.</summary>
|
||||
/// <remarks>
|
||||
/// By default, this is raised when a tool is added or removed. However, a derived implementation
|
||||
/// may raise this event for other reasons, such as when a tool is modified.
|
||||
/// </remarks>
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// <summary>Gets the number of tools in the collection.</summary>
|
||||
public int Count => _tools.Count;
|
||||
|
||||
/// <summary>Gets whether there are any tools in the collection.</summary>
|
||||
public bool IsEmpty => _tools.IsEmpty;
|
||||
|
||||
/// <summary>Raises <see cref="Changed"/> if there are registered handlers.</summary>
|
||||
protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
/// <summary>Gets the <see cref="McpServerTool"/> with the specified <paramref name="name"/> from the collection.</summary>
|
||||
/// <param name="name">The name of the tool to retrieve.</param>
|
||||
/// <returns>The <see cref="McpServerTool"/> with the specified name.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="KeyNotFoundException">A tool with the specified name does not exist in the collection.</exception>
|
||||
public McpServerTool this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
Throw.IfNull(name);
|
||||
return _tools[name];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears all tools from the collection.</summary>
|
||||
public virtual void Clear()
|
||||
{
|
||||
_tools.Clear();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>Adds the specified <see cref="McpServerTool"/> to the collection.</summary>
|
||||
/// <param name="tool">The tool to be added.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">A tool with the same name as <paramref name="tool"/> already exists in the collection.</exception>
|
||||
public void Add(McpServerTool tool)
|
||||
{
|
||||
if (!TryAdd(tool))
|
||||
{
|
||||
throw new ArgumentException($"A tool with the same name '{tool.ProtocolTool.Name}' already exists in the collection.", nameof(tool));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the specified <see cref="McpServerTool"/> to the collection.</summary>
|
||||
/// <param name="tool">The tool to be added.</param>
|
||||
/// <returns><see langword="true"/> if the tool was added; otherwise, <see langword="false"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
public virtual bool TryAdd(McpServerTool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
|
||||
bool added = _tools.TryAdd(tool.ProtocolTool.Name, tool);
|
||||
if (added)
|
||||
{
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
/// <summary>Removes the specified toolfrom the collection.</summary>
|
||||
/// <param name="tool">The tool to be removed from the collection.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the tool was found in the collection and removed; otherwise, <see langword="false"/> if it couldn't be found.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
public virtual bool Remove(McpServerTool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
|
||||
bool removed = ((ICollection<KeyValuePair<string, McpServerTool>>)_tools).Remove(new(tool.ProtocolTool.Name, tool));
|
||||
if (removed)
|
||||
{
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to get the tool with the specified name from the collection.</summary>
|
||||
/// <param name="name">The name of the tool to retrieve.</param>
|
||||
/// <param name="tool">The tool, if found; otherwise, <see langword="null"/>.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the tool was found in the collection and return; otherwise, <see langword="false"/> if it couldn't be found.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public virtual bool TryGetTool(string name, [NotNullWhen(true)] out McpServerTool? tool)
|
||||
{
|
||||
Throw.IfNull(name);
|
||||
return _tools.TryGetValue(name, out tool);
|
||||
}
|
||||
|
||||
/// <summary>Checks if a specific tool is present in the collection of tools.</summary>
|
||||
/// <param name="tool">The tool to search for in the collection.</param>
|
||||
/// <see langword="true"/> if the tool was found in the collection and return; otherwise, <see langword="false"/> if it couldn't be found.
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
public virtual bool Contains(McpServerTool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
return ((ICollection<KeyValuePair<string, McpServerTool>>)_tools).Contains(new(tool.ProtocolTool.Name, tool));
|
||||
}
|
||||
|
||||
/// <summary>Gets the names of all of the tools in the collection.</summary>
|
||||
public virtual ICollection<string> ToolNames => _tools.Keys;
|
||||
|
||||
/// <summary>Creates an array containing all of the tools in the collection.</summary>
|
||||
/// <returns>An array containing all of the tools in the collection.</returns>
|
||||
public virtual McpServerTool[] ToArray() => _tools.Values.ToArray();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual void CopyTo(McpServerTool[] array, int arrayIndex)
|
||||
{
|
||||
Throw.IfNull(array);
|
||||
|
||||
foreach (var entry in _tools)
|
||||
{
|
||||
array[arrayIndex++] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual IEnumerator<McpServerTool> GetEnumerator()
|
||||
{
|
||||
foreach (var entry in _tools)
|
||||
{
|
||||
yield return entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
IEnumerator<McpServerTool> IEnumerable<McpServerTool>.GetEnumerator() => GetEnumerator();
|
||||
|
||||
/// <inheritdoc/>
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
/// <inheritdoc/>
|
||||
bool ICollection<McpServerTool>.IsReadOnly => false;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Used to attribute a type containing methods that should be exposed as MCP tools.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class McpServerToolTypeAttribute : Attribute;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute to mark a method as an MCP tool.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class McpToolAttribute : Attribute
|
||||
{
|
||||
/// <summary>Gets the name of the tool.</summary>
|
||||
/// <remarks>If not provided, the method name will be used.</remarks>
|
||||
public string? Name { get; }
|
||||
/// <summary>
|
||||
/// Attribute to mark a method as an MCP tool.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the tool. If not provided, the method name will be used.</param>
|
||||
public McpToolAttribute(string? name = null)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute to mark a type as container for MCP tools.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class McpToolTypeAttribute : Attribute;
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
667
src/ModelContextProtocol/Server/TemporaryAIFunctionFactory.cs
Normal file
667
src/ModelContextProtocol/Server/TemporaryAIFunctionFactory.cs
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
// 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.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
#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 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 && target is null)
|
||||
{
|
||||
throw new ArgumentNullException("Target must not be null for an instance method.", nameof(target));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private ReflectionAIFunction(ReflectionAIFunctionDescriptor functionDescriptor, object? target, TemporaryAIFunctionFactoryOptions options)
|
||||
{
|
||||
FunctionDescriptor = functionDescriptor;
|
||||
Target = target;
|
||||
AdditionalProperties = options.AdditionalProperties ?? new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
public ReflectionAIFunctionDescriptor FunctionDescriptor { get; }
|
||||
public object? Target { 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 Task<object?> InvokeCoreAsync(
|
||||
IEnumerable<KeyValuePair<string, object?>> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paramMarshallers = FunctionDescriptor.ParameterMarshallers;
|
||||
object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : [];
|
||||
|
||||
Dictionary<string, object?> argumentsDictionary = arguments.ToDictionary();
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
args[i] = paramMarshallers[i](argumentsDictionary, cancellationToken);
|
||||
}
|
||||
|
||||
return FunctionDescriptor.ReturnParameterMarshaller(
|
||||
ReflectionInvoke(FunctionDescriptor.Method, Target, args), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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), AIJsonUtilities.DefaultOptions.GetTypeInfo<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));
|
||||
}
|
||||
|
||||
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
|
||||
Type parameterType = parameter.ParameterType;
|
||||
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// 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="AIFunctionFactory"/>
|
||||
/// 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="AIFunctionFactory"/> 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; }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue