Skip to content

Commit 89dc65e

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 89dc65e

17 files changed

+1008
-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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
/// Represents a parameter for a Model Context Protocol (MCP) prompt, providing metadata about the parameter such as its name and description.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// These have to be added to the methods implementing the MCP prompts in the same order as the parameters.
14+
/// </para>
15+
/// <para>
16+
/// By design, if a prompt parameter is specified, it is considered required. .NET nanoFramework does not support optional parameters in MCP prompts.
17+
/// </para>
18+
/// </remarks>
19+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
20+
public sealed class McpPromptParameterAttribute : Attribute
21+
{
22+
/// <summary>
23+
/// Gets or sets the name of the prompt parameter.
24+
/// </summary>
25+
public string Name { get; set; }
26+
27+
/// <summary>
28+
/// Gets or sets the description of the prompt parameter.
29+
/// </summary>
30+
/// <remarks>
31+
/// The description is optional.
32+
/// </remarks>
33+
public string Description { get; set; }
34+
35+
/// <summary>
36+
/// Initializes a new instance of the <see cref="McpPromptParameterAttribute"/> class with the specified name and description.
37+
/// </summary>
38+
/// <param name="name">The name of the prompt parameter.</param>
39+
/// <param name="description">The description of the prompt parameter.</param>
40+
public McpPromptParameterAttribute(string name, string description)
41+
{
42+
Name = name;
43+
Description = description;
44+
}
45+
46+
/// <inheritdoc/>
47+
public override string ToString()
48+
{
49+
return $"{{\"name\":\"{Name}\",\"description\":\"{Description}\",\"required\":\"true\"}}";
50+
}
51+
}
52+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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($"{{{parameterNameAttribute}}}");
97+
98+
isFirst = false;
99+
}
100+
}
101+
102+
return sb.Length > 0 ? sb.ToString() : string.Empty;
103+
}
104+
105+
/// <summary>
106+
/// Gets the metadata of all registered MCP prompts in JSON format.
107+
/// This method should be called after <see cref="DiscoverPrompts"/> to retrieve the prompt metadata.
108+
/// </summary>
109+
/// <returns>A JSON string containing the metadata of all registered prompts.</returns>
110+
/// <exception cref="Exception">Thrown if there is an error building the prompts list.</exception>
111+
public static string GetPromptMetadataJson()
112+
{
113+
try
114+
{
115+
StringBuilder sb = new StringBuilder();
116+
sb.Append("\"prompts\":[");
117+
118+
foreach (PromptMetadata prompt in promtps.Values)
119+
{
120+
sb.Append(prompt.ToString());
121+
sb.Append(",");
122+
}
123+
124+
sb.Remove(sb.Length - 1, 1);
125+
sb.Append("],\"nextCursor\":null");
126+
return sb.ToString();
127+
}
128+
catch (Exception)
129+
{
130+
throw new Exception("Impossible to build prompts list.");
131+
}
132+
}
133+
134+
/// <summary>
135+
/// Gets the description of a registered MCP prompt by its name.
136+
/// </summary>
137+
/// <param name="promptName">The name of the prompt to invoke.</param>
138+
/// <returns>A string containing the description of the prompt.</returns>
139+
/// <exception cref="Exception">Thrown when the specified prompt is not found in the registry.</exception>
140+
public static string GetPromptDescription(string promptName)
141+
{
142+
if (promtps.Contains(promptName))
143+
{
144+
PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName];
145+
return promptMetadata.Description;
146+
}
147+
148+
throw new Exception("Prompt not found");
149+
}
150+
151+
/// <summary>
152+
/// Invokes a registered MCP prompt by name and returns the serialized result.
153+
/// </summary>
154+
/// <param name="promptName">The name of the prompt to invoke.</param>
155+
/// <param name="arguments">The arguments to pass to the tool.</param>
156+
/// <returns>A JSON string containing the serialized result of the prompt invocation.</returns>
157+
/// <exception cref="Exception">Thrown when the specified prompt is not found in the registry.</exception>
158+
public static string InvokePrompt(string promptName, Hashtable arguments)
159+
{
160+
if (promtps.Contains(promptName))
161+
{
162+
PromptMetadata promptMetadata = (PromptMetadata)promtps[promptName];
163+
MethodInfo method = promptMetadata.Method;
164+
Debug.WriteLine($"Prompt name: {promptName}, method: {method.Name}");
165+
166+
object[] methodParams = null;
167+
168+
if (arguments is not null && arguments.Count > 0)
169+
{
170+
methodParams = new object[arguments.Count];
171+
172+
arguments.Values.CopyTo(methodParams, 0);
173+
}
174+
175+
PromptMessage[] result = (PromptMessage[])method.Invoke(null, methodParams);
176+
177+
// serialize the result to JSON using a speedy approach with a StringBuilder
178+
StringBuilder sb = new StringBuilder();
179+
180+
// start building the JSON response
181+
sb.Append($"{{\"description\":\"{GetPromptDescription(promptName)}\",\"messages\":[");
182+
183+
// iterate through the result array and append each message
184+
for (int i = 0; i < result.Length; i++)
185+
{
186+
sb.Append(result[i]);
187+
if (i < result.Length - 1)
188+
{
189+
sb.Append(",");
190+
}
191+
}
192+
193+
// close the messages array and the main object
194+
sb.Append("]}");
195+
196+
// done here, return the JSON string
197+
return sb.ToString();
198+
}
199+
200+
throw new Exception("Prompt not found");
201+
}
202+
}
203+
}

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
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
/// Used to indicate that a method should be considered an <see cref="McpServerPromptAttribute"/>.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// This attribute is applied to methods that should be exposed as prompts in the Model Context Protocol. When a class
14+
/// containing methods marked with this attribute is registered with McpServerBuilderExtensions,
15+
/// these methods become available as prompts that can be called by MCP clients.
16+
/// </para>
17+
/// </remarks>
18+
[AttributeUsage(AttributeTargets.Method)]
19+
public class McpServerPromptAttribute : Attribute
20+
{
21+
/// <summary>
22+
/// Gets the name of the prompt.
23+
/// </summary>
24+
public string Name { get; }
25+
26+
/// <summary>
27+
/// Gets the description of the tool.
28+
/// </summary>
29+
public string Description { get; }
30+
31+
/// <summary>
32+
/// Initializes a new instance of the <see cref="McpServerPromptAttribute"/> class with the specified name and description.
33+
/// </summary>
34+
/// <param name="name">The unique name of the prompt.</param>
35+
/// <param name="description">The description of the prompt.</param>
36+
public McpServerPromptAttribute(string name, string description = "")
37+
{
38+
Name = name;
39+
Description = description;
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)