From 89dc65e38f2bb8e86e6c991e9932c72ba4d1c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Tue, 22 Jul 2025 20:02:48 +0100 Subject: [PATCH 1/6] Add support for MCP prompts - Add prompt registry class and methods. - Add prompt message, prompt parameter and roles classes. - Extract common methods to RegistryBase class. - Adjust McpToolRegistry accordingly. - Add tests for simple prompts. - Update MCP client test to list available prompts. --- .../McpPromptMessage.cs | 55 ++++ .../McpPromptParameter.cs | 52 ++++ .../McpPromptRegistry.cs | 203 +++++++++++++ .../McpServerController.cs | 17 ++ .../McpServerPromptAttribute.cs | 42 +++ .../McpToolRegistry.cs | 192 +------------ .../McpToolsJsonHelper.cs | 2 +- nanoFramework.WebServer.Mcp/PromptMetadata.cs | 47 +++ nanoFramework.WebServer.Mcp/RegistryBase.cs | 190 +++++++++++++ nanoFramework.WebServer.Mcp/Role.cs | 21 ++ .../nanoFramework.WebServer.Mcp.nfproj | 7 + tests/McpClientTest/McpClientTest.cs | 9 +- tests/McpEndToEndTest/McpEndToEndTest.nfproj | 1 + tests/McpEndToEndTest/McpPromptsClasses.cs | 92 ++++++ tests/McpEndToEndTest/Program.cs | 4 +- .../McpServerTests/McpPromptRegistryTests.cs | 267 ++++++++++++++++++ tests/McpServerTests/McpServerTests.nfproj | 1 + 17 files changed, 1008 insertions(+), 194 deletions(-) create mode 100644 nanoFramework.WebServer.Mcp/McpPromptMessage.cs create mode 100644 nanoFramework.WebServer.Mcp/McpPromptParameter.cs create mode 100644 nanoFramework.WebServer.Mcp/McpPromptRegistry.cs create mode 100644 nanoFramework.WebServer.Mcp/McpServerPromptAttribute.cs create mode 100644 nanoFramework.WebServer.Mcp/PromptMetadata.cs create mode 100644 nanoFramework.WebServer.Mcp/RegistryBase.cs create mode 100644 nanoFramework.WebServer.Mcp/Role.cs create mode 100644 tests/McpEndToEndTest/McpPromptsClasses.cs create mode 100644 tests/McpServerTests/McpPromptRegistryTests.cs diff --git a/nanoFramework.WebServer.Mcp/McpPromptMessage.cs b/nanoFramework.WebServer.Mcp/McpPromptMessage.cs new file mode 100644 index 0000000..1fb7c7d --- /dev/null +++ b/nanoFramework.WebServer.Mcp/McpPromptMessage.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Represents a message within the Model Context Protocol (MCP) system, used for communication between clients and AI models. + /// + /// + /// + /// A encapsulates content sent to or received from AI models in the Model Context Protocol. + /// Each message has a specific role ( or ) and contains content which can be text. + /// + /// + /// It serves as a core data structure in the MCP message exchange flow, particularly in prompt formation and model responses. + /// + /// + public sealed class PromptMessage + { + /// + /// Gets or sets the text content of the message. + /// + public string Text { get; set; } + + /// + /// Gets or sets the role of the message sender, specifying whether it's from a "user" or an "assistant". + /// + /// + /// In the Model Context Protocol, each message must have a clear role assignment to maintain + /// the conversation flow. User messages represent queries or inputs from users, while assistant + /// messages represent responses generated by AI models. + /// + public Role Role { get; set; } = Role.User; + + /// + /// Initializes a new instance of the class with this prompt text. + /// + /// The text content of the message. + public PromptMessage(string text) + { + Text = text; + } + + /// + public override string ToString() + { + return $"{{\"role\":\"{RoleToString(Role)}\",\"content\":{{\"type\": \"text\",\"text\":\"{Text}\"}}}}"; + } + + private static string RoleToString(Role role) + { + return role == Role.User ? "user" : "assistant"; + } + } +} diff --git a/nanoFramework.WebServer.Mcp/McpPromptParameter.cs b/nanoFramework.WebServer.Mcp/McpPromptParameter.cs new file mode 100644 index 0000000..6da47ac --- /dev/null +++ b/nanoFramework.WebServer.Mcp/McpPromptParameter.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Represents a parameter for a Model Context Protocol (MCP) prompt, providing metadata about the parameter such as its name and description. + /// + /// + /// + /// These have to be added to the methods implementing the MCP prompts in the same order as the parameters. + /// + /// + /// By design, if a prompt parameter is specified, it is considered required. .NET nanoFramework does not support optional parameters in MCP prompts. + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public sealed class McpPromptParameterAttribute : Attribute + { + /// + /// Gets or sets the name of the prompt parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the prompt parameter. + /// + /// + /// The description is optional. + /// + public string Description { get; set; } + + /// + /// Initializes a new instance of the class with the specified name and description. + /// + /// The name of the prompt parameter. + /// The description of the prompt parameter. + public McpPromptParameterAttribute(string name, string description) + { + Name = name; + Description = description; + } + + /// + public override string ToString() + { + return $"{{\"name\":\"{Name}\",\"description\":\"{Description}\",\"required\":\"true\"}}"; + } + } +} diff --git a/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs b/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs new file mode 100644 index 0000000..708b36b --- /dev/null +++ b/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs @@ -0,0 +1,203 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Text; + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Registry for Model Context Protocol (MCP) prompts, allowing discovery and invocation of prompts defined with the . + /// + public class McpPromptRegistry : RegistryBase + { + private static readonly Hashtable promtps = new Hashtable(); + private static bool isInitialized = false; + + /// + /// Discovers MCP prompts by scanning the provided types for methods decorated with the . + /// This method should be called once to populate the tool registry. + /// + /// An array of types to scan for MCP prompts. + public static void DiscoverPrompts(Type[] typesWithPrompts) + { + if (isInitialized) + { + // prompts already discovered + return; + } + + foreach (Type mcpPrompt in typesWithPrompts) + { + MethodInfo[] methods = mcpPrompt.GetMethods(); + + foreach (MethodInfo method in methods) + { + try + { + object[] allAttribute = method.GetCustomAttributes(false); + + foreach (object attrib in allAttribute) + { + if (attrib.GetType() != typeof(McpServerPromptAttribute)) + { + continue; + } + + McpServerPromptAttribute attribute = (McpServerPromptAttribute)attrib; + if (attribute != null) + { + // validate if the method returns an array of PromptMessage + if (method.ReturnType != typeof(PromptMessage[]) && !method.ReturnType.IsArray) + { + throw new Exception($"Method {method.Name} does not return an array of PromptMessage."); + } + + promtps.Add(attribute.Name, new PromptMetadata + { + Name = attribute.Name, + Description = attribute.Description, + Arguments = ComposeArgumentsAsJson(allAttribute), + Method = method + }); + } + } + } + catch (Exception) + { + continue; + } + } + } + + isInitialized = true; + } + + private static string ComposeArgumentsAsJson(object[] attributes) + { + StringBuilder sb = new StringBuilder(); + bool isFirst = true; + + foreach (object attrib in attributes) + { + if (attrib is not McpPromptParameterAttribute) + { + continue; + } + + McpPromptParameterAttribute parameterNameAttribute = (McpPromptParameterAttribute)attrib; + if (parameterNameAttribute != null) + { + sb.Append(isFirst ? "" : ","); + + sb.Append($"{{{parameterNameAttribute}}}"); + + isFirst = false; + } + } + + return sb.Length > 0 ? sb.ToString() : string.Empty; + } + + /// + /// Gets the metadata of all registered MCP prompts in JSON format. + /// This method should be called after to retrieve the prompt metadata. + /// + /// A JSON string containing the metadata of all registered prompts. + /// Thrown if there is an error building the prompts list. + public static string GetPromptMetadataJson() + { + try + { + StringBuilder sb = new StringBuilder(); + sb.Append("\"prompts\":["); + + foreach (PromptMetadata prompt in promtps.Values) + { + sb.Append(prompt.ToString()); + sb.Append(","); + } + + sb.Remove(sb.Length - 1, 1); + sb.Append("],\"nextCursor\":null"); + return sb.ToString(); + } + catch (Exception) + { + throw new Exception("Impossible to build prompts list."); + } + } + + /// + /// Gets the description of a registered MCP prompt by its name. + /// + /// The name of the prompt to invoke. + /// A string containing the description of the prompt. + /// Thrown when the specified prompt is not found in the registry. + public static string GetPromptDescription(string promptName) + { + if (promtps.Contains(promptName)) + { + PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName]; + return promptMetadata.Description; + } + + throw new Exception("Prompt not found"); + } + + /// + /// Invokes a registered MCP prompt by name and returns the serialized result. + /// + /// The name of the prompt to invoke. + /// The arguments to pass to the tool. + /// A JSON string containing the serialized result of the prompt invocation. + /// Thrown when the specified prompt is not found in the registry. + public static string InvokePrompt(string promptName, Hashtable arguments) + { + if (promtps.Contains(promptName)) + { + PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName]; + MethodInfo method = promptMetadata.Method; + Debug.WriteLine($"Prompt name: {promptName}, method: {method.Name}"); + + object[] methodParams = null; + + if (arguments is not null && arguments.Count > 0) + { + methodParams = new object[arguments.Count]; + + arguments.Values.CopyTo(methodParams, 0); + } + + PromptMessage[] result = (PromptMessage[])method.Invoke(null, methodParams); + + // serialize the result to JSON using a speedy approach with a StringBuilder + StringBuilder sb = new StringBuilder(); + + // start building the JSON response + sb.Append($"{{\"description\":\"{GetPromptDescription(promptName)}\",\"messages\":["); + + // iterate through the result array and append each message + for (int i = 0; i < result.Length; i++) + { + sb.Append(result[i]); + if (i < result.Length - 1) + { + sb.Append(","); + } + } + + // close the messages array and the main object + sb.Append("]}"); + + // done here, return the JSON string + return sb.ToString(); + } + + throw new Exception("Prompt not found"); + } + } +} diff --git a/nanoFramework.WebServer.Mcp/McpServerController.cs b/nanoFramework.WebServer.Mcp/McpServerController.cs index ca8a929..89ba48e 100644 --- a/nanoFramework.WebServer.Mcp/McpServerController.cs +++ b/nanoFramework.WebServer.Mcp/McpServerController.cs @@ -120,11 +120,28 @@ public void HandleMcpRequest(WebServerEventArgs e) sb.Append($",\"result\":{{\"content\":[{{\"type\":\"text\",\"text\":{result}}}]}}}}"); } + else if (request["method"].ToString() == "prompts/list") + { + string promptListJson = McpPromptRegistry.GetPromptMetadataJson(); + sb.Append($",\"result\":{{{promptListJson}}}}}"); + } + else if (request["method"].ToString() == "prompts/get") + { + string promptName = ((Hashtable)request["params"])["name"].ToString(); + Hashtable arguments = ((Hashtable)request["params"])["arguments"] == null ? null : (Hashtable)((Hashtable)request["params"])["arguments"]; + + string result = McpPromptRegistry.InvokePrompt(promptName, arguments); + sb.Append($",\"result\":{result}}}"); + } else { sb.Append($",\"error\":{{\"code\":-32601,\"message\":\"Method not found\"}}}}"); } + Debug.WriteLine(); + Debug.WriteLine($"Response: {sb.ToString()}"); + Debug.WriteLine(); + WebServer.OutPutStream(e.Context.Response, sb.ToString()); return; } diff --git a/nanoFramework.WebServer.Mcp/McpServerPromptAttribute.cs b/nanoFramework.WebServer.Mcp/McpServerPromptAttribute.cs new file mode 100644 index 0000000..90ce396 --- /dev/null +++ b/nanoFramework.WebServer.Mcp/McpServerPromptAttribute.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Used to indicate that a method should be considered an . + /// + /// + /// + /// This attribute is applied to methods that should be exposed as prompts in the Model Context Protocol. When a class + /// containing methods marked with this attribute is registered with McpServerBuilderExtensions, + /// these methods become available as prompts that can be called by MCP clients. + /// + /// + [AttributeUsage(AttributeTargets.Method)] + public class McpServerPromptAttribute : Attribute + { + /// + /// Gets the name of the prompt. + /// + public string Name { get; } + + /// + /// Gets the description of the tool. + /// + public string Description { get; } + + /// + /// Initializes a new instance of the class with the specified name and description. + /// + /// The unique name of the prompt. + /// The description of the prompt. + public McpServerPromptAttribute(string name, string description = "") + { + Name = name; + Description = description; + } + } +} diff --git a/nanoFramework.WebServer.Mcp/McpToolRegistry.cs b/nanoFramework.WebServer.Mcp/McpToolRegistry.cs index 236918f..cf9a8f8 100644 --- a/nanoFramework.WebServer.Mcp/McpToolRegistry.cs +++ b/nanoFramework.WebServer.Mcp/McpToolRegistry.cs @@ -14,7 +14,7 @@ namespace nanoFramework.WebServer.Mcp /// /// Registry for Model Context Protocol (MCP) tools, allowing discovery and invocation of tools defined with the McpServerToolAttribute. /// - public static class McpToolRegistry + public class McpToolRegistry : RegistryBase { private static readonly Hashtable tools = new Hashtable(); private static bool isInitialized = false; @@ -113,196 +113,6 @@ public static string GetToolMetadataJson() } } - private static object CreateInstance(Type type) - { - // Get the default constructor - ConstructorInfo constructor = type.GetConstructor(new Type[0]); - if (constructor == null) - { - throw new Exception($"Type {type.Name} does not have a parameterless constructor"); - } - - return constructor.Invoke(new object[0]); - } - - /// - /// Converts a value to the specified primitive type with appropriate type conversion and error handling. - /// - /// The value to convert. - /// The target primitive type to convert to. - /// The converted value as the target type. - private static object ConvertToPrimitiveType(object value, Type targetType) - { - if (value == null) - { - return null; - } - - if (targetType == typeof(string)) - { - return value.ToString(); - } - else if (targetType == typeof(int)) - { - return Convert.ToInt32(value.ToString()); - } - else if (targetType == typeof(double)) - { - return Convert.ToDouble(value.ToString()); - } - else if (targetType == typeof(bool)) - { - // If it's a 0 or a 1 - if (value.ToString().Length == 1) - { - try - { - return Convert.ToBoolean(Convert.ToByte(value.ToString())); - } - catch (Exception) - { - // Nothing on purpose, we will handle it below - } - } - - // Then it's a tex - return value.ToString().ToLower() == "true"; - } - else if (targetType == typeof(long)) - { - return Convert.ToInt64(value.ToString()); - } - else if (targetType == typeof(float)) - { - return Convert.ToSingle(value.ToString()); - } - else if (targetType == typeof(byte)) - { - return Convert.ToByte(value.ToString()); - } - else if (targetType == typeof(short)) - { - return Convert.ToInt16(value.ToString()); - } - else if (targetType == typeof(char)) - { - try - { - return Convert.ToChar(Convert.ToUInt16(value.ToString())); - } - catch (Exception) - { - return string.IsNullOrEmpty(value.ToString()) ? '\0' : value.ToString()[0]; - } - } - else if (targetType == typeof(uint)) - { - return Convert.ToUInt32(value.ToString()); - } - else if (targetType == typeof(ulong)) - { - return Convert.ToUInt64(value.ToString()); - } - else if (targetType == typeof(ushort)) - { - return Convert.ToUInt16(value.ToString()); - } - else if (targetType == typeof(sbyte)) - { - return Convert.ToSByte(value.ToString()); - } - - // Fallback - return the original value - return value; - } - - /// - /// Recursively deserializes a Hashtable into a strongly-typed object by mapping properties and handling nested objects. - /// - /// The Hashtable containing the data to deserialize. - /// The target type to deserialize the data into. - /// A new instance of the target type with properties populated from the Hashtable, or null if hashtable or targetType is null. - private static object DeserializeFromHashtable(Hashtable hashtable, Type targetType) - { - if (hashtable == null || targetType == null) - { - return null; - } - - // For primitive types and strings, try direct conversion - if (McpToolJsonHelper.IsPrimitiveType(targetType) || targetType == typeof(string)) - { - // This shouldn't happen in our context, but handle it gracefully - return hashtable; - } - - // Create instance of the target type - object instance = CreateInstance(targetType); - - // Get all methods of the target type - MethodInfo[] methods = targetType.GetMethods(); - - // Find setter methods (set_PropertyName) - foreach (MethodInfo method in methods) - { - if (!method.Name.StartsWith("set_") || method.GetParameters().Length != 1) - { - continue; - } - - // Extract property name from setter method name - string propertyName = method.Name.Substring(4); // Remove "set_" prefix - - // Check if the hashtable contains this property - if (!hashtable.Contains(propertyName)) - { - continue; - } - - object value = hashtable[propertyName]; - if (value == null) - { - continue; - } - - try - { - // Get the parameter type of the setter method (which is the property type) - Type propertyType = method.GetParameters()[0].ParameterType; // Handle primitive types and strings - if (McpToolJsonHelper.IsPrimitiveType(propertyType) || propertyType == typeof(string)) - { - // Use the centralized conversion function - object convertedValue = ConvertToPrimitiveType(value, propertyType); - method.Invoke(instance, new object[] { convertedValue }); - } - else - { - // Handle complex types (nested objects) - if (value is string stringValue) - { - // The nested object is serialized as a JSON string - var nestedHashtable = (Hashtable)JsonConvert.DeserializeObject(stringValue, typeof(Hashtable)); - object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); - method.Invoke(instance, new object[] { nestedObject }); - } - else if (value is Hashtable nestedHashtable) - { - // The nested object is already a Hashtable - object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); - method.Invoke(instance, new object[] { nestedObject }); - } - } - } - catch (Exception) - { - // Skip properties that can't be set - continue; - } - } - - return instance; - } - /// /// Invokes a registered MCP tool by name with the specified parameters and returns the serialized result. /// diff --git a/nanoFramework.WebServer.Mcp/McpToolsJsonHelper.cs b/nanoFramework.WebServer.Mcp/McpToolsJsonHelper.cs index c06cc29..f08e400 100644 --- a/nanoFramework.WebServer.Mcp/McpToolsJsonHelper.cs +++ b/nanoFramework.WebServer.Mcp/McpToolsJsonHelper.cs @@ -35,7 +35,7 @@ public static string GenerateInputJson(Type inputType) /// Checks if the specified is a primitive type. /// /// The to check. - /// true if the type is a primitive type; otherwise, false. + /// true if the type is a primitive type; otherwise, false. public static bool IsPrimitiveType(Type type) { return type == typeof(bool) || diff --git a/nanoFramework.WebServer.Mcp/PromptMetadata.cs b/nanoFramework.WebServer.Mcp/PromptMetadata.cs new file mode 100644 index 0000000..d092c22 --- /dev/null +++ b/nanoFramework.WebServer.Mcp/PromptMetadata.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Represents metadata information for a prompt, including its name, description and associated method. + /// + public class PromptMetadata + { + /// + /// Gets or sets the unique name of the prompt. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the prompt. + /// + public string Description { get; set; } + + /// + /// Gets or sets JSON schema respresentniog the arguments for the prompt. + /// + public string Arguments { get; set; } + + /// + /// Gets or sets the representing the method associated with the prompt. + /// + public MethodInfo Method { get; set; } + + /// + /// Returns a JSON string representation of the prompt metadata. + /// + /// A JSON string containing the prompt's name, description schema. + public override string ToString() + { + string output = $"{{\"name\":\"{Name}\",\"description\":\"{Description}\""; + output += string.IsNullOrEmpty(Arguments) ? string.Empty : $",\"arguments\":[{Arguments}]"; + output += $"}}"; + + return output; + } + } +} diff --git a/nanoFramework.WebServer.Mcp/RegistryBase.cs b/nanoFramework.WebServer.Mcp/RegistryBase.cs new file mode 100644 index 0000000..1a26a12 --- /dev/null +++ b/nanoFramework.WebServer.Mcp/RegistryBase.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections; +using System.Reflection; +using System.Text; +using nanoFramework.Json; + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Base class for registries that support conversion and deserialization of objects. + /// + public abstract class RegistryBase + { + /// + /// Converts a value to the specified primitive type with appropriate type conversion and error handling. + /// + /// The value to convert. + /// The target primitive type to convert to. + /// The converted value as the target type. + protected static object ConvertToPrimitiveType(object value, Type targetType) + { + if (value == null) + { + return null; + } + + if (targetType == typeof(string)) + { + return value.ToString(); + } + else if (targetType == typeof(int)) + { + return Convert.ToInt32(value.ToString()); + } + else if (targetType == typeof(double)) + { + return Convert.ToDouble(value.ToString()); + } + else if (targetType == typeof(bool)) + { + if (value.ToString().Length == 1) + { + try + { + return Convert.ToBoolean(Convert.ToByte(value.ToString())); + } + catch (Exception) + { + } + } + return value.ToString().ToLower() == "true"; + } + else if (targetType == typeof(long)) + { + return Convert.ToInt64(value.ToString()); + } + else if (targetType == typeof(float)) + { + return Convert.ToSingle(value.ToString()); + } + else if (targetType == typeof(byte)) + { + return Convert.ToByte(value.ToString()); + } + else if (targetType == typeof(short)) + { + return Convert.ToInt16(value.ToString()); + } + else if (targetType == typeof(char)) + { + try + { + return Convert.ToChar(Convert.ToUInt16(value.ToString())); + } + catch (Exception) + { + return string.IsNullOrEmpty(value.ToString()) ? '\0' : value.ToString()[0]; + } + } + else if (targetType == typeof(uint)) + { + return Convert.ToUInt32(value.ToString()); + } + else if (targetType == typeof(ulong)) + { + return Convert.ToUInt64(value.ToString()); + } + else if (targetType == typeof(ushort)) + { + return Convert.ToUInt16(value.ToString()); + } + else if (targetType == typeof(sbyte)) + { + return Convert.ToSByte(value.ToString()); + } + + return value; + } + + /// + /// Recursively deserializes a Hashtable into a strongly-typed object by mapping properties and handling nested objects. + /// + /// The Hashtable containing the data to deserialize. + /// The target type to deserialize the data into. + /// A new instance of the target type with properties populated from the Hashtable, or null if hashtable or targetType is null. + protected static object DeserializeFromHashtable(Hashtable hashtable, Type targetType) + { + if (hashtable == null || targetType == null) + { + return null; + } + + if (McpToolJsonHelper.IsPrimitiveType(targetType) || targetType == typeof(string)) + { + return hashtable; + } + + object instance = CreateInstance(targetType); + + MethodInfo[] methods = targetType.GetMethods(); + + foreach (MethodInfo method in methods) + { + if (!method.Name.StartsWith("set_") || method.GetParameters().Length != 1) + { + continue; + } + + string propertyName = method.Name.Substring(4); + + if (!hashtable.Contains(propertyName)) + { + continue; + } + + object value = hashtable[propertyName]; + if (value == null) + { + continue; + } + + try + { + Type propertyType = method.GetParameters()[0].ParameterType; + if (McpToolJsonHelper.IsPrimitiveType(propertyType) || propertyType == typeof(string)) + { + object convertedValue = ConvertToPrimitiveType(value, propertyType); + method.Invoke(instance, new object[] { convertedValue }); + } + else + { + if (value is string stringValue) + { + var nestedHashtable = (Hashtable)JsonConvert.DeserializeObject(stringValue, typeof(Hashtable)); + object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); + method.Invoke(instance, new object[] { nestedObject }); + } + else if (value is Hashtable nestedHashtable) + { + object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); + method.Invoke(instance, new object[] { nestedObject }); + } + } + } + catch (Exception) + { + continue; + } + } + + return instance; + } + + /// + /// Creates an instance of a tool or prompt method using its parameterless constructor. + /// + /// The type of the tool or prompt method to create an instance of. + /// The result of the method invocation. + /// Thrown when the type does not have a parameterless constructor. + protected static object CreateInstance(Type type) + { + ConstructorInfo constructor = type.GetConstructor(new Type[0]); + if (constructor == null) + { + throw new Exception($"Type {type.Name} does not have a parameterless constructor"); + } + return constructor.Invoke(new object[0]); + } + } +} diff --git a/nanoFramework.WebServer.Mcp/Role.cs b/nanoFramework.WebServer.Mcp/Role.cs new file mode 100644 index 0000000..fda6f0e --- /dev/null +++ b/nanoFramework.WebServer.Mcp/Role.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace nanoFramework.WebServer.Mcp +{ + /// + /// Represents the type of role in the Model Context Protocol conversation. + /// + public enum Role + { + /// + /// Corresponds to a human user in the conversation. + /// + User, + + /// + /// Corresponds to the AI assistant in the conversation. + /// + Assistant + } +} diff --git a/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj b/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj index 373655d..ba90555 100644 --- a/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj +++ b/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj @@ -32,13 +32,20 @@ + + + + + + + diff --git a/tests/McpClientTest/McpClientTest.cs b/tests/McpClientTest/McpClientTest.cs index 5767668..c957e74 100644 --- a/tests/McpClientTest/McpClientTest.cs +++ b/tests/McpClientTest/McpClientTest.cs @@ -46,7 +46,14 @@ // Load them as AI functions in the kernel #pragma warning disable SKEXP0001 kernel.Plugins.AddFromFunctions("nanoFramework", tools.Select(aiFunction => aiFunction.AsKernelFunction())); -// -- + +// Check available prompts +var prompts = await mcpToolboxClient.ListPromptsAsync().ConfigureAwait(false); + +// Print those prompts +Console.WriteLine("// Available prompts:"); +foreach (var p in prompts) Console.WriteLine($"{p.Name}: {p.Description}"); +Console.WriteLine("// --"); var history = new ChatHistory(); var chatCompletionService = kernel.GetRequiredService(); diff --git a/tests/McpEndToEndTest/McpEndToEndTest.nfproj b/tests/McpEndToEndTest/McpEndToEndTest.nfproj index d3a69b4..75a5728 100644 --- a/tests/McpEndToEndTest/McpEndToEndTest.nfproj +++ b/tests/McpEndToEndTest/McpEndToEndTest.nfproj @@ -18,6 +18,7 @@ + diff --git a/tests/McpEndToEndTest/McpPromptsClasses.cs b/tests/McpEndToEndTest/McpPromptsClasses.cs new file mode 100644 index 0000000..2e2a120 --- /dev/null +++ b/tests/McpEndToEndTest/McpPromptsClasses.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.WebServer.Mcp; + +namespace McpServerTests +{ + public class McpPrompts + { + [McpServerPrompt("tools_discovery", "Discover all available tools")] + public static PromptMessage[] ToolsDiscovery() + { + return new PromptMessage[] + { + new PromptMessage("List all available tools and their method signatures: Echo, SuperMath, ProcessPerson, GetDefaultAddress, GetDefaultPerson. Show parameter names/types and return types.") + }; + } + + [McpServerPrompt("echo_sanity_check", "Another test prompt")] + public static PromptMessage[] AnotherSimplePrompt() + { + return new PromptMessage[] + { + new PromptMessage("Call Echo with the string 'Hello MCP world!' and return the response.") + }; + } + + [McpServerPrompt("supermaty_basic_usage", "Demonstrate basic usage of SuperMath tool")] + public static PromptMessage[] SuperMathBasicUsage() + { + return new PromptMessage[] + { + new PromptMessage("Run SuperMath to multiply 56 x 78. If the tool fails or returns nothing, indicate the failure; otherwise, report the result.") + }; + } + + [McpServerPrompt("process_person_usage", "Demonstrate usage of ProcessPerson tool")] + public static PromptMessage[] ProcessPersonUsage() + { + return new PromptMessage[] + { + new PromptMessage("Create a Person object with Name: 'Alice', Surname: 'Smith', Age: '25', Address: { Street: '456 Elm St', City: 'Springfield', PostalCode: '67890', Country: 'USA' }. Then call ProcessPerson with this object and return the response.") + }; + } + + [McpServerPrompt("processperson_workflow", "Demonstrate a workflow using ProcessPerson tool")] + public static PromptMessage[] ProcessPersonWorkflow() + { + return new PromptMessage[] + { + new PromptMessage("Call GetDefaultPerson, then pass its output into ProcessPerson. Return both the initial and processed person object.") + }; + } + + [McpServerPrompt("get_default_address_integration", "Demonstrate integration with GetDefaultAddress tool")] + public static PromptMessage[] GetDefaultAddressIntegration() + { + return new PromptMessage[] + { + new PromptMessage("First call GetDefaultPerson, then call GetDefaultAddress. Combine both into a summary like: 'Person X lives at Y'.") + }; + } + + [McpServerPrompt("high_level_agent_prompt", "Adds logic and semantic intelligence on top of tools")] + public static PromptMessage[] HighLevelAgentPrompt() + { + return new PromptMessage[] + { + new PromptMessage("You're a data-summary agent. Fetch the default person and address, then produce a human -readable summary. If age > 30, add '(senior)', otherwise '(junior)' at the end.") + }; + } + + [McpServerPrompt("confirmation_flow", "Tests conversational context, user confirmation, and conditional logic")] + public static PromptMessage[] ConfirmationFlow() + { + return new PromptMessage[] + { + new PromptMessage("Before calling ProcessPerson, ask: 'Do you want to process the default person? [yes/no]'. If user says yes, proceed; else return 'Operation canceled.'") + }; + } + + [McpServerPrompt("summarize_person", "Fetches person and address, processes the person, uses ageThreshold to label as junior or senior.")] + [McpPromptParameter("ageThreshold", "The age threshold to determine if the person is a senior or junior.")] + public static PromptMessage[] SummarizePerson(string ageThreshold) + { + return new PromptMessage[] + { + new PromptMessage($"Please perform the following steps:\r\n1. Call GetDefaultPerson() -> person.\r\n2. Call GetDefaultAddress() -> address.\r\n3. If person.Age > {ageThreshold} then set label = \"senior\"; otherwise set label = \"junior\".\r\n4. Call ProcessPerson(person) -> processed.\r\n5. Return a JSON object:\r\n{{ \"name\": person.Name, \"age\": person.Age, \"label\": label, \"address\": address, \"processed\": processed }}") + }; + } + } +} diff --git a/tests/McpEndToEndTest/Program.cs b/tests/McpEndToEndTest/Program.cs index 8871e25..0deb53e 100644 --- a/tests/McpEndToEndTest/Program.cs +++ b/tests/McpEndToEndTest/Program.cs @@ -30,9 +30,11 @@ public static void Main() Debug.WriteLine($"Connected with wifi credentials. IP Address: {GetCurrentIPAddress()}"); McpToolRegistry.DiscoverTools(new Type[] { typeof(McpServerTests.McpTools) }); - Debug.WriteLine("MCP Tools discovered and registered."); + McpPromptRegistry.DiscoverPrompts(new Type[] { typeof(McpServerTests.McpPrompts) }); + Debug.WriteLine("MCP Prompts discovered and registered."); + _server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(McpServerController) }); _server.CommandReceived += ServerCommandReceived; // Start the server. diff --git a/tests/McpServerTests/McpPromptRegistryTests.cs b/tests/McpServerTests/McpPromptRegistryTests.cs new file mode 100644 index 0000000..72db257 --- /dev/null +++ b/tests/McpServerTests/McpPromptRegistryTests.cs @@ -0,0 +1,267 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using nanoFramework.TestFramework; +using nanoFramework.WebServer.Mcp; + +namespace McpServerTests +{ + public class PromptParameter + { + public string Name { get; set; } + public int Value { get; set; } + } + + // Test prompt classes with MCP prompts + public static class TestPromptsClass + { + [McpServerPrompt("simple_prompt", "A simple prompt description")] + public static PromptMessage[] SimplePrompt() + { + return new[] { + new PromptMessage("This is a prompt example") + }; + } + + [McpServerPrompt("anohter_simple_prompt", "Another prompt description")] + public static PromptMessage[] AnotherSimplePrompt() + { + return new[] { + new PromptMessage("This is another prompt example") + }; + } + + [McpServerPrompt("prompt_with_string_parameter", "A prompt with string parameter")] + [McpPromptParameter("input", "Input string parameter")] + public static PromptMessage[] PromptWithStringParameter(string input) + { + return new[] { + new PromptMessage($"Received input: {input}") + }; + } + + [McpServerPrompt("prompt_with_complex_parameter", "A prompt with complex parameter")] + [McpPromptParameter("Name", "Name of the person")] + [McpPromptParameter("Value", "Value associated with the name")] + public static PromptMessage[] PromptWithComplexParameter(string name, string value) + { + return new[] { + new PromptMessage($"Received complex parameter: {name} with value {value}") + }; + } + + [McpServerPrompt("dynamic_prompt_without_param", "A dynamic prompt without parameters")] + public static PromptMessage[] DynamicPromptWithoutParam() + { + return new[] { + new PromptMessage("This is a dynamic prompt without parameters"), + new PromptMessage("It can return multiple messages"), + new PromptMessage("This is useful for complex prompts") + }; + } + + [McpServerPrompt("dynamic_prompt_with_param", "A dynamic prompt with parameters")] + [McpPromptParameter("value", "Input value for dynamic prompt")] + public static PromptMessage[] DynamicPromptWithParam(string value) + { + return new[] { + new PromptMessage("Analyze these system logs and the code file for any issues"), + new PromptMessage($"Consider the input value: {value}"){ Role = Role.Assistant } + }; + } + } + + [TestClass] + public class McpPromptRegistryTests + { + [TestMethod] + public void TestDiscoverPromtpsAndGetMetadataSimple() + { + // Arrange + Type[] toolTypes = new Type[] { typeof(TestPromptsClass) }; + + // Act + McpPromptRegistry.DiscoverPrompts(toolTypes); + string metadataJson = McpPromptRegistry.GetPromptMetadataJson(); + + // Assert + Assert.IsNotNull(metadataJson, "Metadata JSON should not be null"); + + // Assert + Assert.IsNotNull(metadataJson, "Metadata JSON should not be null"); + Assert.IsTrue(metadataJson.Contains("simple_prompt"), "Metadata should contain simple_prompt"); + Assert.IsTrue(metadataJson.Contains("anohter_simple_prompt"), "Metadata should contain anohter_simple_prompt"); + } + + [TestMethod] + public void TestGetMetadataJsonStructure() + { + // Arrange + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + + // Act + McpPromptRegistry.DiscoverPrompts(promptTypes); + string metadataJson = McpPromptRegistry.GetPromptMetadataJson(); + + // Assert + Assert.IsNotNull(metadataJson, "Metadata JSON should not be null"); + + // Check overall structure + Assert.IsTrue(metadataJson.StartsWith("\"prompts\":["), "Metadata should start with prompts array"); + Assert.IsTrue(metadataJson.EndsWith("],\"nextCursor\":null"), "Metadata should end with nextCursor"); + + // Check for prompts properties + Assert.IsTrue(metadataJson.Contains("\"name\":"), "Metadata should contain prompts names"); + Assert.IsTrue(metadataJson.Contains("\"description\":"), "Metadata should contain prompts descriptions"); + + // Check specific tool content + Assert.IsTrue(metadataJson.Contains("A simple prompt description"), $"Metadata should contain simple prompt description. Got: '{metadataJson}'"); + Assert.IsTrue(metadataJson.Contains("Another prompt description"), $"Metadata should contain another prompt description. Got: '{metadataJson}'"); + } + + [TestMethod] + public void TestDiscoverToolsCalledMultipleTimes() + { + // Arrange + Type[] promptTypes1 = new Type[] { typeof(TestPromptsClass) }; + Type[] promptTypes2 = new Type[] { typeof(TestPromptsClass) }; + + // Act - Call DiscoverTools multiple times + McpPromptRegistry.DiscoverPrompts(promptTypes1); + string firstCall = McpPromptRegistry.GetPromptMetadataJson(); + + // Should be ignored as already initialized + McpPromptRegistry.DiscoverPrompts(promptTypes2); + string secondCall = McpPromptRegistry.GetPromptMetadataJson(); + + // Assert + Assert.AreEqual(firstCall, secondCall, "Multiple calls to DiscoverPrompts should not change the result"); + Assert.IsTrue(firstCall.Contains("simple_prompt"), "Should still contain prompt from first discovery"); + } + + [TestMethod] + public void TestGetMetadataJsonEmptyRegistry() + { + // Note: This test might not work in isolation due to static nature + // But it tests the exception handling + try + { + // This should work even with empty tools if the registry has been initialized + string metadataJson = McpPromptRegistry.GetPromptMetadataJson(); + Assert.IsNotNull(metadataJson, "Metadata JSON should not be null even for empty registry"); + } + catch (Exception ex) + { + Assert.AreEqual("Impossible to build tools list.", ex.Message, "Should throw expected exception for empty tools"); + } + } + + // Tests for InvokePrompt function + [TestMethod] + public void TestInvokePromptSimple() + { + // Arrange - Simulate how HandleMcpRequest creates the Hashtable for simple types + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + McpPromptRegistry.DiscoverPrompts(promptTypes); + + // Act + string result = McpPromptRegistry.InvokePrompt("simple_prompt", null); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("This is a prompt example"), "Result should contain 'This is a prompt example'"); + } + + [TestMethod] + public void TestInvokePromptWithStringParameter() + { + // Arrange + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + McpPromptRegistry.DiscoverPrompts(promptTypes); + + // Create arguments Hashtable + var arguments = new Hashtable(); + arguments["value"] = "Test input"; + + // Act + string result = McpPromptRegistry.InvokePrompt("prompt_with_string_parameter", arguments); + + // debug purposes only + OutputHelper.WriteLine($">>>{result}<<<"); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("Received input: Test input"), "Result should contain 'Received input: Test input'"); + } + + [TestMethod] + public void TestInvokePromptWithComplexParameter() + { + // Arrange + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + McpPromptRegistry.DiscoverPrompts(promptTypes); + + // Create arguments Hashtable with complex parameter + var arguments = new Hashtable(); + arguments.Add("Name", "John"); + arguments.Add("Value", "100"); + + // Act + string result = McpPromptRegistry.InvokePrompt("prompt_with_complex_parameter", arguments); + + // debug purposes only + OutputHelper.WriteLine($">>>{result}<<<"); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("Received complex parameter: John with value 100"), "Result should contain 'Received complex parameter: Test with value 42'"); + } + + [TestMethod] + public void TestInvokeDynamicPromptWithoutParam() + { + // Arrange + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + McpPromptRegistry.DiscoverPrompts(promptTypes); + + // Act + string result = McpPromptRegistry.InvokePrompt("dynamic_prompt_without_param", null); + + // debug purposes only + OutputHelper.WriteLine($">>>{result}<<<"); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("This is a dynamic prompt without parameters"), "Result should contain 'This is a dynamic prompt without parameters'"); + Assert.IsTrue(result.Contains("It can return multiple messages"), "Result should contain 'It can return multiple messages'"); + Assert.IsTrue(result.Contains("\"role\":\"user\""), "Result should contain role 'user' in the message"); + Assert.IsFalse(result.Contains("\"role\":\"assistant\""), "Result should not contain role 'assistant' in the message"); + } + + [TestMethod] + public void TestInvokeDynamicPromptWithParam() + { + // Arrange + Type[] promptTypes = new Type[] { typeof(TestPromptsClass) }; + McpPromptRegistry.DiscoverPrompts(promptTypes); + + // Create arguments Hashtable with input parameter + var arguments = new Hashtable(); + arguments["value"] = "Test input for dynamic prompt"; + + // Act + string result = McpPromptRegistry.InvokePrompt("dynamic_prompt_with_param", arguments); + + // debug purposes only + OutputHelper.WriteLine($">>>{result}<<<"); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("Analyze these system logs and the code file for any issues"), "Result should contain analysis message"); + Assert.IsTrue(result.Contains("Consider the input value: Test input for dynamic prompt"), "Result should contain input value"); + Assert.IsTrue(result.Contains("\"role\":\"assistant\""), "Result should contain role 'assistant' in the message"); + } + } +} diff --git a/tests/McpServerTests/McpServerTests.nfproj b/tests/McpServerTests/McpServerTests.nfproj index 6e54e28..4ebdce0 100644 --- a/tests/McpServerTests/McpServerTests.nfproj +++ b/tests/McpServerTests/McpServerTests.nfproj @@ -27,6 +27,7 @@ $(MSBuildProjectDirectory)\nano.runsettings + From e64bc4e66b0af6466adbe282cb9007110f839a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Fri, 25 Jul 2025 11:58:48 +0100 Subject: [PATCH 2/6] Update README and MCP doc --- README.md | 54 +++++++--- doc/model-context-protocol.md | 190 +++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0e7a4f8..9291a68 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nanoframework_lib-nanoframework.WebServer&metric=alert_status)](https://sonarcloud.io/dashboard?id=nanoframework_lib-nanoframework.WebServer) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=nanoframework_lib-nanoframework.WebServer&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=nanoframework_lib-nanoframework.WebServer) [![NuGet](https://img.shields.io/nuget/dt/nanoFramework.WebServer.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer/) [![#yourfirstpr](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://github.com/nanoframework/Home/blob/main/CONTRIBUTING.md) [![Discord](https://img.shields.io/discord/478725473862549535.svg?logo=discord&logoColor=white&label=Discord&color=7289DA)](https://discord.gg/gCyBu8T) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nanoframework_lib-nanoframework.WebServer&metric=alert_status)](https://sonarcloud.io/dashboard?id=nanoframework_lib-nanoframework.WebServer) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=nanoframework_lib-nanoframework.WebServer&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=nanoframework_lib-nanoframework.WebServer) [![NuGet](https://img.shields.io/nuget/dt/nanoFramework.WebServer.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer/) [![#yourfirstpr](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://github.com/nanoframework/Home/blob/main/CONTRIBUTING.md) [![Discord](https://img.shields.io/discord/478725473862549535.svg?logo=discord&logoColor=white&label=Discord&color=7289DA)](https://discord.gg/gCyBu8T) ![nanoFramework logo](https://raw.githubusercontent.com/nanoframework/Home/main/resources/logo/nanoFramework-repo-logo.png) @@ -35,7 +35,7 @@ This library provides a lightweight, multi-threaded HTTP/HTTPS WebServer for .NE Using the Web Server is very straight forward and supports event based calls. -```csharp +'''csharp // You need to be connected to a wifi or ethernet connection with a proper IP Address using (WebServer server = new WebServer(80, HttpProtocol.Http)) @@ -56,13 +56,13 @@ private static void ServerCommandReceived(object source, WebServerEventArgs e) WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.NotFound); } } -``` +''' ### Controller-Based WebServer -Controllers are supported including with parametarized routes like `api/led/{id}/dosomething/{order}`. +Controllers are supported including with parametarized routes like 'api/led/{id}/dosomething/{order}'. -```csharp +'''csharp using (WebServer server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(MyController) })) { server.Start(); @@ -86,7 +86,7 @@ public class MyController WebServer.OutPutStream(e.Context.Response, $"You selected Led {ledId}!"); } } -``` +''' ## Model Context Protocol (MCP) Support @@ -94,7 +94,7 @@ Enable AI agents to interact with your embedded devices through standardized too ### Defining MCP Tools -```csharp +'''csharp public class IoTTools { [McpServerTool("read_sensor", "Reads temperature from sensor")] @@ -117,11 +117,35 @@ public class LedCommand [Description("LED state: on, off, or blink")] public string State { get; set; } } +''' + +### Defining MCP Prompts + +You can define reusable, high-level prompts for AI agents using the `McpServerPrompt` attribute. Prompts encapsulate multi-step instructions or workflows that can be invoked by agents. + +Here's a simple example: + +```csharp +using nanoFramework.WebServer.Mcp; + +public class McpPrompts +{ + [McpServerPrompt("echo_sanity_check", "Echo test prompt")] + public static PromptMessage[] EchoSanityCheck() + { + return new PromptMessage[] + { + new PromptMessage("Call Echo with the string 'Hello MCP world!' and return the response.") + }; + } +} ``` +Prompts can be discovered and invoked by AI agents in the same way as tools. You can also define prompts with parameters using the `McpPromptParameter` attribute. + ### Setting Up MCP Server -```csharp +'''csharp public static void Main() { // Connect to WiFi first @@ -129,6 +153,9 @@ public static void Main() // Discover and register MCP tools McpToolRegistry.DiscoverTools(new Type[] { typeof(IoTTools) }); + + // Discover and register MCP prompts + McpPromptRegistry.DiscoverPrompts(new Type[] { typeof(McpPrompts) }); // Start WebServer with MCP support using (var server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(McpServerController) })) @@ -141,13 +168,13 @@ public static void Main() Thread.Sleep(Timeout.Infinite); } } -``` +''' ### AI Agent Integration Once running, AI agents can discover and invoke your tools: -```json +'''json // Tool discovery POST /mcp { @@ -167,7 +194,7 @@ POST /mcp }, "id": 2 } -``` +''' ## Documentation @@ -187,11 +214,12 @@ POST /mcp - No compression support in request/response streams - MCP implementation supports server features only (no notifications or SSE) - No or single parameter limitation for MCP tools (use complex objects for multiple parameters) +- Prompt parameters, when declared, are always mandatory. ## Installation -Install `nanoFramework.WebServer` for the Web Server without File System support. Install `nanoFramework.WebServer.FileSystem` for file serving, so with devices supporting File System. -Install `nanoFramework.WebServer.Mcp` for MCP support. It does contains the full `nanoFramework.WebServer` but does not include native file serving. You can add this feature fairly easilly by reusing the code function serving it. +Install 'nanoFramework.WebServer' for the Web Server without File System support. Install 'nanoFramework.WebServer.FileSystem' for file serving, so with devices supporting File System. +Install 'nanoFramework.WebServer.Mcp' for MCP support. It does contains the full 'nanoFramework.WebServer' but does not include native file serving. You can add this feature fairly easilly by reusing the code function serving it. ## Contributing diff --git a/doc/model-context-protocol.md b/doc/model-context-protocol.md index 9f65d75..8ba82f8 100644 --- a/doc/model-context-protocol.md +++ b/doc/model-context-protocol.md @@ -27,7 +27,8 @@ The Model Context Protocol (MCP) is an open standard that enables seamless integ ### Key Features -- **Automatic tool discovery** through reflection and attributes +- **Automatic tool and prompt discovery** through reflection and attributes +- **MCP Prompts**: Define reusable, high-level prompt workflows for AI agents, with support for parameters - **JSON-RPC 2.0 compliant** request/response handling - **Type-safe parameter handling** with automatic deserialization from JSON to .NET objects - **Flexible authentication** options (none, basic auth, API key) @@ -35,6 +36,41 @@ The Model Context Protocol (MCP) is an open standard that enables seamless integ - **Robust error handling** and validation - **Memory efficient** implementation optimized for embedded devices - **HTTPS support** with SSL/TLS encryption +## Defining MCP Prompts + +MCP Prompts allow you to define reusable, multi-step instructions or workflows that can be invoked by AI agents. Prompts are discovered and registered similarly to tools, using the `[McpServerPrompt]` attribute on static methods that return an array of `PromptMessage`. + +Prompts can encapsulate complex logic, multi-step flows, or provide high-level instructions for agents. You can also define parameters for prompts using the `[McpPromptParameter]` attribute. **All parameters defined for a prompt are mandatory.** + +### Example: Defining a Prompt + +```csharp +using nanoFramework.WebServer.Mcp; + +public class McpPrompts +{ + [McpServerPrompt("echo_sanity_check", "Echo test prompt")] + public static PromptMessage[] EchoSanityCheck() + { + return new PromptMessage[] + { + new PromptMessage("Call Echo with the string 'Hello MCP world!' and return the response.") + }; + } + + [McpServerPrompt("summarize_person", "Summarize a person with age threshold")] + [McpPromptParameter("ageThreshold", "The age threshold to determine if the person is a senior or junior.")] + public static PromptMessage[] SummarizePerson(string ageThreshold) + { + return new PromptMessage[] + { + new PromptMessage($"Call GetDefaultPerson, then if person.Age > {ageThreshold} label as senior, else junior.") + }; + } +} +``` + +Prompts are listed and invoked via the MCP protocol, just like tools. ### Supported Version @@ -309,6 +345,11 @@ public static void Main() typeof(SensorTools) }); + // Discover and register prompts + McpPromptRegistry.DiscoverPrompts(new Type[] { + typeof(McpPrompts) + }); + // Step 3: Start WebServer with MCP support using (var server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(McpServerController) })) { @@ -482,7 +523,8 @@ POST /mcp } ``` -### 2. Tool Discovery + +### 2. Tool and Prompt Discovery Agent discovers available tools: @@ -495,7 +537,18 @@ POST /mcp } ``` -The response will be the list of the tools. See next section for detailed examples. +And prompts: + +```json +POST /mcp +{ + "jsonrpc": "2.0", + "method": "prompts/list", + "id": 2 +} +``` + +There will be responses for tools and prompts. See next section for detailed examples. ### 3. Tool Invocation @@ -519,7 +572,8 @@ POST /mcp ## Request/Response Examples -This section shows real exampled of requests and responses. +This section shows real examples of requests and responses. + ### Tool Discovery @@ -527,7 +581,6 @@ This section shows real exampled of requests and responses. ```json POST /mcp - { "jsonrpc": "2.0", "method": "tools/list", @@ -611,6 +664,61 @@ POST /mcp } ``` +### Prompt Discovery + +**Request:** + +```json +POST /mcp +{ + "jsonrpc": "2.0", + "method": "prompts/list", + "id": 1 +} +``` + +**Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "prompts": [ + { + "name": "echo_sanity_check", + "description": "Echo test prompt", + "parameters": [], + "messages": [ + { + "role": "system", + "content": "Call Echo with the string 'Hello MCP world!' and return the response." + } + ] + }, + { + "name": "summarize_person", + "description": "Summarize a person with age threshold", + "parameters": [ + { + "name": "ageThreshold", + "description": "The age threshold to determine if the person is a senior or junior.", + "type": "string" + } + ], + "messages": [ + { + "role": "system", + "content": "Call GetDefaultPerson, then if person.Age > {ageThreshold} label as senior, else junior." + } + ] + } + ], + "nextCursor": null + } +} +``` + ### Simple Tool Invocation **Request:** @@ -714,6 +822,78 @@ POST /mcp } ``` +### Prompt usage + +#### Prompt Retrieval Example + +To retrieve a prompt, use the `prompts/get` method. Provide the prompt name and any required parameters. + +**Request:** + +```json +POST /mcp +{ + "jsonrpc": "2.0", + "method": "prompts/get", + "params": { + "name": "echo_sanity_check", + "arguments": {} + }, + "id": 5 +} +``` + +**Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "messages": [ + { + "role": "system", + "content": "Call Echo with the string 'Hello MCP world!' and return the response." + } + ] + } +} +``` + +If the prompt requires parameters, include them in the `arguments` object: + +```json +POST /mcp +{ + "jsonrpc": "2.0", + "method": "prompts/get", + "params": { + "name": "summarize_person", + "arguments": { + "ageThreshold": "65" + } + }, + "id": 6 +} +``` + +**Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "messages": [ + { + "role": "system", + "content": "Call GetDefaultPerson, then if person.Age > 65 label as senior, else junior." + } + ] + } +} +``` + ## Error Handling The .NET nanoFramework MCP Server knows how to handle properly errors. The following will show examples of request and error responses. From 532724935210412d425125bfed4bdaddf9765be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Mon, 4 Aug 2025 12:20:26 +0100 Subject: [PATCH 3/6] Fix code blocks markup --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9291a68..fe2d810 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This library provides a lightweight, multi-threaded HTTP/HTTPS WebServer for .NE Using the Web Server is very straight forward and supports event based calls. -'''csharp +```csharp // You need to be connected to a wifi or ethernet connection with a proper IP Address using (WebServer server = new WebServer(80, HttpProtocol.Http)) @@ -56,13 +56,13 @@ private static void ServerCommandReceived(object source, WebServerEventArgs e) WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.NotFound); } } -''' +``` ### Controller-Based WebServer Controllers are supported including with parametarized routes like 'api/led/{id}/dosomething/{order}'. -'''csharp +```csharp using (WebServer server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(MyController) })) { server.Start(); @@ -86,7 +86,7 @@ public class MyController WebServer.OutPutStream(e.Context.Response, $"You selected Led {ledId}!"); } } -''' +``` ## Model Context Protocol (MCP) Support @@ -94,7 +94,7 @@ Enable AI agents to interact with your embedded devices through standardized too ### Defining MCP Tools -'''csharp +```csharp public class IoTTools { [McpServerTool("read_sensor", "Reads temperature from sensor")] @@ -117,7 +117,7 @@ public class LedCommand [Description("LED state: on, off, or blink")] public string State { get; set; } } -''' +``` ### Defining MCP Prompts @@ -145,7 +145,7 @@ Prompts can be discovered and invoked by AI agents in the same way as tools. You ### Setting Up MCP Server -'''csharp +```csharp public static void Main() { // Connect to WiFi first @@ -168,13 +168,13 @@ public static void Main() Thread.Sleep(Timeout.Infinite); } } -''' +``` ### AI Agent Integration Once running, AI agents can discover and invoke your tools: -'''json +```json // Tool discovery POST /mcp { @@ -194,7 +194,7 @@ POST /mcp }, "id": 2 } -''' +``` ## Documentation From 6dae0a532bcd7ee4e1046ef9b22c77e9eb3095fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Mon, 4 Aug 2025 14:34:40 +0100 Subject: [PATCH 4/6] Fix prompt parameter and registry --- nanoFramework.WebServer.Mcp/McpPromptParameter.cs | 2 +- nanoFramework.WebServer.Mcp/McpPromptRegistry.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanoFramework.WebServer.Mcp/McpPromptParameter.cs b/nanoFramework.WebServer.Mcp/McpPromptParameter.cs index 6da47ac..7ce6215 100644 --- a/nanoFramework.WebServer.Mcp/McpPromptParameter.cs +++ b/nanoFramework.WebServer.Mcp/McpPromptParameter.cs @@ -46,7 +46,7 @@ public McpPromptParameterAttribute(string name, string description) /// public override string ToString() { - return $"{{\"name\":\"{Name}\",\"description\":\"{Description}\",\"required\":\"true\"}}"; + return $"{{\"name\":\"{Name}\",\"description\":\"{Description}\",\"required\":true}}"; } } } diff --git a/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs b/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs index 708b36b..dca4bb1 100644 --- a/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs +++ b/nanoFramework.WebServer.Mcp/McpPromptRegistry.cs @@ -93,7 +93,7 @@ private static string ComposeArgumentsAsJson(object[] attributes) { sb.Append(isFirst ? "" : ","); - sb.Append($"{{{parameterNameAttribute}}}"); + sb.Append($"{parameterNameAttribute}"); isFirst = false; } From cab0e6e78c83280eceed49672c6ad62b6a0df9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Mon, 4 Aug 2025 14:35:15 +0100 Subject: [PATCH 5/6] Replace function names with registered tools --- tests/McpEndToEndTest/McpPromptsClasses.cs | 27 +++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/McpEndToEndTest/McpPromptsClasses.cs b/tests/McpEndToEndTest/McpPromptsClasses.cs index 2e2a120..9751820 100644 --- a/tests/McpEndToEndTest/McpPromptsClasses.cs +++ b/tests/McpEndToEndTest/McpPromptsClasses.cs @@ -12,7 +12,7 @@ public static PromptMessage[] ToolsDiscovery() { return new PromptMessage[] { - new PromptMessage("List all available tools and their method signatures: Echo, SuperMath, ProcessPerson, GetDefaultAddress, GetDefaultPerson. Show parameter names/types and return types.") + new PromptMessage("List all available tools and their method signatures: echo, super_math, process_person, get_default_address, get_default_person. Show parameter names/types and return types.") }; } @@ -21,16 +21,16 @@ public static PromptMessage[] AnotherSimplePrompt() { return new PromptMessage[] { - new PromptMessage("Call Echo with the string 'Hello MCP world!' and return the response.") + new PromptMessage("Call echo with the string 'Hello MCP world!' and return the response.") }; } - [McpServerPrompt("supermaty_basic_usage", "Demonstrate basic usage of SuperMath tool")] + [McpServerPrompt("supermaty_basic_usage", "Demonstrate basic usage of super_math tool")] public static PromptMessage[] SuperMathBasicUsage() { return new PromptMessage[] { - new PromptMessage("Run SuperMath to multiply 56 x 78. If the tool fails or returns nothing, indicate the failure; otherwise, report the result.") + new PromptMessage("Run super_math to multiply 56 x 78. If the tool fails or returns nothing, indicate the failure; otherwise, report the result.") }; } @@ -39,25 +39,25 @@ public static PromptMessage[] ProcessPersonUsage() { return new PromptMessage[] { - new PromptMessage("Create a Person object with Name: 'Alice', Surname: 'Smith', Age: '25', Address: { Street: '456 Elm St', City: 'Springfield', PostalCode: '67890', Country: 'USA' }. Then call ProcessPerson with this object and return the response.") + new PromptMessage("Create a Person object with Name: 'Alice', Surname: 'Smith', Age: '25', Address: { Street: '456 Elm St', City: 'Springfield', PostalCode: '67890', Country: 'USA' }. Then call process_person with this object and return the response.") }; } - [McpServerPrompt("processperson_workflow", "Demonstrate a workflow using ProcessPerson tool")] + [McpServerPrompt("processperson_workflow", "Demonstrate a workflow using process_person tool")] public static PromptMessage[] ProcessPersonWorkflow() { return new PromptMessage[] { - new PromptMessage("Call GetDefaultPerson, then pass its output into ProcessPerson. Return both the initial and processed person object.") + new PromptMessage("Call GetDefaultPerson, then pass its output into process_person. Return both the initial and processed person object.") }; } - [McpServerPrompt("get_default_address_integration", "Demonstrate integration with GetDefaultAddress tool")] + [McpServerPrompt("get_default_address_integration", "Demonstrate integration with get_default_address tool")] public static PromptMessage[] GetDefaultAddressIntegration() { return new PromptMessage[] { - new PromptMessage("First call GetDefaultPerson, then call GetDefaultAddress. Combine both into a summary like: 'Person X lives at Y'.") + new PromptMessage("First call GetDefaultPerson, then call get_default_address. Combine both into a summary like: 'Person X lives at Y'.") }; } @@ -66,7 +66,7 @@ public static PromptMessage[] HighLevelAgentPrompt() { return new PromptMessage[] { - new PromptMessage("You're a data-summary agent. Fetch the default person and address, then produce a human -readable summary. If age > 30, add '(senior)', otherwise '(junior)' at the end.") + new PromptMessage("You're a data-summary agent. Fetch the default person and address, then produce a human-readable summary. If age > 30, add '(senior)', otherwise '(junior)' at the end.") }; } @@ -85,7 +85,12 @@ public static PromptMessage[] SummarizePerson(string ageThreshold) { return new PromptMessage[] { - new PromptMessage($"Please perform the following steps:\r\n1. Call GetDefaultPerson() -> person.\r\n2. Call GetDefaultAddress() -> address.\r\n3. If person.Age > {ageThreshold} then set label = \"senior\"; otherwise set label = \"junior\".\r\n4. Call ProcessPerson(person) -> processed.\r\n5. Return a JSON object:\r\n{{ \"name\": person.Name, \"age\": person.Age, \"label\": label, \"address\": address, \"processed\": processed }}") + new PromptMessage("Please perform the following steps:\\n" + + "1. Call get_default_person \u2192 person.\\n" + + "2. Call get_default_address \u2192 address.\\n" + + $"3. If person.Age > {ageThreshold ?? string.Empty} then set label = 'senior'; otherwise set label = 'junior'.\\n" + + "4. Call process_person(person) \u2192 processed.\\n" + + "5. Return a JSON object: { 'name': person.Name, 'age': person.Age, 'label': label, 'address': address, 'processed': 'processed' }") }; } } From 3bfaf95c3b44055c7b331f48f073cf0404d92a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Mon, 4 Aug 2025 14:38:15 +0100 Subject: [PATCH 6/6] Improve MCP client test with prompts listing --- tests/McpClientTest/McpClientTest.cs | 64 +++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/tests/McpClientTest/McpClientTest.cs b/tests/McpClientTest/McpClientTest.cs index c957e74..c382a99 100644 --- a/tests/McpClientTest/McpClientTest.cs +++ b/tests/McpClientTest/McpClientTest.cs @@ -48,12 +48,66 @@ kernel.Plugins.AddFromFunctions("nanoFramework", tools.Select(aiFunction => aiFunction.AsKernelFunction())); // Check available prompts -var prompts = await mcpToolboxClient.ListPromptsAsync().ConfigureAwait(false); - -// Print those prompts Console.WriteLine("// Available prompts:"); -foreach (var p in prompts) Console.WriteLine($"{p.Name}: {p.Description}"); -Console.WriteLine("// --"); + +try +{ + var prompts = await mcpToolboxClient.ListPromptsAsync().ConfigureAwait(false); + + List functionPrompts = new List(); + + foreach (var p in prompts) + { + Console.WriteLine($"{p.Name}: {p.Description}"); + + // compose parameters list, if any + Dictionary promptArguments = new Dictionary(); + + if (p.ProtocolPrompt.Arguments is not null) + { + foreach (ModelContextProtocol.Protocol.PromptArgument argument in p.ProtocolPrompt.Arguments) + { + if (argument.Required.HasValue && argument.Required.Value) + { + // simplification here + // we assume that the only prompt argument from the list is the ageThreshold which we are hard coding to "65" + promptArguments.Add(argument.Name, "65"); + } + else + { + promptArguments.Add(argument.Name, string.Empty); + } + } + } + + var promptResult = await mcpToolboxClient.GetPromptAsync(p.Name, promptArguments); + + var promptTemplate = string.Join("\n", promptResult.Messages.Select(m => m.Content)); + + var semanticFunction = KernelFunctionFactory.CreateFromPrompt( + promptTemplate: promptTemplate, + executionSettings: (PromptExecutionSettings?)null, // Explicit cast to resolve ambiguity + functionName: p.Name, + description: promptResult.Description, + templateFormat: "semantic-kernel" + ); + + functionPrompts.Add(semanticFunction); + } + + if (functionPrompts.Any()) + { + kernel.Plugins.AddFromFunctions("from_prompts", functionPrompts); + } +} +catch (Exception ex) +{ + Console.WriteLine($"Error loading prompts: {ex.Message}"); +} +finally +{ + Console.WriteLine("// --"); +} var history = new ChatHistory(); var chatCompletionService = kernel.GetRequiredService();