diff --git a/MCPSharp.Example.OllamaChatCLI/MCPClientPool.cs b/MCPSharp.Example.OllamaChatCLI/MCPClientPool.cs index ad303b4..e229f74 100644 --- a/MCPSharp.Example.OllamaChatCLI/MCPClientPool.cs +++ b/MCPSharp.Example.OllamaChatCLI/MCPClientPool.cs @@ -6,16 +6,21 @@ class MCPClientPool : ICollection { private readonly List clients = []; - public List GetAllAIFunctions() + public async Task> GetAllAIFunctionsAsync() { var functions = new List(); - clients.ForEach(c => functions.AddRange(c.GetFunctionsAsync().Result)); + + foreach (var c in clients) + { + functions.AddRange(await c.GetFunctionsAsync()); + } + return functions; } public int Count => clients.Count; public bool IsReadOnly => false; - public void Add(string name, McpServerConfiguration server, Func, bool> permissionFunction = null) + public void Add(string name, McpServerConfiguration server, Func, bool>? permissionFunction = null) { clients.Add(new MCPClient(name, "0.1.0", server.Command, string.Join(' ', server.Args ?? []), server.Env) { diff --git a/MCPSharp.Example.OllamaChatCLI/MCPSharp.Example.OllamaChatCLI.csproj b/MCPSharp.Example.OllamaChatCLI/MCPSharp.Example.OllamaChatCLI.csproj index 27ac6c7..aab0822 100644 --- a/MCPSharp.Example.OllamaChatCLI/MCPSharp.Example.OllamaChatCLI.csproj +++ b/MCPSharp.Example.OllamaChatCLI/MCPSharp.Example.OllamaChatCLI.csproj @@ -12,6 +12,7 @@ + diff --git a/MCPSharp.Example.OllamaChatCLI/Program.cs b/MCPSharp.Example.OllamaChatCLI/Program.cs index 395794a..9a07c4b 100644 --- a/MCPSharp.Example.OllamaChatCLI/Program.cs +++ b/MCPSharp.Example.OllamaChatCLI/Program.cs @@ -2,10 +2,8 @@ using System.Text; using System.Text.Json; -JsonSerializerOptions jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; -McpServerConfigurationCollection conf = JsonSerializer.Deserialize(File.ReadAllText("config.json"), - jsonSerializerOptions)!; +McpServerConfigurationCollection conf = JsonSerializer.Deserialize(File.ReadAllText("config.json"), JsonOptionsManager.Standard)!; MCPClientPool clients = []; foreach (var server in conf.McpServers) @@ -23,8 +21,8 @@ } var chatOptions = new ChatOptions { - Tools = clients.GetAllAIFunctions(), - ToolMode = ChatToolMode.Auto //let the assistant choose not to use a tool if it doesn't need to + Tools = clients.GetAllAIFunctionsAsync().Result, + ToolMode = ChatToolMode.Auto //let the assistant choose not to use a tool if it doesn't need to (should be default) }; var chatHistory = new List() { new(ChatRole.System, conf.Models["ollama"].SystemPrompt) }; var chatClient = new OllamaChatClient(conf.Models["ollama"].Endpoint, conf.Models["ollama"].ModelId).AsBuilder().UseFunctionInvocation().Build(); @@ -37,7 +35,7 @@ chatHistory.Add(new ChatMessage(ChatRole.User, input)); var response = await chatClient.GetResponseAsync(chatHistory, chatOptions); Console.WriteLine($"\n\n[Assistant] {DateTime.Now.ToShortTimeString()}: {response}"); - chatHistory.Add(response.Message); + chatHistory.AddRange(response.Messages); } class McpServerConfiguration @@ -47,17 +45,21 @@ class McpServerConfiguration public Dictionary Env { get; set; } = []; } +class JsonOptionsManager +{ + public static JsonSerializerOptions Standard = new() { PropertyNameCaseInsensitive = true }; +} class McpServerConfigurationCollection { - public Dictionary McpServers { get; set; } - public Dictionary Models { get; set; } + public Dictionary McpServers { get; set; } = []; + public Dictionary Models { get; set; } = []; } class ModelConfiguration { - public string Endpoint { get; set; } - public string ModelId { get; set; } - public string SystemPrompt { get; set; } + public string Endpoint { get; set; } = ""; + public string ModelId { get; set; } = ""; + public string SystemPrompt { get; set; } = ""; } diff --git a/MCPSharp.Example/MCPDev.cs b/MCPSharp.Example/MCPDev.cs index 194ec8d..fc50f8a 100644 --- a/MCPSharp.Example/MCPDev.cs +++ b/MCPSharp.Example/MCPDev.cs @@ -3,14 +3,26 @@ ///testing interface for custom .net mcp server public class MCPDev() { - [McpResource("name", "test://{name}")] - public string Name(string name) => $"hello {name}"; - - - [McpResource("settings", "test://settings", "string", "the settings document")] + /// + /// example of a simple resource that returns a string + /// + [McpResource("settings", "test://settings", "text/plain", "the settings document")] public string Settings { get; set; } = "settings"; + /// + /// example of a function that changes the value of the settings resource + /// + /// + [McpTool("change_setings", "change the value of the settings resource")] + public bool ChangeSettings(string value) { + Settings = value; + return true; + } + /// + /// example of a function that attempts to write to console - to ensure this does not break the stream + /// + /// [McpTool("write-to-console", "write a string to the console")] public static void WriteToConsole(string message) => Console.WriteLine(message); @@ -43,7 +55,9 @@ public class MCPDev() /// /// /// +#pragma warning disable CS0618 // Type or member is obsolete [McpFunction("throw_exception")] //leaving this one as [McpFunction] for testing purposes +#pragma warning restore CS0618 // Type or member is obsolete public static string Exception() => throw new Exception("This is an exception"); } } \ No newline at end of file diff --git a/MCPSharp.Example/Program.cs b/MCPSharp.Example/Program.cs index 48baed0..762d728 100644 --- a/MCPSharp.Example/Program.cs +++ b/MCPSharp.Example/Program.cs @@ -1,11 +1,24 @@ using MCPSharp; using MCPSharp.Model; -using MCPSharp.ExternalExample; using MCPSharp.Model.Schemas; +using MCPSharp.Example.Import; +using Microsoft.Extensions.AI; -MCPServer.Register(); +//register tools that are not part of the core assembly +MCPServer.Register(); + +//register tools that are build with semantic kernel attributes MCPServer.Register(); +//example AIFunctions created from lambdas +var myFunc = AIFunctionFactory.Create(() => { return "ahoyhoy!"; }, "AI_Function", "an AIFunction that has been imported"); +var myOtherFunc = AIFunctionFactory.Create((string input) => { return input.ToUpperInvariant(); }, "to_upper", "converts a string to uppercase (culture invariant)"); + +//register the AIFunctions +MCPServer.RegisterAIFunction(myFunc); +MCPServer.RegisterAIFunction(myOtherFunc); + +//add a dynamically built tool handler MCPServer.AddToolHandler( new Tool() { Name = "dynamicTool", @@ -18,6 +31,9 @@ {"input2", new ParameterSchema{Type="string", Description="the input2"}} } } -}, (string input, string? input2 = null) => { return $"hello, {input}.\n{input2 ?? "didn't feel like filling in the second value just because it wasn't required? shame. just kidding! thanks for your help!"}"; }); + +}, (string input, string? input2 = null) => { return $"hello, {input}.\n{input2 ?? + "didn't feel like filling in the second value just because it wasn't required? shame. just kidding! thanks for your help!"}"; }); +//start the server await MCPServer.StartAsync("TestServer", "1.0"); \ No newline at end of file diff --git a/MCPSharp.ExternalExample/ExternalTool.cs b/MCPSharp.ExternalExample/ExternalTool.cs index 86082ec..95f74fd 100644 --- a/MCPSharp.ExternalExample/ExternalTool.cs +++ b/MCPSharp.ExternalExample/ExternalTool.cs @@ -1,14 +1,13 @@ using Microsoft.SemanticKernel; using System.ComponentModel; -namespace MCPSharp.ExternalExample +namespace MCPSharp.Example.Import { - [McpTool("external_tools", "for testing accessing tool classes loaded from a library")] public class ExternalTool { - [McpFunction("dll-tool", "attempts to use a tool that is loaded from an external assembly dll. should return 'success'")] + [McpTool("dll-tool", "attempts to use a tool that is loaded from an external assembly dll. should return 'success'")] public static async Task UseAsync() { return await Task.Run(()=>"success"); diff --git a/MCPSharp.Test/AIFunctionAbstractionTests.cs b/MCPSharp.Test/AIFunctionAbstractionTests.cs index cf18cb1..03df5a9 100644 --- a/MCPSharp.Test/AIFunctionAbstractionTests.cs +++ b/MCPSharp.Test/AIFunctionAbstractionTests.cs @@ -1,19 +1,18 @@ using MCPSharp.Model.Results; using Microsoft.Extensions.AI; -using System.Text.Json; -using System.Xml.Linq; namespace MCPSharp.Test { [TestClass] public class AIFunctionAbstractionTests { - private static MCPClient client; - private static IList functions; + private static MCPClient? client; + private static IList? functions; [ClassInitialize] public static async Task ClassInitialize(TestContext context) { + Console.WriteLine(context.TestName); client = new("Test Client", "1.0.0", "dotnet", "MCPSharp.Example.dll"); functions = await client.GetFunctionsAsync(); } @@ -23,7 +22,7 @@ public static async Task ClassInitialize(TestContext context) public async Task TestInvokingAnAIFunction() { - var function = functions.First(f => f.Name == "Hello"); + var function = functions?.First(f => f.Name == "Hello"); CallToolResult result = (CallToolResult)(await function!.InvokeAsync())!; Assert.IsFalse(result.IsError, $"{result.Content[0].Text}"); Assert.AreEqual("hello, claude.", result.Content[0].Text); @@ -34,10 +33,10 @@ public async Task TestInvokingAnAIFunction() public async Task TestInvokingAnAIFunctionWithParameters() { - var function = functions.First(f => f.Name == "Echo"); - var Schema = function.JsonSchema; + var function = functions?.First(f => f.Name == "Echo"); + var Schema = function?.JsonSchema; Console.WriteLine(Schema); - CallToolResult result = (CallToolResult)(await function.InvokeAsync(new Dictionary { { "input", "hello there" } }))!; + CallToolResult result = (CallToolResult)(await function!.InvokeAsync(new Dictionary { { "input", "hello there" } }))!; Assert.IsFalse(result.IsError); @@ -46,7 +45,8 @@ public async Task TestInvokingAnAIFunctionWithParameters() } - [ClassCleanup] + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] public static void ClassCleanup() { client?.Dispose(); diff --git a/MCPSharp.Test/ClientTests.cs b/MCPSharp.Test/ClientTests.cs index 9928582..d910e35 100644 --- a/MCPSharp.Test/ClientTests.cs +++ b/MCPSharp.Test/ClientTests.cs @@ -5,11 +5,12 @@ namespace MCPSharp.Test [TestClass] public class ClientTests { - public static MCPClient client; + private static MCPClient? client; [ClassInitialize] public static void ClassInitialize(TestContext context) { + Console.WriteLine(context.TestName); client = new("Test Client", "1.0.0", RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "c:\\program files\\nodejs\\npx.cmd" : "npx", "-y @modelcontextprotocol/server-everything") @@ -17,14 +18,12 @@ public static void ClassInitialize(TestContext context) } - [ClassCleanup] - public static void ClassCleanup() { client?.Dispose(); } - + [TestCategory("Tools")] [TestMethod("Client - Tools/list")] public async Task TestListTools() { - var tools = await client.GetToolsAsync(); + var tools = await client!.GetToolsAsync(); Assert.IsNotNull(tools); Assert.IsTrue(tools.Count > 0); tools.ForEach(tool => @@ -40,7 +39,7 @@ public async Task TestListTools() public async Task TestCallTool() { - var result = await client.CallToolAsync("echo", new Dictionary { { "message", "test" } }); + var result = await client!.CallToolAsync("echo", new Dictionary { { "message", "test" } }); string response = result.Content[0].Text; Assert.AreEqual("Echo: test", response); @@ -52,7 +51,7 @@ public async Task TestCallTool() [TestMethod("Client - Prompts/List")] public async Task TestListPrompts() { - var result = await client.GetPromptListAsync(); + var result = await client!.GetPromptListAsync(); Assert.IsFalse(result.Prompts.Count != 0); } @@ -60,12 +59,17 @@ public async Task TestListPrompts() [TestMethod("Client - Resources/List")] public async Task TestResources() { - var result = await client.GetResourcesAsync(); + var result = await client!.GetResourcesAsync(); Assert.IsTrue(result.Resources.Count != 0); result.Resources.ForEach(result => { Console.WriteLine(result.Name); }); } + + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static void ClassCleanup() { client?.Dispose(); } + } } diff --git a/MCPSharp.Test/MCPSharp.Test.csproj b/MCPSharp.Test/MCPSharp.Test.csproj index bd18914..446f235 100644 --- a/MCPSharp.Test/MCPSharp.Test.csproj +++ b/MCPSharp.Test/MCPSharp.Test.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,6 +8,15 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/MCPSharp.Test/SSETransportTests.cs b/MCPSharp.Test/SSETransportTests.cs index a51abe5..c9cc926 100644 --- a/MCPSharp.Test/SSETransportTests.cs +++ b/MCPSharp.Test/SSETransportTests.cs @@ -1,41 +1,42 @@ -namespace MCPSharp.Test -{ - [TestClass()] - public sealed class SSETransportTests - { - private static MCPClient? _client; - private static MCPClient? client; +//namespace MCPSharp.Test +//{ +// [TestClass()] +// public sealed class SSETransportTests +// { +// private static readonly MCPClient? _client; +// private static readonly MCPClient? client; - [ClassInitialize] - [Timeout(5000)] - public static void ClassInitialize(TestContext context) { - // _client = new("Test Client", "1.0.0", "MCPSharp.Example.exe"); //start the exe - // client = new(new Uri("http://localhost:8000/sse"), "test_sse_client", "1"); //connect to the sse server - } +// [ClassInitialize] +// [Timeout(5000)] +// public static void ClassInitialize(TestContext context) { +// Console.WriteLine(context.TestName); +// // _client = new("Test Client", "1.0.0", "MCPSharp.Example.exe"); //start the exe +// // client = new(new Uri("http://localhost:8000/sse"), "test_sse_client", "1"); //connect to the sse server +// } - // [TestMethod("tools/list")] //unucomment this when SSE works - [Timeout(10000)] - public async Task TestListToolsAsync() - { - Assert.IsTrue(client!.Initialized); - var tools = await client.GetToolsAsync(); - Assert.IsNotNull(tools); - Assert.IsTrue(tools.Count > 0); - tools.ForEach(tool => - { - Assert.IsFalse(string.IsNullOrEmpty(tool.Name)); - Assert.IsFalse(string.IsNullOrEmpty(tool.Description)); - Console.WriteLine(tool.Name); - }); - } +// // [TestMethod("tools/list")] //unucomment this when SSE works +// [Timeout(10000)] +// public static async Task TestListToolsAsync() +// { +// Assert.IsTrue(client!.Initialized); +// var tools = await client.GetToolsAsync(); +// Assert.IsNotNull(tools); +// Assert.IsTrue(tools.Count > 0); +// tools.ForEach(tool => +// { +// Assert.IsFalse(string.IsNullOrEmpty(tool.Name)); +// Assert.IsFalse(string.IsNullOrEmpty(tool.Description)); +// Console.WriteLine(tool.Name); +// }); +// } - public class TestTool - { - [McpTool("test", "test function")] - public async Task TestFunctionAsync() => await Task.FromResult("test"); - } +// public class TestTool +// { +// [McpTool("test", "test function")] +// public static async Task TestFunctionAsync() => await Task.FromResult("test"); +// } - } -} +// } +//} diff --git a/MCPSharp.Test/STDIOTransportTests.cs b/MCPSharp.Test/STDIOTransportTests.cs index fc586c5..11e4d55 100644 --- a/MCPSharp.Test/STDIOTransportTests.cs +++ b/MCPSharp.Test/STDIOTransportTests.cs @@ -1,4 +1,6 @@ using MCPSharp.Example; +using MCPSharp.Model.Results; +using Microsoft.SemanticKernel; namespace MCPSharp.Test { @@ -71,6 +73,24 @@ public async Task TestCallToolWithParameters() } + [TestCategory("Tools")] + [TestMethod("Tools/Call with AIFunction")] + public async Task TestCallToolWithAIFunction() + { + var result = await client.CallToolAsync("AI_Function"); + string response = result.Content[0].Text; + Assert.AreEqual("ahoyhoy!", response); + } + + [TestCategory("Tools")] + [TestMethod("Tools/Call with AIFunction with parameters")] + public async Task TestCallToolWithAIFunctionWithParameters() + { + var result = await client.CallToolAsync("to_upper", new Dictionary { { "input", "this is a test of the to_upper function" } }); + string response = result.Content[0].Text; + Assert.AreEqual("this is a test of the to_upper function".ToUpperInvariant(), response); + } + [TestCategory("Misc")] [TestMethod("Exception Handling")] public async Task TestException() @@ -108,13 +128,37 @@ public async Task TestCallExternalTool() } + [TestCategory("Tools")] + [TestMethod("Tools/Call from SemanticKernel")] + public async Task TestCallToolFromSemanticKernel() + { + + + var builder = Kernel.CreateBuilder(); +#pragma warning disable SKEXP0070 + builder.AddOllamaChatCompletion("llama3.2:latest", new Uri("http://192.168.0.134:11434")); +#pragma warning restore SKEXP0070 + + builder.Plugins.Add(await client.GetKernelPluginAsync()); + + Kernel kernel = builder.Build(); + + var plugin = kernel.Plugins.First(); + var function = plugin.First(f => f.Name == "Echo"); + + var response = await kernel.InvokeAsync(function, new KernelArguments { { "input", new Dictionary { { "input", "test string" } } } }); + + CallToolResult result = response.GetValue()!; + + Assert.AreEqual("test string", result.Content[0].Text); + } [TestCategory("Prompts")] [TestMethod("Prompts/List")] public async Task TestListPrompts() { var result = await client.GetPromptListAsync(); - Assert.IsFalse(result.Prompts.Count != 0); + Assert.AreEqual(0, result.Prompts.Count); } [TestCategory("Misc")] @@ -129,13 +173,21 @@ public async Task TestPing() public async Task TestResources() { var result = await client.GetResourcesAsync(); - Assert.IsTrue(result.Resources.Count != 0); + Assert.AreNotEqual(0, result.Resources.Count); result.Resources.ForEach(result => { Console.WriteLine(result.Name); }); } + [TestCategory("Resources")] + [TestMethod("Resources/read")] + public async Task TestReadResource() + { + var result = await client.GetResourceAsync("test://settings"); + Assert.AreEqual("settings", result.Contents[0].Text); + } + [TestCategory("Tools")] [TestMethod("Tools/Call with parameter obj")] public async Task TestCallToolWithParameterObject() diff --git a/MCPSharp/Attributes/McpResourceAttribute.cs b/MCPSharp/Attributes/McpResourceAttribute.cs index e67f249..a319c43 100644 --- a/MCPSharp/Attributes/McpResourceAttribute.cs +++ b/MCPSharp/Attributes/McpResourceAttribute.cs @@ -1,11 +1,30 @@ namespace MCPSharp { + /// + /// Attribute to mark a method or property as a resource. + /// + /// + /// + /// + /// [AttributeUsage(AttributeTargets.All)] public class McpResourceAttribute(string name = null, string uri=null, string mimeType = null, string description = null) : Attribute { + /// + /// The name of the resource. + /// public string Name { get; set; } = name; - public string Description { get; set; } = description; + /// + /// The description of the resource. + /// + public string? Description { get; set; } = description; + /// + /// The URI of the resource. + /// public string Uri { get; set; } = uri; - public string MimeType { get; set; } = mimeType; + /// + /// The MIME type of the resource. + /// + public string? MimeType { get; set; } = mimeType; } } diff --git a/MCPSharp/Core/MCPClient.cs b/MCPSharp/Core/MCPClient.cs index b66a09d..ed19aea 100644 --- a/MCPSharp/Core/MCPClient.cs +++ b/MCPSharp/Core/MCPClient.cs @@ -3,12 +3,14 @@ using MCPSharp.Model.Parameters; using MCPSharp.Model.Results; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; using StreamJsonRpc; using System.Diagnostics; +using System.Text.RegularExpressions; namespace MCPSharp { - /// /// MCPSharp Model Context Protocol Client. /// @@ -23,6 +25,7 @@ public class MCPClient : IDisposable private readonly string _version; private readonly Process _process; private readonly JsonRpc _rpc; + private readonly ILogger _logger; /// /// Gets a value indicating whether the client has been initialized. @@ -40,7 +43,7 @@ public class MCPClient : IDisposable /// The name of the client. /// The version of the client. /// The path to the executable server. - /// Dictionary containing enviroment variables + /// Dictionary containing enviroment variables /// Additional arguments for the server. public MCPClient(string name, string version, string server, string args = null, IDictionary env =null) { @@ -106,9 +109,37 @@ public MCPClient(string name, string version, string server, string args = null, /// /// [JsonRpcMethod("notifications/tools/list_changed")] - public async Task ToolsListChangedAsync() => await GetToolsAsync(); + public async Task ToolsListChangedAsync() => _ = await GetToolsAsync(); + /// + /// recieve and log a message from the server + /// + /// + /// + [JsonRpcMethod("notifications/message")] + public static async Task MessageAsync(Dictionary parameters) + { + LogLevel logLevel = parameters["level"].ToString() switch + { + "trace" => LogLevel.Trace, + "debug" => LogLevel.Debug, + "information" => LogLevel.Information, + "warning" => LogLevel.Warning, + "error" => LogLevel.Error, + "critical" => LogLevel.Critical, + _ => LogLevel.None + }; + await Task.FromResult(logLevel); + + //Dictionary data = parameters["data"] as Dictionary; + //var errorMesage = data["error"].ToString(); + //var details = data["details"] as Dictionary; + //_logger.Log(logLevel, errorMesage, details); + + Console.WriteLine("Message Logged"); + + } /// /// Expose tools as Microsoft.Extensions.AI AIFunctions @@ -129,7 +160,49 @@ public async Task> GetFunctionsAsync() return functions; } + + /// + /// Gets a semantic kernel plugin from the MCP server. This is a collection of Semantic Kernel Functions + /// + /// + + public async Task GetKernelPluginAsync() + { + var regex = new Regex("[^a-zA-Z0-9]"); + List functions = []; + + await GetToolsAsync(); + // convert tools into kernel plugins + foreach (var tool in Tools) + { + List parameters = []; + + foreach (var parameter in tool.InputSchema.Properties) + { + parameters.Add(new(parameter.Key) { + Name = parameter.Key, + Description = parameter.Value.Description, + IsRequired = parameter.Value.Required, + ParameterType = Type.GetType(parameter.Value.Type) ?? Type.GetType("System.Object") + }); + } + + functions.Add(KernelFunctionFactory.CreateFromMethod( + async (Dictionary input) => { + var result = await CallToolAsync(tool.Name, input); + return result; + }, + new KernelFunctionFromMethodOptions { + FunctionName = regex.Replace(tool.Name, "_"), + Description = tool.Description, + Parameters = parameters })); + } + + + return KernelPluginFactory.CreateFromFunctions(regex.Replace(_name, "_"), functions); + } + /// /// Gets a list of tools from the MCP server. /// @@ -159,6 +232,12 @@ public async Task CallToolAsync(string name, Dictionary GetResourceAsync(string uri) + { + + return await _rpc.InvokeAsync("resources/read", uri); + + } /// /// Calls a tool with the given name. diff --git a/MCPSharp/Core/MCPFunction.cs b/MCPSharp/Core/MCPFunction.cs index df57ccd..ff136c3 100644 --- a/MCPSharp/Core/MCPFunction.cs +++ b/MCPSharp/Core/MCPFunction.cs @@ -1,53 +1,44 @@ using MCPSharp.Model; -using MCPSharp.Model.Schemas; using Microsoft.Extensions.AI; -using System.Dynamic; using System.Text.Json; -using System.Text.Json.Serialization; namespace MCPSharp { - public class MCPFunctionInputSchema : InputSchema - { - public MCPFunctionInputSchema(string name, string description, InputSchema schema) - { - Name = name; - Description = description; - Schema = schema.Schema; - Required = schema.Required; - Properties = schema.Properties; - Type = schema.Type; - AdditionalProperties = schema.AdditionalProperties; - } - - [JsonPropertyName("title")] - public string Name; - - [JsonPropertyName("description")] - public string Description; - } - - public class MCPFunctionSchema - { - [JsonPropertyName("description")] - string Description { get; set; } - [JsonPropertyName("type")] - string Type { get; set; } - - [JsonPropertyName("properties")] - Dictionary Properties { get; set; } - - } + /// + /// This class represents a function that can be called by any client that implements the Microsoft.Extensions.AI IChatClient interface. + /// This is generate automatically to expose tools to that ecosystem. + /// + /// + /// public class MCPFunction(Tool tool, MCPClient client) : AIFunction() { - private Tool _tool = tool; + private readonly Tool _tool = tool; + private readonly MCPClient _client = client; + + /// + /// Gets a description of the tool. + /// public override string Description => _tool.Description; + + /// + /// Gets the name of the tool. + /// public override string Name => _tool.Name; + + /// + /// Gets a JSON schema that describes the input to the tool. + /// public override JsonElement JsonSchema => JsonSerializer.SerializeToElement(new MCPFunctionInputSchema(_tool.Name, _tool.Description, _tool.InputSchema)); + /// + /// Invokes the tool with the specified arguments. + /// + /// + /// + /// protected override async Task InvokeCoreAsync(IEnumerable> arguments, CancellationToken cancellationToken) { return await _client.CallToolAsync(_tool.Name, arguments.ToDictionary(p => p.Key, p => p.Value)); diff --git a/MCPSharp/Core/MCPServer.cs b/MCPSharp/Core/MCPServer.cs index d55bad9..7bde876 100644 --- a/MCPSharp/Core/MCPServer.cs +++ b/MCPSharp/Core/MCPServer.cs @@ -1,9 +1,13 @@ -using MCPSharp.Core.Tools; +using MCPSharp.Core; +using MCPSharp.Core.Tools; using MCPSharp.Core.Transport; using MCPSharp.Model; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Threading; using StreamJsonRpc; using System.Reflection; +using System.Text.Json; namespace MCPSharp { @@ -14,17 +18,17 @@ public class MCPServer { private static readonly MCPServer _instance = new(); private readonly JsonRpc _rpc; - private readonly Stream StandardOutput; - + private readonly ILogger _logger; private readonly ToolManager _toolManager = new() { - ToolChangeNotification = () => { if (EnableToolChangeNotification) - _= _instance._rpc.InvokeWithParameterObjectAsync("notifications/tools/list_changed", null);} + ToolChangeNotification = () => { + if (EnableToolChangeNotification) + _= _instance._rpc.NotifyWithParameterObjectAsync("notifications/tools/list_changed", null); + } }; private readonly ResourceManager _resouceManager = new(); - - private readonly ServerRpcTarget _target; + //private readonly ServerRpcTarget _target; private readonly CancellationTokenSource _cancellationTokenSource = new(); /// @@ -53,16 +57,21 @@ public class MCPServer private MCPServer() { Implementation = new(); - _target = new(_toolManager, _resouceManager, Implementation); + Console.SetOut(RedirectedOutput); + _rpc = new JsonRpc(new NewLineDelimitedMessageHandler(new StdioTransportPipe(), new SystemTextJsonFormatter() { - JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions { + JsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase - } }), _target); + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } })); + + _logger = new McpServerLogger(_rpc); + _rpc.AddLocalRpcTarget(new ServerRpcTarget(_toolManager, _resouceManager, Implementation, _logger)); _rpc.StartListening(); + } /// @@ -70,15 +79,44 @@ private MCPServer() /// /// [Obsolete("Call Register instead. The method has been renamed to clear any confusion. They are functionally identical")] - public static void RegisterTool() where T : class, new() => _instance._toolManager.Register(); + public static void RegisterTool() where T : class, new() => _=_instance.RegisterAsync(); /// /// Registers a tool with the server. /// - /// - public static void Register() where T : class, new()=>_ = _instance.RegisterAsync(); - public async Task RegisterAsync() where T : class, new() { _toolManager.Register(); _resouceManager.Register(); } - public static void AddToolHandler(Tool tool, Delegate func) => _instance._toolManager.AddToolHandler(new ToolHandler(tool, func.Method)); + /// The class containing the member methods you wish to expose + public static void Register() where T : class, new()=> _=_instance.RegisterAsync(); + + /// + /// Registers a tool with the server. + /// + /// The class containing the member methods you wish to expose + /// + public async Task RegisterAsync() where T : class, new() + { + var instance = new T(); + + await Task.Run(() => + { + _logger.LogInformation("Registering {TypeName}", typeof(T).Name); + _toolManager.Register(instance); + _resouceManager.Register(instance); + }); + } + + /// + /// Registers a tool with the server. + /// + /// + public static void RegisterAIFunction(AIFunction function) => _ = _instance._toolManager.RegisterAIFunctionAsync(function); + + /// + /// Register a Delegate function along with tool definition + /// + /// the Tool object describing the tool and it's parameters + /// the Function you wish to handle the tool + public static void AddToolHandler(Tool tool, Delegate func) => + _instance._toolManager.AddToolHandler(new ToolHandler(tool, func.Method)); /// /// forward Console.WriteLine() to a TextWriter @@ -109,7 +147,7 @@ public static async Task StartAsync(string serverName, string version) foreach (var toolType in allTypes) { - var registerMethod = typeof(MCPServer).GetMethod(nameof(ToolManager.Register))?.MakeGenericMethod(toolType); + var registerMethod = typeof(MCPServer).GetMethod(nameof(Register))?.MakeGenericMethod(toolType); registerMethod?.Invoke(_instance, null); } @@ -147,5 +185,10 @@ internal void Dispose() _cancellationTokenSource.Cancel(); _rpc.Dispose(); } + + internal static void HandleClientDisconnected() + { + _instance?.Dispose(); + } } } diff --git a/MCPSharp/Core/McpServerLogger.cs b/MCPSharp/Core/McpServerLogger.cs new file mode 100644 index 0000000..0d28071 --- /dev/null +++ b/MCPSharp/Core/McpServerLogger.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Threading; +using StreamJsonRpc; +using System.Collections.Concurrent; + +namespace MCPSharp +{ + /// + /// Logger implementation that sends log messages to a JSON-RPC endpoint. + /// + internal class McpServerLogger : ILogger + { + private readonly JsonRpc _rpc; + private static readonly ConcurrentQueue _messageBuffer = new ConcurrentQueue(); + private static bool _clientConnected = false; + + /// + /// Initializes a new instance of the class. + /// + /// The JSON-RPC client to use for sending log messages. + public McpServerLogger(JsonRpc rpc) + { + _rpc = rpc; + // Set up event handler for JsonRpc connection established + _rpc.Disconnected += OnClientDisconnected; + } + + /// + /// Handles the client disconnection event + /// + private void OnClientDisconnected(object sender, JsonRpcDisconnectedEventArgs e) + { + _clientConnected = false; + // Notify the MCPServer to properly dispose resources and exit + MCPServer.HandleClientDisconnected(); + } + + /// + /// Begins a logical operation scope. + /// + /// The type of the state to begin scope for. + /// The identifier for the scope. + /// A disposable object that ends the logical operation scope on dispose. + public IDisposable BeginScope(TState state) where TState : notnull + { + return null; + } + + + + /// + /// Checks if the given is enabled. + /// + /// Level to be checked. + /// True if enabled, false otherwise. + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + /// + /// Sets the client connection state and sends any buffered messages. + /// + /// Whether the client is connected. + public static void SetClientConnected(bool connected) + { + _clientConnected = connected; + if (connected) + { + FlushMessageBuffer(); + } + } + + /// + /// Sends all buffered messages to the connected client. + /// + private static void FlushMessageBuffer() + { + if (!_clientConnected || _messageBuffer.IsEmpty) + return; + + // Process all messages in the buffer + while (_messageBuffer.TryDequeue(out var message)) + { + message.SendMessageAction(); + } + } + + /// + /// Writes a log entry to the JSON-RPC endpoint or buffers it if the client is not connected. + /// + /// The type of the object to be written. + /// Entry will be written on this level. + /// Id of the event. + /// The entry to be written. Can be also an object. + /// The exception related to this entry. + /// Function to create a string message of the state and exception. + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + + try + { + // Format the message using the provided formatter + string message = formatter(state, exception); + + // Create error details including exception info if available + var details = new Dictionary + { + ["message"] = message + }; + + if (exception != null) + { + details["exceptionType"] = exception.GetType().FullName; + details["exceptionMessage"] = exception.Message; + details["stackTrace"] = exception.StackTrace; + } + + if (eventId.Id != 0) + { + details["eventId"] = eventId.Id.ToString(); + if (!string.IsNullOrEmpty(eventId.Name)) + { + details["eventName"] = eventId.Name; + } + } + + // Create a delegate to send the message + Action sendAction = () => + { + try + { + _ = _rpc.NotifyWithParameterObjectAsync("notifications/message", + new + { + level = logLevel.ToString().ToLowerInvariant(), + logger = "console", + data = new + { + error = message, + details + } + }); + } + catch + { + // Silently handle exceptions when sending messages + } + }; + + // Either send immediately if connected or buffer for later + if (_clientConnected) + { + sendAction(); + } + else + { + _messageBuffer.Enqueue(new LogMessage(sendAction)); + } + } + catch + { + // Prevent any logging errors from crashing the application + } + } + + /// + /// Represents a log message that can be buffered. + /// + private class LogMessage + { + public Action SendMessageAction { get; } + + public LogMessage(Action sendMessageAction) + { + SendMessageAction = sendMessageAction; + } + } + } +} \ No newline at end of file diff --git a/MCPSharp/Core/ResourceManager.cs b/MCPSharp/Core/ResourceManager.cs new file mode 100644 index 0000000..b65268f --- /dev/null +++ b/MCPSharp/Core/ResourceManager.cs @@ -0,0 +1,46 @@ +using System.Reflection; + +namespace MCPSharp.Core +{ + class ResourceManager + { + public readonly List Resources = []; + + public void Register(object instance) + { + var type = instance.GetType(); + foreach (var method in type.GetMethods()) + { + var resAttr = method.GetCustomAttribute(); + if (resAttr != null) + { + Resources.Add(new Resource() + { + Name = resAttr.Name, + Description = resAttr.Description, + Uri = resAttr.Uri, + MimeType = resAttr.MimeType, + Method = method, + Instance = instance + }); + } + } + foreach (var property in type.GetProperties()) + { + var resAttr = property.GetCustomAttribute(); + if (resAttr != null) + { + Resources.Add(new Resource() + { + Name = resAttr.Name, + Description = resAttr.Description, + Uri = resAttr.Uri, + MimeType = resAttr.MimeType, + Method = property.GetMethod, + Instance = instance + }); + } + } + } + } +} diff --git a/MCPSharp/Core/ServerRpcTarget.cs b/MCPSharp/Core/ServerRpcTarget.cs index 2d4c210..21b32be 100644 --- a/MCPSharp/Core/ServerRpcTarget.cs +++ b/MCPSharp/Core/ServerRpcTarget.cs @@ -1,14 +1,19 @@ -using MCPSharp.Core.Tools; +using MCPSharp.Core; +using MCPSharp.Core.Tools; using MCPSharp.Model; using MCPSharp.Model.Capabilities; using MCPSharp.Model.Parameters; using MCPSharp.Model.Results; +using MessagePack; +using Microsoft.Extensions.Logging; using StreamJsonRpc; +using System.Text.Json; namespace MCPSharp { - class ServerRpcTarget(ToolManager toolManager, ResourceManager resourceManager, Implementation implementation) + class ServerRpcTarget(ToolManager toolManager, ResourceManager resourceManager, Implementation implementation, ILogger logger) { + private readonly ILogger _logger = logger; private Implementation _clientInfo; private ClientCapabilities _clientCapabilities; @@ -20,16 +25,23 @@ class ServerRpcTarget(ToolManager toolManager, ResourceManager resourceManager, /// The client information. /// The result of the initialization process. [JsonRpcMethod("initialize")] - public async Task InitializeAsync(string protocolVersion, ClientCapabilities capabilities, Implementation clientInfo) + public async Task InitializeAsync(string protocolVersion, object capabilities, Implementation clientInfo) { - _clientInfo = clientInfo ?? new(); - _clientCapabilities = capabilities ?? new(); - - if (_clientCapabilities.Tools.TryGetValue("listChanged", out bool value)) + if (capabilities is ClientCapabilities cc) { - MCPServer.EnableToolChangeNotification = value; + _clientCapabilities = cc; } + _clientInfo = clientInfo ?? new(); + + //if (_clientCapabilities.Tools.TryGetValue("listChanged", out bool value)) + //{ + // MCPServer.EnableToolChangeNotification = value; + // } + + // Set client as connected when initialized + McpServerLogger.SetClientConnected(true); + return await Task.FromResult( new(protocolVersion, new ServerCapabilities { Tools = new() { { "listChanged", true } } @@ -41,15 +53,38 @@ public async Task InitializeAsync(string protocolVersion, Clie /// /// A task that represents the asynchronous operation. [JsonRpcMethod("notifications/initialized")] - public static async Task InitializedAsync() => await Task.Run(() => { }); + public async Task InitializedAsync() => await Task.Run(() => + { + _logger.LogInformation("notifications/initialized"); + }); /// /// Lists the resources available on the server. /// /// A task that represents the asynchronous operation. The task result contains the list of resources. [JsonRpcMethod("resources/list")] - public async Task ListResourcesAsync() => await Task.Run(() => new ResourcesListResult() { Resources = resourceManager.Resources }); + public async Task ListResourcesAsync() => await Task.Run(() => + { + //_logger.LogInformation("resources/list"); + return new ResourcesListResult() { Resources = resourceManager.Resources }; + }); + + [JsonRpcMethod("resources/read")] + public async Task ReadResourcesAsync(string uri) + { + var res = resourceManager.Resources.First(r => r.Uri == uri); + + return new ResourceReadResultContainer() { + Contents = [new ResourceReadResult(){ + Uri = uri, + Text = (string)res.Method.Invoke(res.Instance, null), + MimeType = "text/plain" + }] + }; + + + } /// /// Lists the resource templates available on the server. /// @@ -63,10 +98,14 @@ public async Task InitializeAsync(string protocolVersion, Clie /// The parameters for the tool call. /// A task that represents the asynchronous operation. The task result contains the result of the tool call. [JsonRpcMethod("tools/call", UseSingleObjectParameterDeserialization = true)] - public async Task CallToolAsync(ToolCallParameters parameters) => - !toolManager.Tools.TryGetValue(parameters.Name, out var toolHandler) + public async Task CallToolAsync(ToolCallParameters parameters) + { + _logger.LogInformation("tools/call {ToolName}", parameters.Name); + _logger.LogDebug("tools/call {ToolName} {Arguments}", parameters.Name, parameters.Arguments); + return !toolManager.Tools.TryGetValue(parameters.Name, out var toolHandler) ? new CallToolResult { IsError = true, Content = [new Model.Content.TextContent { Text = $"Tool {parameters.Name} not found" }] } : await toolHandler.HandleAsync(parameters.Arguments); + } /// /// Lists the tools available on the server. @@ -79,6 +118,15 @@ public async Task ListToolsAsync(object parameters = null) return await Task.FromResult(new ToolsListResult([.. toolManager.Tools.Values.Select(t => t.Tool)])); } + [JsonRpcMethod("logging/setLevel")] + public static async Task SetLogLevelAsync(string level) + { + await Task.Run(() => { + //set the logger level + _ = level; + }); + } + /// /// Pings the server. /// diff --git a/MCPSharp/Core/Tools/ToolHandler.cs b/MCPSharp/Core/Tools/ToolHandler.cs index 9f541ee..4b4e766 100644 --- a/MCPSharp/Core/Tools/ToolHandler.cs +++ b/MCPSharp/Core/Tools/ToolHandler.cs @@ -6,11 +6,29 @@ namespace MCPSharp.Core.Tools { - public class ToolHandler(Tool tool, MethodInfo method) + /// + /// The ToolHandler class is responsible for handling the invocation of a tool. + /// + /// The Tool object holds the description of the Tool and it's parameters + /// The Attributes and metadata of a method, needed to invoke it. + /// The instance of the object that contains the method to be invoked. + public class ToolHandler(Tool tool, MethodInfo method, object instance = null) { + /// + /// The Tool object holds the description of the Tool and it's parameters + /// public Tool Tool = tool; + private readonly MethodInfo _method = method; - + + private readonly object _instance = instance ?? Activator.CreateInstance(method.DeclaringType); + + /// + /// Handles the invocation of a tool with the specified parameters. + /// + /// + /// + /// public async Task HandleAsync(Dictionary parameters, CancellationToken cancellationToken = default) { try @@ -38,7 +56,7 @@ public async Task HandleAsync(Dictionary paramet return new CallToolResult { IsError = true, Content = [new TextContent("Operation was cancelled")] }; } - var result = _method.Invoke(Activator.CreateInstance(_method.DeclaringType),[.. inputValues.Values]); + var result = _method.Invoke(_instance, [.. inputValues.Values]); if (cancellationToken.IsCancellationRequested) diff --git a/MCPSharp/Core/Tools/ToolManager.cs b/MCPSharp/Core/Tools/ToolManager.cs index e2a5e9c..d28cc53 100644 --- a/MCPSharp/Core/Tools/ToolManager.cs +++ b/MCPSharp/Core/Tools/ToolManager.cs @@ -1,54 +1,14 @@ -using MCPSharp.Model.Schemas; -using MCPSharp.Model; +using MCPSharp.Model; +using MCPSharp.Model.Schemas; +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; -using System.ComponentModel.DataAnnotations; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Reflection; -using System.Resources; -using Microsoft.Extensions.AI; -using System.Reflection.Metadata; +using System.Text.Json; namespace MCPSharp.Core.Tools { - class ResourceManager - { - public readonly List Resources = []; - public void Register() where T : class, new() - { - var type = typeof(T); - - foreach (var method in type.GetMethods()) - { - var resAttr = method.GetCustomAttribute(); - if (resAttr != null) - { - Resources.Add(new Resource() - { - Name = resAttr.Name, - Description = resAttr.Description, - Uri = resAttr.Uri, - MimeType = resAttr.MimeType - }); - } - } - - foreach (var property in type.GetProperties()) - { - - var resAttr = property.GetCustomAttribute(); - if (resAttr != null) - { - Resources.Add(new Resource() - { - Name = resAttr.Name, - Description = resAttr.Description, - Uri = resAttr.Uri, - MimeType = resAttr.MimeType - }); - } - } - } - } class ToolManager { @@ -60,27 +20,35 @@ class ToolManager /// /// Scans a class for Tools and resources and registers them with the server /// - public void Register() where T : class, new() + public void Register(object instance) { - - var type = typeof(T); - + var type = instance.GetType(); foreach (var method in type.GetMethods()) { - RegisterMcpFunction(method); - RegisterSemanticKernelFunction(method); + RegisterMcpFunction(method, instance); + RegisterSemanticKernelFunction(method, instance); } - ToolChangeNotification.Invoke(); } - public void AddToolHandler(ToolHandler tool) + public async Task RegisterAIFunctionAsync(AIFunction function) + { + Tools[function.Name] = new ToolHandler(new Tool + { + Name = function.Name, + Description = function.Description, + InputSchema = JsonSerializer.Deserialize(function.JsonSchema) + }, function.UnderlyingMethod, Activator.CreateInstance(function.UnderlyingMethod.DeclaringType)); + await Task.Run(ToolChangeNotification.Invoke); + } + + public void AddToolHandler(ToolHandler tool) { Tools[tool.Tool.Name] = tool; ToolChangeNotification.Invoke(); } - private void RegisterSemanticKernelFunction(MethodInfo method) + private void RegisterSemanticKernelFunction(MethodInfo method, object instance) { var kernelFunctionAttribute = method.GetCustomAttribute(); if (kernelFunctionAttribute == null) return; @@ -94,7 +62,7 @@ private void RegisterSemanticKernelFunction(MethodInfo method) parameterSchemas.Add(parameter.Name, GetParameterSchema(parameter)); } - + Tools[kernelFunctionAttribute.Name] = new ToolHandler(new Tool { Name = kernelFunctionAttribute.Name, @@ -102,12 +70,12 @@ private void RegisterSemanticKernelFunction(MethodInfo method) InputSchema = new InputSchema { Properties = parameterSchemas, - Required = parameterSchemas.Where(kvp => kvp.Value.Required).Select(kvp => kvp.Key).ToList(), + Required = [.. parameterSchemas.Where(kvp => kvp.Value.Required).Select(kvp => kvp.Key)], } - }, method!); + }, method!, instance); } - private ParameterSchema GetParameterSchema(ParameterInfo parameter) + private static ParameterSchema GetParameterSchema(ParameterInfo parameter) { string type = parameter.ParameterType switch { @@ -130,16 +98,18 @@ private ParameterSchema GetParameterSchema(ParameterInfo parameter) return schema; } - private void RegisterMcpFunction(MethodInfo method) + private void RegisterMcpFunction(MethodInfo method, object instance = null) { - string name = ""; + string name = ""; string description = ""; +#pragma warning disable CS0618 // This is needed for backwards compatibility with older versions of the library var mcpFuncAttr = method.GetCustomAttribute(); +#pragma warning restore CS0618 // Type or member is obsolete if (mcpFuncAttr != null) { name = mcpFuncAttr.Name ?? method.Name; - description = mcpFuncAttr.Description ?? method.GetXmlDocumentation(); + description = mcpFuncAttr.Description ?? method.GetXmlDocumentation(); } else { @@ -166,9 +136,9 @@ private void RegisterMcpFunction(MethodInfo method) InputSchema = new InputSchema { Properties = parameterSchemas, - Required = parameterSchemas.Where(kvp => kvp.Value.Required).Select(kvp => kvp.Key).ToList(), + Required = [.. parameterSchemas.Where(kvp => kvp.Value.Required).Select(kvp => kvp.Key)], } - }, method!); + }, method!, instance); } } } diff --git a/MCPSharp/Core/Transport/DuplexPipe.cs b/MCPSharp/Core/Transport/DuplexPipe.cs index 3b9efb0..c3b95ac 100644 --- a/MCPSharp/Core/Transport/DuplexPipe.cs +++ b/MCPSharp/Core/Transport/DuplexPipe.cs @@ -23,20 +23,20 @@ internal class StdioTransportPipe : IDuplexPipe public PipeWriter Output => _writer; } - internal class SSETransportPipe : IDuplexPipe - { - private readonly HttpClient _httpClient = new(); - private readonly Uri _address; - public SSETransportPipe(Uri address) - { - _address = address; - _reader = PipeReader.Create( _httpClient.GetStreamAsync(_address).Result); - _writer = PipeWriter.Create(new HttpPostStream(_address.ToString())); - } + //internal class SSETransportPipe : IDuplexPipe + //{ + // private readonly HttpClient _httpClient = new(); + // private readonly Uri _address; + // public SSETransportPipe(Uri address) + // { + // _address = address; + // _reader = PipeReader.Create( _httpClient.GetStreamAsync(_address).Result); + // _writer = PipeWriter.Create(new HttpPostStream(_address.ToString())); + // } - private PipeReader _reader; - private PipeWriter _writer; - public PipeReader Input => _reader; - public PipeWriter Output => _writer; - } + // private PipeReader _reader; + // private PipeWriter _writer; + // public PipeReader Input => _reader; + // public PipeWriter Output => _writer; + //} } \ No newline at end of file diff --git a/MCPSharp/Core/Transport/SSE/ServerSentEventsService.cs b/MCPSharp/Core/Transport/SSE/ServerSentEventsService.cs index 0c9f518..39cf6ef 100644 --- a/MCPSharp/Core/Transport/SSE/ServerSentEventsService.cs +++ b/MCPSharp/Core/Transport/SSE/ServerSentEventsService.cs @@ -1,41 +1,41 @@ -using Microsoft.AspNetCore.Http; +//using Microsoft.AspNetCore.Http; -namespace MCPSharp.Core.Transport.SSE -{ +//namespace MCPSharp.Core.Transport.SSE +//{ - public class ServerSentEventsService - { - private readonly List<(SseClient Client, MCPServer Instance)> _clients = []; - private readonly object _lock = new(); - private readonly Func _instanceFactory; +// public class ServerSentEventsService +// { +// private readonly List<(SseClient Client, MCPServer Instance)> _clients = []; +// private readonly object _lock = new(); +// private readonly Func _instanceFactory; - public ServerSentEventsService(Func instanceFactory) - { - _instanceFactory = instanceFactory; - } +// public ServerSentEventsService(Func instanceFactory) +// { +// _instanceFactory = instanceFactory; +// } - public async Task AddClientAsync(HttpContext context, Stream input) - { - var client = new SseClient(context, input); - var instance = _instanceFactory(input, context.Response.Body); +// public async Task AddClientAsync(HttpContext context, Stream input) +// { +// var client = new SseClient(context, input); +// var instance = _instanceFactory(input, context.Response.Body); - lock (_lock) - { - _clients.Add((client, instance)); - } - } +// lock (_lock) +// { +// _clients.Add((client, instance)); +// } +// } - public async Task RemoveClientAsync(SseClient client) - { - lock (_lock) - { - var index = _clients.FindIndex(x => x.Client == client); - if (index >= 0) - { - _clients[index].Instance.Dispose(); - _clients.RemoveAt(index); - } - } - } - } -} \ No newline at end of file +// public async Task RemoveClientAsync(SseClient client) +// { +// lock (_lock) +// { +// var index = _clients.FindIndex(x => x.Client == client); +// if (index >= 0) +// { +// _clients[index].Instance.Dispose(); +// _clients.RemoveAt(index); +// } +// } +// } +// } +//} \ No newline at end of file diff --git a/MCPSharp/Core/Transport/SSE/SseClient.cs b/MCPSharp/Core/Transport/SSE/SseClient.cs index 77dbd98..e86581f 100644 --- a/MCPSharp/Core/Transport/SSE/SseClient.cs +++ b/MCPSharp/Core/Transport/SSE/SseClient.cs @@ -1,38 +1,206 @@ -using Microsoft.AspNetCore.Http; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.Threading; using System.IO.Pipelines; +using System.Text; namespace MCPSharp.Core.Transport.SSE { - public class SseClient + /// + /// Represents a connection to a client using Server-Sent Events. + /// + /// + /// + public class SseServerTransport(string endpoint, ServerSseSession session) { - private readonly HttpContext _context; - private readonly CancellationTokenSource _disconnectTokenSource; - public Stream Stream; - public SseClient(HttpContext context, Stream stream) - { - _context = context; - _disconnectTokenSource = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); - Stream = stream; + private readonly string _endpoint = endpoint; + private readonly ServerSseSession _session = session; + private bool _initialized = false; + + /// + /// The session ID for the connection. + /// + public readonly string SessionId = Guid.NewGuid().ToString(); + + /// + /// Starts the connection to the client. + /// + /// + /// + public async Task StartAsync() + { + if (_initialized) + { + throw new InvalidOperationException("SseServerTransport already started! If using Server class, note that connect() calls start() automatically."); + } + + _initialized = true; + + await _session.SendAsync("endpoint", $"{Uri.EscapeDataString(_endpoint)}?sessionId={SessionId}"); + + try + { + await _session.CancellationToken.WaitHandle.ToTask(); + } + finally + { + OnClose?.Invoke(); + } } - public async Task SendEventAsync(string eventType, string data) + /// + /// Handles a POST request from the client. + /// + /// + /// + /// + public async Task HandlePostMessageAsync(HttpContext context) { + if (!_initialized) + { + var message = "SSE connection not established"; + context.Response.StatusCode = 500; + await context.Response.WriteAsync(message); + OnError?.Invoke(new InvalidOperationException(message)); + return; + } + + string body; try { - var response = _context.Response; - await response.WriteAsync($"event: {eventType}\n"); - await response.WriteAsync($"data: {data}\n\n"); - await response.Body.FlushAsync(); + if (!context.Request.ContentType.Equals("application/json", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unsupported content-type: {context.Request.ContentType}"); + } + + using var reader = new StreamReader(context.Request.Body); + body = await reader.ReadToEndAsync(); } - catch + catch (Exception e) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Invalid message: {e.Message}"); + OnError?.Invoke(e); + return; + } + + try { - _disconnectTokenSource.Cancel(); + await HandleMessageAsync(body); } + catch (Exception e) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Error handling message {body}: {e.Message}"); + return; + } + + context.Response.StatusCode = 202; + await context.Response.WriteAsync("Accepted"); } - public Task WaitForDisconnectAsync() + /// + /// Handles a message received from the client. + /// + /// + /// + public async Task HandleMessageAsync(string message) { - return Task.Delay(Timeout.Infinite, _disconnectTokenSource.Token); - } + try + { + var parsedMessage = JsonSerializer.Deserialize(message); + await Task.Run(() => OnMessage?.Invoke(parsedMessage)); + } + catch (Exception e) + { + OnError?.Invoke(e); + throw; + } + } + /// + /// Closes the connection to the client. + /// + /// + public async Task CloseAsync() + { + await _session.CloseAsync(); + OnClose?.Invoke(); + } + /// + /// sse + /// + /// + /// + /// + public async Task SendAsync(JsonRpcMessage message) + { + if (!_initialized) + { + throw new InvalidOperationException("Not connected"); + } + + var jsonMessage = JsonSerializer.Serialize(message); + await _session.SendAsync("message", jsonMessage); + } + + /// + /// the action that is called when a message is received from the client. + /// + public event Action OnMessage; + + /// + /// the action that is called when the connection is closed. + /// + public event Action OnClose; + + /// + /// the action that is called when an error occurs. + /// + public event Action OnError; + } + + /// + /// Represents a connection to a client using Server-Sent Events. + /// + public class ServerSseSession(IDuplexPipe duplexPipe) + { + + private bool connected = true; + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + public async Task SendAsync(string eventName, string data) + { + if (!connected) return; + + await duplexPipe.Output.WriteAsync(UTF8Encoding.UTF8.GetBytes($"event: {eventName}\n")); + await duplexPipe.Output.WriteAsync(UTF8Encoding.UTF8.GetBytes($"data: {data}")); + } + + /// + /// Closes the connection to the client. + /// + /// + public Task CloseAsync() + { + connected = false; + return Task.CompletedTask; + } + + /// + /// cancellation token + /// + public CancellationToken CancellationToken { get; } + } + + /// + /// not needed + /// + public class JsonRpcMessage + { + /* Implementation */ } } \ No newline at end of file diff --git a/MCPSharp/MCPSharp.csproj b/MCPSharp/MCPSharp.csproj index 4865e89..d6061ee 100644 --- a/MCPSharp/MCPSharp.csproj +++ b/MCPSharp/MCPSharp.csproj @@ -79,14 +79,16 @@ await client.CallToolAsync("echo", new Dictionary<string, object>{{"input" - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/MCPSharp/Model/Capabilities/ClientCapabilities.cs b/MCPSharp/Model/Capabilities/ClientCapabilities.cs index fb12d77..94f3271 100644 --- a/MCPSharp/Model/Capabilities/ClientCapabilities.cs +++ b/MCPSharp/Model/Capabilities/ClientCapabilities.cs @@ -8,27 +8,27 @@ public class ClientCapabilities /// /// Gets or sets a value indicating wether roots are supported. /// - public Dictionary Roots { get; set; } = []; + public Dictionary Roots { get; set; } = []; /// /// Gets or sets a value indicating whether sampling is supported. /// - public Dictionary Sampling { get; set; } = []; + public Dictionary Sampling { get; set; } = []; /// /// Gets or sets a value indicating whether tools are supported. /// - public Dictionary Tools { get; set; } = []; + public Dictionary Tools { get; set; } = []; /// /// Gets or sets a value indicating whether resources are supported. /// - public Dictionary Resources { get; set; } = []; + public Dictionary Resources { get; set; } = []; /// /// Gets or sets a value indicating whether prompts are supported. /// - public Dictionary Prompts { get; set; } = []; + public Dictionary Prompts { get; set; } = []; } } diff --git a/MCPSharp/Model/Capabilities/ServerCapabilities.cs b/MCPSharp/Model/Capabilities/ServerCapabilities.cs index b4d8964..c792248 100644 --- a/MCPSharp/Model/Capabilities/ServerCapabilities.cs +++ b/MCPSharp/Model/Capabilities/ServerCapabilities.cs @@ -30,5 +30,10 @@ public class ServerCapabilities /// A dictionary of root capabilities that the server supports. /// public Dictionary Roots { get; set; } = []; + + /// + /// A dictionary of logging capabilities that the server supports. + /// + public Dictionary logging { get; set; } = []; } } \ No newline at end of file diff --git a/MCPSharp/Model/ResourceReadRequest.cs b/MCPSharp/Model/ResourceReadRequest.cs new file mode 100644 index 0000000..2a6713f --- /dev/null +++ b/MCPSharp/Model/ResourceReadRequest.cs @@ -0,0 +1,7 @@ +namespace MCPSharp.Model +{ + public class ResourceReadRequest + { + public string Uri { get; set; } + } +} \ No newline at end of file diff --git a/MCPSharp/Model/Results/ResourceReadResult.cs b/MCPSharp/Model/Results/ResourceReadResult.cs new file mode 100644 index 0000000..4044559 --- /dev/null +++ b/MCPSharp/Model/Results/ResourceReadResult.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace MCPSharp.Model.Results +{ + public class ResourceReadResult + { + public string Uri { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string MimeType { get; set; } + public string Text { get; set; } + public string Blob { get; set; } + } + + public class ResourceReadResultContainer + { + public List Contents { get; set; } + } +} \ No newline at end of file diff --git a/MCPSharp/Model/Results/ResourcesListResult.cs b/MCPSharp/Model/Results/ResourcesListResult.cs index 2e8406e..2cb9c2c 100644 --- a/MCPSharp/Model/Results/ResourcesListResult.cs +++ b/MCPSharp/Model/Results/ResourcesListResult.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +using System.Reflection; using System.Text.Json.Serialization; namespace MCPSharp @@ -17,9 +18,18 @@ public class Resource [JsonPropertyName("uri")] public string Uri { get; set; } [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Description { get; set; } + [JsonPropertyName("mimeType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string MimeType { get; set; } + + + [JsonIgnore] + public MethodInfo Method { get; set; } + [JsonIgnore] + public object Instance { get; set; } } public class ResourceTemplateListResult diff --git a/MCPSharp/Model/Schemas/MCPFunctionInputSchema.cs b/MCPSharp/Model/Schemas/MCPFunctionInputSchema.cs new file mode 100644 index 0000000..33374c9 --- /dev/null +++ b/MCPSharp/Model/Schemas/MCPFunctionInputSchema.cs @@ -0,0 +1,40 @@ +using MCPSharp.Model.Schemas; +using System.Text.Json.Serialization; + +namespace MCPSharp +{ + /// + /// This is a wrapper class for an InputSchema - it includes a couple extra fields that make it work with AIFunctions + /// + public class MCPFunctionInputSchema : InputSchema + { + /// + /// Creates a new MCPFunctionInputSchema from an existing InputSchema + /// + /// + /// + /// + public MCPFunctionInputSchema(string name, string description, InputSchema schema) + { + Name = name; + Description = description; + Schema = schema.Schema; + Required = schema.Required; + Properties = schema.Properties; + Type = schema.Type; + AdditionalProperties = schema.AdditionalProperties; + } + + /// + /// The name of the tool + /// + [JsonPropertyName("title")] + public string Name; + + /// + /// The description of the tool + /// + [JsonPropertyName("description")] + public string Description; + } +} \ No newline at end of file diff --git a/MCPSharp/Model/Schemas/MCPFunctionSchema.cs b/MCPSharp/Model/Schemas/MCPFunctionSchema.cs new file mode 100644 index 0000000..502fab8 --- /dev/null +++ b/MCPSharp/Model/Schemas/MCPFunctionSchema.cs @@ -0,0 +1,21 @@ +//using MCPSharp.Model.Schemas; +//using System.Text.Json.Serialization; + +//namespace MCPSharp +//{ +// /// +// /// This is a wrapper class for ParameterSchemas - it includes a couple extra fields that make it work with AIFunctions +// /// +// public class MCPFunctionSchema +// { +// [JsonPropertyName("description")] +// string Description { get; set; } + +// [JsonPropertyName("type")] +// string Type { get; set; } + +// [JsonPropertyName("properties")] +// Dictionary Properties { get; set; } + +// } +//} \ No newline at end of file diff --git a/MCPSharp/Model/Schemas/ParameterSchema.cs b/MCPSharp/Model/Schemas/ParameterSchema.cs index cd76c2c..b92ebb8 100644 --- a/MCPSharp/Model/Schemas/ParameterSchema.cs +++ b/MCPSharp/Model/Schemas/ParameterSchema.cs @@ -27,6 +27,9 @@ public class ParameterSchema [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonElement? Contents { get; set; } = null; + /// + /// sets whether the parameter is required + /// [JsonIgnore] public bool Required { get; set; } = false; }