Improve tool handling (#28)
* Improve tool handling * Rework stuff with new hosting options * Update readme * Fix build * Try run client tests * Fix build * Fix issues on linux * Try to run testserver on linux * Fix test issue * Fix code smells
This commit is contained in:
parent
7d69499ad5
commit
ec9bdfcf5d
3 changed files with 1036 additions and 235 deletions
|
|
@ -0,0 +1,111 @@
|
|||
using McpDotNet.Configuration;
|
||||
using McpDotNet.Protocol.Types;
|
||||
using McpDotNet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace McpDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Extension to configure the MCP server with handlers
|
||||
/// </summary>
|
||||
public static partial class McpServerBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the handler for list tools requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithListToolsHandler(this IMcpServerBuilder builder, Func<RequestContext<ListToolsRequestParams>, CancellationToken, Task<ListToolsResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.ListToolsHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for call tool requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithCallToolHandler(this IMcpServerBuilder builder, Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.CallToolHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for list prompts requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithListPromptsHandler(this IMcpServerBuilder builder, Func<RequestContext<ListPromptsRequestParams>, CancellationToken, Task<ListPromptsResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.ListPromptsHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for get prompt requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithGetPromptHandler(this IMcpServerBuilder builder, Func<RequestContext<GetPromptRequestParams>, CancellationToken, Task<GetPromptResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.GetPromptHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for list resources requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithListResourcesHandler(this IMcpServerBuilder builder, Func<RequestContext<ListResourcesRequestParams>, CancellationToken, Task<ListResourcesResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.ListResourcesHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for read resources requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithReadResourceHandler(this IMcpServerBuilder builder, Func<RequestContext<ReadResourceRequestParams>, CancellationToken, Task<ReadResourceResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.ReadResourceHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for get resources requests.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithGetCompletionHandler(this IMcpServerBuilder builder, Func<RequestContext<CompleteRequestParams>, CancellationToken, Task<CompleteResult>> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.GetCompletionHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the handler for subscribe to resources messages.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithSubscribeToResourcesHandler(this IMcpServerBuilder builder, Func<RequestContext<string>, CancellationToken, Task> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.SubscribeToResourcesHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or sets the handler for subscribe to resources messages.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="handler">The handler.</param>
|
||||
public static IMcpServerBuilder WithUnsubscribeFromResourcesHandler(this IMcpServerBuilder builder, Func<RequestContext<string>, CancellationToken, Task> handler)
|
||||
{
|
||||
builder.Services.Configure<McpServerDelegates>(s => s.UnsubscribeFromResourcesHandler = handler);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
249
src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs
Normal file
249
src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using McpDotNet.Configuration;
|
||||
using McpDotNet.Protocol.Types;
|
||||
using McpDotNet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace McpDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Extension to configure the MCP server with tools
|
||||
/// </summary>
|
||||
public static partial class McpServerBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a tool to the server.
|
||||
/// </summary>
|
||||
/// <typeparam name="TTool">The tool type.</typeparam>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
public static IMcpServerBuilder WithTool<TTool>(this IMcpServerBuilder builder)
|
||||
{
|
||||
return WithTools(builder, typeof(TTool));
|
||||
}
|
||||
/// <summary>
|
||||
/// Adds all tools marked with <see cref="McpToolTypeAttribute"/> from the current assembly to the server.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder)
|
||||
{
|
||||
return WithToolsFromAssembly(builder, Assembly.GetCallingAssembly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds tools to the server.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="toolTypes">Types with marked methods to add as tools to the server.</param>
|
||||
public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params Type[] toolTypes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(toolTypes);
|
||||
if (toolTypes.Length == 0)
|
||||
throw new ArgumentException("At least one tool type must be provided.", nameof(toolTypes));
|
||||
|
||||
var tools = new List<Tool>();
|
||||
Dictionary<string, Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>>> callbacks = [];
|
||||
|
||||
foreach (var type in toolTypes)
|
||||
{
|
||||
var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var attribute = method.GetCustomAttribute<McpToolAttribute>();
|
||||
if (attribute != null)
|
||||
{
|
||||
var tool = CreateTool(method, attribute);
|
||||
tools.Add(tool);
|
||||
|
||||
callbacks.Add(tool.Name, async (request, cancellationToken) => await CallTool(request, method, cancellationToken));
|
||||
|
||||
// register type because method is not static and so we need an instance
|
||||
if (!method.IsStatic)
|
||||
builder.Services.AddScoped(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.WithListToolsHandler((_, _) => Task.FromResult(new ListToolsResult() { Tools = tools }));
|
||||
|
||||
builder.WithCallToolHandler(async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params != null && callbacks.TryGetValue(request.Params.Name, out var callback))
|
||||
return await callback(request, cancellationToken);
|
||||
|
||||
throw new McpServerException($"Unknown tool: {request.Params?.Name}");
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds types marked with the <see cref="McpToolTypeAttribute"/> attribute from the given assembly as tools to the server.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
/// <param name="assembly">The assembly to load the types from. Null to get the current assembly</param>
|
||||
public static IMcpServerBuilder WithToolsFromAssembly(this IMcpServerBuilder builder, Assembly? assembly = null)
|
||||
{
|
||||
assembly ??= Assembly.GetCallingAssembly();
|
||||
|
||||
List<Type> toolTypes = [];
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
bool hasToolTypeAttribute = type.GetCustomAttribute<McpToolTypeAttribute>() != null;
|
||||
if (!hasToolTypeAttribute)
|
||||
continue;
|
||||
|
||||
var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var attribute = method.GetCustomAttribute<McpToolAttribute>();
|
||||
if (attribute != null)
|
||||
{
|
||||
toolTypes.Add(type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolTypes.Count == 0)
|
||||
throw new ArgumentException("No types with marked methods found in the assembly.", nameof(assembly));
|
||||
|
||||
return WithTools(builder, toolTypes.ToArray());
|
||||
}
|
||||
|
||||
private static Tool CreateTool(MethodInfo method, McpToolAttribute attribute)
|
||||
{
|
||||
Dictionary<string, JsonSchemaProperty> properties = [];
|
||||
List<string>? requiredProperties = null;
|
||||
|
||||
foreach (var parameter in method.GetParameters())
|
||||
{
|
||||
if (parameter.ParameterType == typeof(CancellationToken))
|
||||
continue;
|
||||
|
||||
var parameterAttribute = parameter.GetCustomAttribute<McpParameterAttribute>();
|
||||
|
||||
properties.Add(parameter.Name ?? "NoName", new JsonSchemaProperty()
|
||||
{
|
||||
Type = GetParameterType(parameter.ParameterType),
|
||||
Description = parameterAttribute?.Description
|
||||
});
|
||||
|
||||
if (parameterAttribute?.Required == true)
|
||||
{
|
||||
requiredProperties ??= [];
|
||||
requiredProperties.Add(parameter.Name ?? "NoName");
|
||||
}
|
||||
}
|
||||
|
||||
return new Tool()
|
||||
{
|
||||
Name = attribute.Name ?? method.Name,
|
||||
Description = attribute.Description,
|
||||
InputSchema = new JsonSchema()
|
||||
{
|
||||
Type = "object",
|
||||
Properties = properties,
|
||||
Required = requiredProperties
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetParameterType(Type parameterType)
|
||||
{
|
||||
return parameterType switch
|
||||
{
|
||||
Type t when t == typeof(string) => "string",
|
||||
Type t when t == typeof(int) || t == typeof(double) || t == typeof(float) => "number",
|
||||
Type t when t == typeof(bool) => "boolean",
|
||||
Type t when t.IsArray => "array",
|
||||
Type t when t == typeof(DateTime) => "string",
|
||||
_ => "object"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<CallToolResponse> CallTool(RequestContext<CallToolRequestParams> request, MethodInfo method, CancellationToken cancellationToken)
|
||||
{
|
||||
var methodParameters = method.GetParameters();
|
||||
List<object?> parameters = ResolveParameters(request, methodParameters, cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return new CallToolResponse { Content = [new Content { Text = "Operation was cancelled" }] };
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = request.Server.ServiceProvider?.CreateScope();
|
||||
var objectInstance = CreateObjectInstance(method, scope?.ServiceProvider);
|
||||
var result = method.Invoke(objectInstance, parameters.ToArray());
|
||||
|
||||
|
||||
if (result is Task task)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
var resultProperty = task.GetType().GetProperty("Result");
|
||||
result = resultProperty?.GetValue(task);
|
||||
}
|
||||
|
||||
if (result is string resultString)
|
||||
return new CallToolResponse { Content = [new Content() { Text = resultString, Type = "text" }] };
|
||||
|
||||
if (result is string[] resultStringArray)
|
||||
return new CallToolResponse { Content = resultStringArray.Select(s => new Content() { Text = s, Type = "text" }).ToList() };
|
||||
|
||||
if (result is null)
|
||||
return new CallToolResponse { Content = [new Content() { Text = "null" }] };
|
||||
|
||||
if (result is JsonElement jsonElement)
|
||||
return new CallToolResponse { Content = [new Content() { Text = jsonElement.GetRawText(), Type = "text" }] };
|
||||
|
||||
return new CallToolResponse { Content = [new Content() { Text = result.ToString(), Type = "text" }] };
|
||||
}
|
||||
catch (TargetInvocationException e)
|
||||
{
|
||||
throw new McpServerException(e.Message, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<object?> ResolveParameters(RequestContext<CallToolRequestParams> request, ParameterInfo[] methodParameters, CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = new List<object?>(methodParameters.Length);
|
||||
|
||||
foreach (var parameter in methodParameters)
|
||||
{
|
||||
if (parameter.ParameterType == typeof(CancellationToken))
|
||||
{
|
||||
parameters.Add(cancellationToken);
|
||||
}
|
||||
else if (request.Params?.Arguments != null && request.Params.Arguments.TryGetValue(parameter.Name ?? "NoName", out var value))
|
||||
{
|
||||
if (value is JsonElement element)
|
||||
value = JsonSerializer.Deserialize(element.GetRawText(), parameter.ParameterType);
|
||||
|
||||
parameters.Add(Convert.ChangeType(value, parameter.ParameterType));
|
||||
}
|
||||
else
|
||||
{
|
||||
var parameterAttribute = parameter.GetCustomAttribute<McpParameterAttribute>();
|
||||
|
||||
if (parameterAttribute?.Required == true)
|
||||
throw new McpServerException($"Missing required argument '{parameter.Name}'.");
|
||||
|
||||
parameters.Add(parameter.HasDefaultValue ? parameter.DefaultValue : null);
|
||||
}
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static object? CreateObjectInstance(MethodInfo method, IServiceProvider? serviceProvider)
|
||||
{
|
||||
if (method.IsStatic)
|
||||
return null;
|
||||
|
||||
if (serviceProvider != null)
|
||||
return ActivatorUtilities.CreateInstance(serviceProvider, method.DeclaringType!);
|
||||
|
||||
return Activator.CreateInstance(method.DeclaringType!);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using McpDotNet.Configuration;
|
||||
using McpDotNet.Protocol.Transport;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace McpDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Extension to configure the MCP server with transports
|
||||
/// </summary>
|
||||
public static partial class McpServerBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a server transport that uses stdin/stdout for communication.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder instance.</param>
|
||||
public static IMcpServerBuilder WithStdioServerTransport(this IMcpServerBuilder builder)
|
||||
{
|
||||
builder.Services.AddSingleton<IServerTransport, StdioServerTransport>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue