Skip to content

Commit 27fde16

Browse files
committed
Implement SEP-986: Specify Format for Tool Names
We can add static analysis to provide compile-time diagnostics later, once the analyzers project is merged. For now, this adds run-time validation.
1 parent 121929d commit 27fde16

File tree

2 files changed

+163
-2
lines changed

2 files changed

+163
-2
lines changed

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using System.Reflection;
77
using System.Text.Json;
88
using System.Text.Json.Nodes;
9-
using System.Text.Json.Serialization.Metadata;
109
using System.Text.RegularExpressions;
1110

1211
namespace ModelContextProtocol.Server;
@@ -208,6 +207,8 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
208207
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
209208
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList<object> metadata)
210209
{
210+
ValidateToolName(tool.Name);
211+
211212
AIFunction = function;
212213
ProtocolTool = tool;
213214
ProtocolTool.McpServerTool = this;
@@ -376,15 +377,35 @@ internal static IReadOnlyList<object> CreateMetadata(MethodInfo method)
376377
return meta;
377378
}
378379

379-
/// <summary>Regex that flags runs of characters other than ASCII digits or letters.</summary>
380380
#if NET
381+
/// <summary>Regex that flags runs of characters other than ASCII digits or letters.</summary>
381382
[GeneratedRegex("[^0-9A-Za-z]+")]
382383
private static partial Regex NonAsciiLetterDigitsRegex();
384+
385+
/// <summary>Regex that validates tool names.</summary>
386+
[GeneratedRegex(@"^[A-Za-z0-9_.-]{1,128}\z")]
387+
private static partial Regex ValidateToolNameRegex();
383388
#else
384389
private static Regex NonAsciiLetterDigitsRegex() => _nonAsciiLetterDigits;
385390
private static readonly Regex _nonAsciiLetterDigits = new("[^0-9A-Za-z]+", RegexOptions.Compiled);
391+
392+
private static Regex ValidateToolNameRegex() => _validateToolName;
393+
private static readonly Regex _validateToolName = new(@"^[A-Za-z0-9_.-]{1,128}\z", RegexOptions.Compiled);
386394
#endif
387395

396+
private static void ValidateToolName(string name)
397+
{
398+
if (name is null)
399+
{
400+
throw new ArgumentException("Tool name cannot be null.");
401+
}
402+
403+
if (!ValidateToolNameRegex().IsMatch(name))
404+
{
405+
throw new ArgumentException($"The tool name '{name}' is invalid. Tool names must match the regular expression '{ValidateToolNameRegex()}'");
406+
}
407+
}
408+
388409
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
389410
{
390411
structuredOutputRequiresWrapping = false;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using Microsoft.Extensions.AI;
2+
using ModelContextProtocol.Server;
3+
4+
namespace ModelContextProtocol.Tests.Server;
5+
6+
public class McpServerToolNameValidationTests
7+
{
8+
[Fact]
9+
public void WithValidCharacters_Succeeds()
10+
{
11+
const string AllValidChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-";
12+
13+
Validate(AllValidChars);
14+
15+
foreach (char c in AllValidChars)
16+
{
17+
Validate($"{c}");
18+
Validate($"tool{c}name");
19+
Validate($"{c}toolname");
20+
Validate($"toolname{c}");
21+
}
22+
23+
static void Validate(string toolName)
24+
{
25+
var tool = McpServerTool.Create(() => "result", new McpServerToolCreateOptions { Name = toolName });
26+
Assert.Equal(toolName, tool.ProtocolTool.Name);
27+
}
28+
}
29+
30+
[Fact]
31+
public void WithInvalidCharacters_Throws()
32+
{
33+
Validate("café");
34+
Validate("tööl");
35+
Validate("🔧tool");
36+
37+
for (int i = 0; i < 256; i++)
38+
{
39+
char c = (char)i;
40+
if (c is not (>= 'a' and <= 'z')
41+
and not (>= 'A' and <= 'Z')
42+
and not (>= '0' and <= '9')
43+
and not ('_' or '-' or '.'))
44+
{
45+
Validate($"{c}");
46+
Validate($"tool{c}name");
47+
Validate($"{c}toolname");
48+
Validate($"toolname{c}");
49+
}
50+
}
51+
52+
static void Validate(string toolName)
53+
{
54+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create(() => "result", new McpServerToolCreateOptions { Name = toolName }));
55+
Assert.Contains(toolName, ex.Message, StringComparison.OrdinalIgnoreCase);
56+
}
57+
}
58+
59+
[Theory]
60+
[InlineData(1)]
61+
[InlineData(10)]
62+
[InlineData(127)]
63+
[InlineData(128)]
64+
public void WithValidLengths_Succeeds(int length)
65+
{
66+
string validName = new('a', length);
67+
var tool = McpServerTool.Create(() => "result", new McpServerToolCreateOptions { Name = validName });
68+
Assert.Equal(validName, tool.ProtocolTool.Name);
69+
}
70+
71+
[Theory]
72+
[InlineData(0)]
73+
[InlineData(129)]
74+
[InlineData(130)]
75+
public void WithInvalidLengths_ThrowsArgumentException(int length)
76+
{
77+
string invalidName = new('a', length);
78+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create(() => "result", new McpServerToolCreateOptions { Name = invalidName }));
79+
Assert.Contains(invalidName, ex.Message);
80+
}
81+
82+
[Fact]
83+
public void UsingAttribute_ValidatesToolName()
84+
{
85+
var validTool = McpServerTool.Create([McpServerTool(Name = "valid_tool")] () => "result");
86+
Assert.Equal("valid_tool", validTool.ProtocolTool.Name);
87+
88+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create([McpServerTool(Name = "invalid@tool")] () => "result"));
89+
Assert.Contains("invalid@tool", ex.Message);
90+
}
91+
92+
[Fact]
93+
public void UsingAttributeWithInvalidCharacters_ThrowsArgumentException()
94+
{
95+
var validTool = McpServerTool.Create([McpServerTool(Name = "tool")] () => "result");
96+
Assert.Equal("tool", validTool.ProtocolTool.Name);
97+
98+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create([McpServerTool(Name = "tööl")] () => "result"));
99+
Assert.Contains("tööl", ex.Message);
100+
}
101+
102+
[Fact]
103+
public void FromMethodInfo_ValidatesToolName()
104+
{
105+
Assert.Equal(nameof(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa), McpServerTool.Create(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa).ProtocolTool.Name);
106+
107+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa));
108+
Assert.Contains(nameof(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa), ex.Message);
109+
}
110+
111+
[Fact]
112+
public void FromAIFunction_ValidatesToolName()
113+
{
114+
var validTool = McpServerTool.Create(AIFunctionFactory.Create(() => "result", new AIFunctionFactoryOptions { Name = "valid_ai" }));
115+
Assert.Equal("valid_ai", validTool.ProtocolTool.Name);
116+
117+
var invalidFunction = AIFunctionFactory.Create(() => "result", new AIFunctionFactoryOptions { Name = "invalid ai" });
118+
var ex = Assert.Throws<ArgumentException>(() => McpServerTool.Create(invalidFunction));
119+
Assert.Contains("invalid ai", ex.Message);
120+
}
121+
122+
[Fact]
123+
public void FromNullAIFunctionName_ThrowsArgumentNullException()
124+
{
125+
AIFunction f = new NullNameAIFunction();
126+
Assert.Throws<ArgumentException>(() => McpServerTool.Create(f));
127+
}
128+
129+
private static void aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa() { }
130+
131+
private static void aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa() { }
132+
133+
private sealed class NullNameAIFunction : AIFunction
134+
{
135+
public override string Name => null!;
136+
137+
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
138+
throw new NotSupportedException();
139+
}
140+
}

0 commit comments

Comments
 (0)