diff --git a/GenHTTP.slnx b/GenHTTP.slnx index a1d3af5d7..b839e75e9 100644 --- a/GenHTTP.slnx +++ b/GenHTTP.slnx @@ -28,6 +28,7 @@ + diff --git a/Modules/Mcp/GenHTTP.Modules.Mcp.csproj b/Modules/Mcp/GenHTTP.Modules.Mcp.csproj new file mode 100644 index 000000000..968795b3a --- /dev/null +++ b/Modules/Mcp/GenHTTP.Modules.Mcp.csproj @@ -0,0 +1,24 @@ + + + + + Allows to provide tools for LLMs via the MCP. + HTTP Webserver C# Module MCP LLM Tool + + + + + + + + + + + + + + + + + + diff --git a/Modules/Mcp/ITool.cs b/Modules/Mcp/ITool.cs new file mode 100644 index 000000000..604d71934 --- /dev/null +++ b/Modules/Mcp/ITool.cs @@ -0,0 +1,29 @@ +namespace GenHTTP.Modules.Mcp; + +public interface ITool +{ + + string Name { get; } + + string Description { get; } + + internal Type InputType { get; } + + internal Type OutputType { get; } + + internal object CallUntyped(object input); + +} + +public interface ITool : ITool +{ + + TOutput Call(TInput input); + + Type ITool.InputType => typeof(TInput); + + Type ITool.OutputType => typeof(TOutput); + + object ITool.CallUntyped(object input) => Call(((TInput)input)!)!; + +} diff --git a/Modules/Mcp/Logic/ToolsHandler.cs b/Modules/Mcp/Logic/ToolsHandler.cs new file mode 100644 index 000000000..d78d226fa --- /dev/null +++ b/Modules/Mcp/Logic/ToolsHandler.cs @@ -0,0 +1,160 @@ +using System.Text.Json; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Mcp.Types; +using GenHTTP.Modules.Reflection; +using GenHTTP.Modules.Websockets; + +using NJsonSchema; + +namespace GenHTTP.Modules.Mcp.Logic; + +public class ToolsHandler : IHandler +{ + private static readonly JsonSerializerOptions SerializationOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + private readonly IHandler _Websocket; + + private readonly Dictionary _Tools; + + #region Initialization + + public ToolsHandler(List tools) + { + _Tools = tools.ToDictionary(t => t.Name, t => t); + + _Websocket = Websocket.Create() + .OnMessage(DispatchMessage) + .Build(); + } + + #endregion + + #region Functionality + + public ValueTask PrepareAsync() => ValueTask.CompletedTask; + + public ValueTask HandleAsync(IRequest request) => _Websocket.HandleAsync(request); + + private async Task DispatchMessage(IWebsocketConnection connection, string message) + { + var request = JsonSerializer.Deserialize(message, SerializationOptions); + + if (request == null) + { + throw new ArgumentException("Unable read JsonRpc frame from message"); + } + + switch (request.Method) + { + case "tools/list": + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = ListTools() + }; + + await connection.SendAsync(JsonSerializer.Serialize(response)); + break; + } + case "tools/call": + { + var arguments = request.Params?.Deserialize(); + + if (arguments?.Name is not null) + { + if (_Tools.TryGetValue(arguments.Name, out var tool)) + { + try + { + var result = await CallTool(tool, arguments.Arguments); + + var response = new JsonRpcResponse + { + Id = request.Id, + Result = result + }; + + await connection.SendAsync(JsonSerializer.Serialize(response)); + } + catch (Exception e) + { + await SendError(connection, 22, $"Error while executing tool '{arguments.Name}': {e.Message}"); + } + } + else + { + await SendError(connection, 20, $"Unrecognized tool '{arguments.Name}'"); + } + } + else + { + await SendError(connection, 21, "Unable to read tool from request"); + } + + break; + } + default: + { + await SendError(connection, 10, $"Unsupported method '{request.Method}'"); + break; + } + } + } + + private ToolList ListTools() + { + var descriptions = new List(); + + foreach (var tool in _Tools.Values) + { + descriptions.Add(new ToolInfo + { + Name = tool.Name, + Description = tool.Description, + InputSchema = JsonSchema.FromType(tool.InputType), + OutputSchema = JsonSchema.FromType(tool.OutputType) + }); + } + + return new ToolList() + { + Tools = descriptions + }; + } + + private static async ValueTask CallTool(ITool tool, JsonElement? input) + { + var argument = input?.Deserialize(tool.InputType); + + var result = tool.CallUntyped(argument!); + + return await MethodHandler.UnwrapAsync(result); + } + + #endregion + + #region Helpers + + private static async ValueTask SendError(IWebsocketConnection connection, int code, string message) + { + var response = new JsonRpcError() + { + Code = code, + Message = message + }; + + await connection.SendAsync(JsonSerializer.Serialize(response)); + } + + #endregion + +} diff --git a/Modules/Mcp/Logic/ToolsHandlerBuilder.cs b/Modules/Mcp/Logic/ToolsHandlerBuilder.cs new file mode 100644 index 000000000..3cbfd2a8c --- /dev/null +++ b/Modules/Mcp/Logic/ToolsHandlerBuilder.cs @@ -0,0 +1,28 @@ +using GenHTTP.Api.Content; + +namespace GenHTTP.Modules.Mcp.Logic; + +public class ToolsHandlerBuilder : IHandlerBuilder +{ + private readonly List _Concerns = []; + + private readonly List _Tools = []; + + public ToolsHandlerBuilder Add(ITool tool) + { + _Tools.Add(tool); + return this; + } + + public ToolsHandlerBuilder Add(IConcernBuilder concern) + { + _Concerns.Add(concern); + return this; + } + + public IHandler Build() + { + return Concerns.Chain(_Concerns, new ToolsHandler(_Tools)); + } + +} diff --git a/Modules/Mcp/Tools.cs b/Modules/Mcp/Tools.cs new file mode 100644 index 000000000..cac39a380 --- /dev/null +++ b/Modules/Mcp/Tools.cs @@ -0,0 +1,10 @@ +using GenHTTP.Modules.Mcp.Logic; + +namespace GenHTTP.Modules.Mcp; + +public static class Tools +{ + + public static ToolsHandlerBuilder Create() => new(); + +} diff --git a/Modules/Mcp/Types/JsonRpc.cs b/Modules/Mcp/Types/JsonRpc.cs new file mode 100644 index 000000000..c9969413d --- /dev/null +++ b/Modules/Mcp/Types/JsonRpc.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GenHTTP.Modules.Mcp.Types; + +public abstract class JsonRpcBase +{ + + [JsonPropertyName("jsonrpc")] + public string Version { get; init; } = "2.0"; + +} + +public sealed class JsonRpcRequest : JsonRpcBase +{ + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("method")] + public required string Method { get; init; } + + [JsonPropertyName("params")] + public JsonElement? Params { get; init; } +} + +public sealed class JsonRpcResponse : JsonRpcBase +{ + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("result")] + public TResult? Result { get; init; } + + [JsonPropertyName("error")] + public JsonRpcError? Error { get; init; } + + [JsonIgnore] + public bool IsError => Error != null; + +} + +public sealed class JsonRpcError +{ + + [JsonPropertyName("code")] + public int Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("data")] + public object? Data { get; init; } + +} diff --git a/Modules/Mcp/Types/ToolCall.cs b/Modules/Mcp/Types/ToolCall.cs new file mode 100644 index 000000000..985b08253 --- /dev/null +++ b/Modules/Mcp/Types/ToolCall.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GenHTTP.Modules.Mcp.Types; + +public sealed class ToolCallParams +{ + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("arguments")] + public JsonElement? Arguments { get; init; } + +} diff --git a/Modules/Mcp/Types/ToolList.cs b/Modules/Mcp/Types/ToolList.cs new file mode 100644 index 000000000..97cabbd61 --- /dev/null +++ b/Modules/Mcp/Types/ToolList.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace GenHTTP.Modules.Mcp.Types; + +public sealed class ToolList +{ + + [JsonPropertyName("tools")] + public List Tools { get; set; } = new(); + +} + +public sealed class ToolInfo +{ + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("inputSchema")] + public required object InputSchema { get; set; } + + [JsonPropertyName("outputSchema")] + public required object OutputSchema { get; set; } + +} diff --git a/Modules/Reflection/MethodHandler.cs b/Modules/Reflection/MethodHandler.cs index dd9580bda..993bb79a3 100644 --- a/Modules/Reflection/MethodHandler.cs +++ b/Modules/Reflection/MethodHandler.cs @@ -164,7 +164,7 @@ public MethodHandler(Operation operation, Func> inst } } - private static async ValueTask UnwrapAsync(object? result) + public static async ValueTask UnwrapAsync(object? result) { if (result == null) { diff --git a/Playground/GenHTTP.Playground.csproj b/Playground/GenHTTP.Playground.csproj index 7fd044725..98f1f74f0 100644 --- a/Playground/GenHTTP.Playground.csproj +++ b/Playground/GenHTTP.Playground.csproj @@ -46,6 +46,7 @@ + diff --git a/Playground/Program.cs b/Playground/Program.cs index 5808f9974..8c080c98b 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,9 +1,12 @@ using GenHTTP.Engine.Kestrel; -using GenHTTP.Modules.IO; +using GenHTTP.Modules.Mcp; using GenHTTP.Modules.Practices; -var app = Content.From(Resource.FromString("Hello World")); +// var app = Content.From(Resource.FromString("Hello World")); + +var app = Tools.Create() + .Add(new Add()); await Host.Create() .Handler(app) @@ -11,3 +14,17 @@ await Host.Create() .Development() .Console() .RunAsync(); + +class Add : ITool<(int A, int B), int> +{ + + public string Name => "add"; + + public string Description => "Adds to integers and returns the result"; + + public int Call((int A, int B) input) + { + return input.A + input.B; + } + +}