Skip to content

Commit 5355efd

Browse files
committed
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.
1 parent 404c01e commit 5355efd

17 files changed

+1034
-194
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace nanoFramework.WebServer.Mcp
5+
{
6+
/// <summary>
7+
/// Represents a message within the Model Context Protocol (MCP) system, used for communication between clients and AI models.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// A <see cref="PromptMessage"/> encapsulates content sent to or received from AI models in the Model Context Protocol.
12+
/// Each message has a specific role (<see cref="Role.User"/> or <see cref="Role.Assistant"/>) and contains content which can be text.
13+
/// </para>
14+
/// <para>
15+
/// It serves as a core data structure in the MCP message exchange flow, particularly in prompt formation and model responses.
16+
/// </para>
17+
/// </remarks>
18+
public sealed class PromptMessage
19+
{
20+
/// <summary>
21+
/// Gets or sets the text content of the message.
22+
/// </summary>
23+
public string Text { get; set; }
24+
25+
/// <summary>
26+
/// Gets or sets the role of the message sender, specifying whether it's from a "user" or an "assistant".
27+
/// </summary>
28+
/// <remarks>
29+
/// In the Model Context Protocol, each message must have a clear role assignment to maintain
30+
/// the conversation flow. User messages represent queries or inputs from users, while assistant
31+
/// messages represent responses generated by AI models.
32+
/// </remarks>
33+
public Role Role { get; set; } = Role.User;
34+
35+
/// <summary>
36+
/// Initializes a new instance of the <see cref="PromptMessage"/> class with this prompt text.
37+
/// </summary>
38+
/// <param name="text">The text content of the message.</param>
39+
public PromptMessage(string text)
40+
{
41+
Text = text;
42+
}
43+
44+
/// <inheritdoc/>
45+
public override string ToString()
46+
{
47+
return $"{{\"role\":\"{RoleToString(Role)}\",\"content\":{{\"type\": \"text\",\"text\":\"{Text}\"}}}}";
48+
}
49+
50+
private static string RoleToString(Role role)
51+
{
52+
return role == Role.User ? "user" : "assistant";
53+
}
54+
}
55+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
6+
namespace nanoFramework.WebServer.Mcp
7+
{
8+
/// <summary>
9+
///
10+
/// </summary>
11+
/// <remarks>
12+
/// These have to be added to the methods implementing the MCP prompts in the same order as the parameters.
13+
/// </remarks>
14+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
15+
public sealed class McpPromptParameterAttribute : Attribute
16+
{
17+
/// <summary>
18+
/// Gets or sets the name of the prompt parameter.
19+
/// </summary>
20+
public string Name { get; set; }
21+
22+
/// <summary>
23+
/// Gets or sets the description of the prompt parameter.
24+
/// </summary>
25+
/// <remarks>
26+
/// The description is optional.
27+
/// </remarks>
28+
public string Description { get; set; }
29+
30+
/// <summary>
31+
/// Gets or sets a value indicating whether the prompt parameter is required.
32+
/// </summary>
33+
public bool Required { get; set; }
34+
35+
///// <summary>
36+
///// Initializes a new instance of the <see cref="McpPromptParameterAttribute"/> class.
37+
///// </summary>
38+
//public McpPromptParameterAttribute()
39+
//{
40+
//}
41+
42+
/// <summary>
43+
/// Initializes a new instance of the <see cref="McpPromptParameterAttribute"/> class with the specified name and description.
44+
/// </summary>
45+
/// <param name="name">The name of the prompt parameter.</param>
46+
/// <param name="description">The description of the prompt parameter.</param>
47+
public McpPromptParameterAttribute(string name, string description)
48+
{
49+
Name = name;
50+
Description = description;
51+
}
52+
53+
/// <summary>
54+
/// Initializes a new instance of the <see cref="McpPromptParameterAttribute"/> class with the specified name, description, and required status.
55+
/// </summary>
56+
/// <param name="name">The name of the prompt parameter.</param>
57+
/// <param name="description">The description of the prompt parameter.</param>
58+
/// <param name="required">Indicates whether the prompt parameter is required.</param>
59+
public McpPromptParameterAttribute(string name, string description, bool required)
60+
{
61+
Name = name;
62+
Description = description;
63+
Required = required;
64+
}
65+
66+
/// <inheritdoc/>
67+
public override string ToString()
68+
{
69+
return $"{{\"name\":\"{Name}\",\"description\":\"{Description}\",\"required\":{Required.ToString().ToLower()}}}";
70+
}
71+
}
72+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections;
6+
using System.Diagnostics;
7+
using System.Reflection;
8+
using System.Text;
9+
10+
namespace nanoFramework.WebServer.Mcp
11+
{
12+
/// <summary>
13+
/// Registry for Model Context Protocol (MCP) prompts, allowing discovery and invocation of prompts defined with the <see cref="McpServerPromptAttribute"/>.
14+
/// </summary>
15+
public class McpPromptRegistry : RegistryBase
16+
{
17+
private static readonly Hashtable promtps = new Hashtable();
18+
private static bool isInitialized = false;
19+
20+
/// <summary>
21+
/// Discovers MCP prompts by scanning the provided types for methods decorated with the <see cref="McpServerPromptAttribute"/>.
22+
/// This method should be called once to populate the tool registry.
23+
/// </summary>
24+
/// <param name="typesWithPrompts">An array of types to scan for MCP prompts.</param>
25+
public static void DiscoverPrompts(Type[] typesWithPrompts)
26+
{
27+
if (isInitialized)
28+
{
29+
// prompts already discovered
30+
return;
31+
}
32+
33+
foreach (Type mcpPrompt in typesWithPrompts)
34+
{
35+
MethodInfo[] methods = mcpPrompt.GetMethods();
36+
37+
foreach (MethodInfo method in methods)
38+
{
39+
try
40+
{
41+
object[] allAttribute = method.GetCustomAttributes(false);
42+
43+
foreach (object attrib in allAttribute)
44+
{
45+
if (attrib.GetType() != typeof(McpServerPromptAttribute))
46+
{
47+
continue;
48+
}
49+
50+
McpServerPromptAttribute attribute = (McpServerPromptAttribute)attrib;
51+
if (attribute != null)
52+
{
53+
// validate if the method returns an array of PromptMessage
54+
if (method.ReturnType != typeof(PromptMessage[]) && !method.ReturnType.IsArray)
55+
{
56+
throw new Exception($"Method {method.Name} does not return an array of PromptMessage.");
57+
}
58+
59+
promtps.Add(attribute.Name, new PromptMetadata
60+
{
61+
Name = attribute.Name,
62+
Description = attribute.Description,
63+
Arguments = ComposeArgumentsAsJson(allAttribute),
64+
Method = method
65+
});
66+
}
67+
}
68+
}
69+
catch (Exception)
70+
{
71+
continue;
72+
}
73+
}
74+
}
75+
76+
isInitialized = true;
77+
}
78+
79+
private static string ComposeArgumentsAsJson(object[] attributes)
80+
{
81+
StringBuilder sb = new StringBuilder();
82+
bool isFirst = true;
83+
84+
foreach (object attrib in attributes)
85+
{
86+
if (attrib is not McpPromptParameterAttribute)
87+
{
88+
continue;
89+
}
90+
91+
McpPromptParameterAttribute parameterNameAttribute = (McpPromptParameterAttribute)attrib;
92+
if (parameterNameAttribute != null)
93+
{
94+
sb.Append(isFirst ? "" : ",");
95+
96+
sb.Append("{");
97+
98+
sb.Append($"\"name\": \"{parameterNameAttribute.Name}\"");
99+
sb.Append(string.IsNullOrEmpty(parameterNameAttribute.Description) ? "" : $",\"description\": \"{parameterNameAttribute.Description}\"");
100+
sb.Append($",\"required\": {parameterNameAttribute.Required.ToString().ToLower()}");
101+
102+
sb.Append("}");
103+
104+
isFirst = false;
105+
}
106+
}
107+
108+
return sb.Length > 0 ? sb.ToString() : string.Empty;
109+
}
110+
111+
/// <summary>
112+
/// Gets the metadata of all registered MCP prompts in JSON format.
113+
/// This method should be called after <see cref="DiscoverPrompts"/> to retrieve the prompt metadata.
114+
/// </summary>
115+
/// <returns>A JSON string containing the metadata of all registered prompts.</returns>
116+
/// <exception cref="Exception">Thrown if there is an error building the prompts list.</exception>
117+
public static string GetPromptMetadataJson()
118+
{
119+
try
120+
{
121+
StringBuilder sb = new StringBuilder();
122+
sb.Append("\"prompts\":[");
123+
124+
foreach (PromptMetadata prompt in promtps.Values)
125+
{
126+
sb.Append(prompt.ToString());
127+
sb.Append(",");
128+
}
129+
130+
sb.Remove(sb.Length - 1, 1);
131+
sb.Append("],\"nextCursor\":null");
132+
return sb.ToString();
133+
}
134+
catch (Exception)
135+
{
136+
throw new Exception("Impossible to build prompts list.");
137+
}
138+
}
139+
140+
/// <summary>
141+
/// Gets the description of a registered MCP prompt by its name.
142+
/// </summary>
143+
/// <param name="promptName">The name of the prompt to invoke.</param>
144+
/// <returns>A string containing the description of the prompt.</returns>
145+
/// <exception cref="Exception">Thrown when the specified prompt is not found in the registry.</exception>
146+
public static string GetPromptDescription(string promptName)
147+
{
148+
if (promtps.Contains(promptName))
149+
{
150+
PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName];
151+
return promptMetadata.Description;
152+
}
153+
154+
throw new Exception("Prompt not found");
155+
}
156+
157+
/// <summary>
158+
/// Invokes a registered MCP prompt by name and returns the serialized result.
159+
/// </summary>
160+
/// <param name="promptName">The name of the prompt to invoke.</param>
161+
/// <param name="arguments">The arguments to pass to the tool.</param>
162+
/// <returns>A JSON string containing the serialized result of the prompt invocation.</returns>
163+
/// <exception cref="Exception">Thrown when the specified prompt is not found in the registry.</exception>
164+
public static string InvokePrompt(string promptName, Hashtable arguments)
165+
{
166+
if (promtps.Contains(promptName))
167+
{
168+
PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName];
169+
MethodInfo method = promptMetadata.Method;
170+
Debug.WriteLine($"Prompt name: {promptName}, method: {method.Name}");
171+
172+
object[] methodParams = null;
173+
174+
if (arguments is not null && arguments.Count > 0)
175+
{
176+
methodParams = new object[arguments.Count];
177+
178+
arguments.Values.CopyTo(methodParams, 0);
179+
}
180+
181+
PromptMessage[] result = (PromptMessage[])method.Invoke(null, methodParams);
182+
183+
// serialize the result to JSON using a speedy approach with a StringBuilder
184+
StringBuilder sb = new StringBuilder();
185+
186+
// start building the JSON response
187+
sb.Append($"{{\"description\":\"{GetPromptDescription(promptName)}\",\"messages\":[");
188+
189+
// iterate through the result array and append each message
190+
for (int i = 0; i < result.Length; i++)
191+
{
192+
sb.Append(result[i]);
193+
if (i < result.Length - 1)
194+
{
195+
sb.Append(",");
196+
}
197+
}
198+
199+
// close the messages array and the main object
200+
sb.Append("]}");
201+
202+
// done here, return the JSON string
203+
return sb.ToString();
204+
}
205+
206+
throw new Exception("Prompt not found");
207+
}
208+
}
209+
}

nanoFramework.WebServer.Mcp/McpServerController.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,28 @@ public void HandleMcpRequest(WebServerEventArgs e)
120120

121121
sb.Append($",\"result\":{{\"content\":[{{\"type\":\"text\",\"text\":{result}}}]}}}}");
122122
}
123+
else if (request["method"].ToString() == "prompts/list")
124+
{
125+
string promptListJson = McpPromptRegistry.GetPromptMetadataJson();
126+
sb.Append($",\"result\":{{{promptListJson}}}}}");
127+
}
128+
else if (request["method"].ToString() == "prompts/get")
129+
{
130+
string promptName = ((Hashtable)request["params"])["name"].ToString();
131+
Hashtable arguments = ((Hashtable)request["params"])["arguments"] == null ? null : (Hashtable)((Hashtable)request["params"])["arguments"];
132+
133+
string result = McpPromptRegistry.InvokePrompt(promptName, arguments);
134+
sb.Append($",\"result\":{result}}}}}");
135+
}
123136
else
124137
{
125138
sb.Append($",\"error\":{{\"code\":-32601,\"message\":\"Method not found\"}}}}");
126139
}
127140

141+
Debug.WriteLine();
142+
Debug.WriteLine($"Response: {sb.ToString()}");
143+
Debug.WriteLine();
144+
128145
WebServer.OutPutStream(e.Context.Response, sb.ToString());
129146
return;
130147
}

0 commit comments

Comments
 (0)