Skip to content

Commit 44b7937

Browse files
authored
Merge branch 'main' into copilot/add-telemetry-for-custom-tools
2 parents 19bd5b0 + 20df64c commit 44b7937

2 files changed

Lines changed: 325 additions & 2 deletions

File tree

src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Net;
45
using Azure.DataApiBuilder.Mcp.Model;
6+
using Azure.DataApiBuilder.Service.Exceptions;
57
using ModelContextProtocol.Protocol;
8+
using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
69

710
namespace Azure.DataApiBuilder.Mcp.Core
811
{
@@ -11,15 +14,42 @@ namespace Azure.DataApiBuilder.Mcp.Core
1114
/// </summary>
1215
public class McpToolRegistry
1316
{
14-
private readonly Dictionary<string, IMcpTool> _tools = new();
17+
private readonly Dictionary<string, IMcpTool> _tools = new(StringComparer.OrdinalIgnoreCase);
1518

1619
/// <summary>
1720
/// Registers a tool in the registry
1821
/// </summary>
22+
/// <exception cref="DataApiBuilderException">Thrown when tool name is invalid or duplicate</exception>
1923
public void RegisterTool(IMcpTool tool)
2024
{
2125
Tool metadata = tool.GetToolMetadata();
22-
_tools[metadata.Name] = tool;
26+
string toolName = metadata.Name?.Trim() ?? string.Empty;
27+
28+
// Reject empty or whitespace-only tool names
29+
if (string.IsNullOrWhiteSpace(toolName))
30+
{
31+
throw new DataApiBuilderException(
32+
message: "MCP tool name cannot be null, empty, or whitespace.",
33+
statusCode: HttpStatusCode.ServiceUnavailable,
34+
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
35+
}
36+
37+
// Check for duplicate tool names (case-insensitive)
38+
if (_tools.TryGetValue(toolName, out IMcpTool? existingTool))
39+
{
40+
string existingToolType = existingTool.ToolType == ToolType.BuiltIn ? "built-in" : "custom";
41+
string newToolType = tool.ToolType == ToolType.BuiltIn ? "built-in" : "custom";
42+
43+
throw new DataApiBuilderException(
44+
message: $"Duplicate MCP tool name '{toolName}' detected. " +
45+
$"A {existingToolType} tool with this name is already registered. " +
46+
$"Cannot register {newToolType} tool with the same name. " +
47+
$"Tool names must be unique across all tool types.",
48+
statusCode: HttpStatusCode.ServiceUnavailable,
49+
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
50+
}
51+
52+
_tools[toolName] = tool;
2353
}
2454

2555
/// <summary>
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Net;
10+
using System.Text.Json;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using Azure.DataApiBuilder.Mcp.Core;
14+
using Azure.DataApiBuilder.Mcp.Model;
15+
using Azure.DataApiBuilder.Service.Exceptions;
16+
using Microsoft.VisualStudio.TestTools.UnitTesting;
17+
using ModelContextProtocol.Protocol;
18+
using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
19+
20+
namespace Azure.DataApiBuilder.Service.Tests.Mcp
21+
{
22+
/// <summary>
23+
/// Tests for McpToolRegistry to ensure tool name uniqueness validation.
24+
/// </summary>
25+
[TestClass]
26+
public class McpToolRegistryTests
27+
{
28+
/// <summary>
29+
/// Test that registering multiple tools with unique names succeeds.
30+
/// </summary>
31+
[TestMethod]
32+
public void RegisterTool_WithMultipleUniqueNames_Succeeds()
33+
{
34+
// Arrange
35+
McpToolRegistry registry = new();
36+
IMcpTool tool1 = new MockMcpTool("tool_one", ToolType.BuiltIn);
37+
IMcpTool tool2 = new MockMcpTool("tool_two", ToolType.Custom);
38+
IMcpTool tool3 = new MockMcpTool("tool_three", ToolType.BuiltIn);
39+
40+
// Act & Assert - should not throw
41+
registry.RegisterTool(tool1);
42+
registry.RegisterTool(tool2);
43+
registry.RegisterTool(tool3);
44+
45+
// Verify all tools were registered
46+
IEnumerable<Tool> allTools = registry.GetAllTools();
47+
Assert.AreEqual(3, allTools.Count());
48+
}
49+
50+
/// <summary>
51+
/// Test that registering duplicate tools of the same type throws an exception.
52+
/// Validates that both built-in and custom tools enforce name uniqueness within their own type.
53+
/// </summary>
54+
[DataTestMethod]
55+
[DataRow(ToolType.BuiltIn, "duplicate_tool", "built-in", DisplayName = "Duplicate Built-In Tools")]
56+
[DataRow(ToolType.Custom, "my_custom_tool", "custom", DisplayName = "Duplicate Custom Tools")]
57+
public void RegisterTool_WithDuplicateSameType_ThrowsException(
58+
ToolType toolType,
59+
string toolName,
60+
string expectedToolTypeText)
61+
{
62+
// Arrange
63+
McpToolRegistry registry = new();
64+
IMcpTool tool1 = new MockMcpTool(toolName, toolType);
65+
IMcpTool tool2 = new MockMcpTool(toolName, toolType);
66+
67+
// Act - Register first tool
68+
registry.RegisterTool(tool1);
69+
70+
// Assert - Second registration should throw
71+
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(
72+
() => registry.RegisterTool(tool2)
73+
);
74+
75+
// Verify exception details
76+
Assert.IsTrue(exception.Message.Contains($"Duplicate MCP tool name '{toolName}' detected"));
77+
Assert.IsTrue(exception.Message.Contains($"{expectedToolTypeText} tool with this name is already registered"));
78+
Assert.IsTrue(exception.Message.Contains($"Cannot register {expectedToolTypeText} tool with the same name"));
79+
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode);
80+
Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode);
81+
}
82+
83+
/// <summary>
84+
/// Test that registering tools with conflicting names across different types throws an exception.
85+
/// Validates that tool names must be unique across all tool types (built-in and custom).
86+
/// </summary>
87+
[DataTestMethod]
88+
[DataRow("create_record", ToolType.BuiltIn, ToolType.Custom, "built-in", "custom", DisplayName = "Built-In then Custom conflict")]
89+
[DataRow("read_records", ToolType.BuiltIn, ToolType.Custom, "built-in", "custom", DisplayName = "Built-In then Custom conflict (read_records)")]
90+
[DataRow("my_stored_proc", ToolType.Custom, ToolType.BuiltIn, "custom", "built-in", DisplayName = "Custom then Built-In conflict")]
91+
public void RegisterTool_WithCrossTypeConflict_ThrowsException(
92+
string toolName,
93+
ToolType firstToolType,
94+
ToolType secondToolType,
95+
string expectedExistingType,
96+
string expectedNewType)
97+
{
98+
// Arrange
99+
McpToolRegistry registry = new();
100+
IMcpTool existingTool = new MockMcpTool(toolName, firstToolType);
101+
IMcpTool conflictingTool = new MockMcpTool(toolName, secondToolType);
102+
103+
// Act - Register first tool
104+
registry.RegisterTool(existingTool);
105+
106+
// Assert - Second tool registration should throw
107+
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(
108+
() => registry.RegisterTool(conflictingTool)
109+
);
110+
111+
// Verify exception details
112+
Assert.IsTrue(exception.Message.Contains($"Duplicate MCP tool name '{toolName}' detected"));
113+
Assert.IsTrue(exception.Message.Contains($"{expectedExistingType} tool with this name is already registered"));
114+
Assert.IsTrue(exception.Message.Contains($"Cannot register {expectedNewType} tool with the same name"));
115+
Assert.IsTrue(exception.Message.Contains("Tool names must be unique across all tool types"));
116+
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode);
117+
Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode);
118+
}
119+
120+
/// <summary>
121+
/// Test that tool name comparison is case-sensitive.
122+
/// Tools with different casing should not be allowed.
123+
/// </summary>
124+
[TestMethod]
125+
public void RegisterTool_WithDifferentCasing_ThrowsException()
126+
{
127+
// Arrange
128+
McpToolRegistry registry = new();
129+
IMcpTool tool1 = new MockMcpTool("my_tool", ToolType.BuiltIn);
130+
IMcpTool tool2 = new MockMcpTool("My_Tool", ToolType.Custom);
131+
132+
// Act - Register first tool
133+
registry.RegisterTool(tool1);
134+
135+
// Assert - Case-insensitive duplicate should throw
136+
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(
137+
() => registry.RegisterTool(tool2)
138+
);
139+
140+
Assert.IsTrue(exception.Message.Contains("Duplicate MCP tool name"));
141+
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode);
142+
}
143+
144+
/// <summary>
145+
/// Test that GetAllTools returns all registered tools.
146+
/// </summary>
147+
[TestMethod]
148+
public void GetAllTools_ReturnsAllRegisteredTools()
149+
{
150+
// Arrange
151+
McpToolRegistry registry = new();
152+
registry.RegisterTool(new MockMcpTool("tool_a", ToolType.BuiltIn));
153+
registry.RegisterTool(new MockMcpTool("tool_b", ToolType.Custom));
154+
registry.RegisterTool(new MockMcpTool("tool_c", ToolType.BuiltIn));
155+
156+
// Act
157+
IEnumerable<Tool> allTools = registry.GetAllTools();
158+
159+
// Assert
160+
Assert.AreEqual(3, allTools.Count());
161+
Assert.IsTrue(allTools.Any(t => t.Name == "tool_a"));
162+
Assert.IsTrue(allTools.Any(t => t.Name == "tool_b"));
163+
Assert.IsTrue(allTools.Any(t => t.Name == "tool_c"));
164+
}
165+
166+
/// <summary>
167+
/// Test that TryGetTool returns false for non-existent tool.
168+
/// </summary>
169+
[TestMethod]
170+
public void TryGetTool_WithNonExistentName_ReturnsFalse()
171+
{
172+
// Arrange
173+
McpToolRegistry registry = new();
174+
registry.RegisterTool(new MockMcpTool("existing_tool", ToolType.BuiltIn));
175+
176+
// Act
177+
bool found = registry.TryGetTool("non_existent_tool", out IMcpTool? tool);
178+
179+
// Assert
180+
Assert.IsFalse(found);
181+
Assert.IsNull(tool);
182+
}
183+
184+
/// <summary>
185+
/// Test edge case: empty tool name should throw exception.
186+
/// </summary>
187+
[TestMethod]
188+
public void RegisterTool_WithEmptyToolName_ThrowsException()
189+
{
190+
// Arrange
191+
McpToolRegistry registry = new();
192+
IMcpTool tool = new MockMcpTool("", ToolType.BuiltIn);
193+
194+
// Assert - Empty tool names should be rejected
195+
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(
196+
() => registry.RegisterTool(tool)
197+
);
198+
199+
Assert.IsTrue(exception.Message.Contains("cannot be null, empty, or whitespace"));
200+
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode);
201+
}
202+
203+
/// <summary>
204+
/// Test realistic scenario with actual built-in tool names.
205+
/// </summary>
206+
[TestMethod]
207+
public void RegisterTool_WithRealisticBuiltInToolNames_DetectsDuplicates()
208+
{
209+
// Arrange
210+
McpToolRegistry registry = new();
211+
212+
// Simulate registering built-in tools
213+
registry.RegisterTool(new MockMcpTool("create_record", ToolType.BuiltIn));
214+
registry.RegisterTool(new MockMcpTool("read_records", ToolType.BuiltIn));
215+
registry.RegisterTool(new MockMcpTool("update_record", ToolType.BuiltIn));
216+
registry.RegisterTool(new MockMcpTool("delete_record", ToolType.BuiltIn));
217+
registry.RegisterTool(new MockMcpTool("describe_entities", ToolType.BuiltIn));
218+
219+
// Try to register a custom tool with a conflicting name
220+
IMcpTool customTool = new MockMcpTool("read_records", ToolType.Custom);
221+
222+
// Assert - Should throw
223+
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(
224+
() => registry.RegisterTool(customTool)
225+
);
226+
227+
Assert.IsTrue(exception.Message.Contains("read_records"));
228+
Assert.IsTrue(exception.Message.Contains("built-in tool"));
229+
}
230+
231+
/// <summary>
232+
/// Test that registering a tool with leading/trailing whitespace in the name is treated as a duplicate of the trimmed name.
233+
/// Note: during tool registration, the registry should trim whitespace and detect duplicates accordingly.
234+
/// </summary>
235+
[TestMethod]
236+
public void RegisterTool_WithLeadingTrailingWhitespace_DetectsDuplicate()
237+
{
238+
// Arrange
239+
McpToolRegistry registry = new();
240+
IMcpTool tool1 = new MockMcpTool("my_tool", ToolType.BuiltIn);
241+
IMcpTool tool2 = new MockMcpTool(" my_tool ", ToolType.Custom);
242+
243+
// Act
244+
registry.RegisterTool(tool1);
245+
246+
// Assert - trimmed name should collide
247+
Assert.ThrowsException<DataApiBuilderException>(
248+
() => registry.RegisterTool(tool2)
249+
);
250+
}
251+
252+
#region Private helpers
253+
254+
/// <summary>
255+
/// Mock implementation of IMcpTool for testing purposes.
256+
/// </summary>
257+
private class MockMcpTool : IMcpTool
258+
{
259+
private readonly string _toolName;
260+
261+
public MockMcpTool(string toolName, ToolType toolType)
262+
{
263+
_toolName = toolName;
264+
ToolType = toolType;
265+
}
266+
267+
public ToolType ToolType { get; }
268+
269+
public Tool GetToolMetadata()
270+
{
271+
// Create a simple JSON object for the input schema
272+
using JsonDocument doc = JsonDocument.Parse("{\"type\": \"object\"}");
273+
return new Tool
274+
{
275+
Name = _toolName,
276+
Description = $"Mock {ToolType} tool",
277+
InputSchema = doc.RootElement.Clone()
278+
};
279+
}
280+
281+
public Task<CallToolResult> ExecuteAsync(
282+
JsonDocument? arguments,
283+
IServiceProvider serviceProvider,
284+
CancellationToken cancellationToken = default)
285+
{
286+
// Not used in these tests
287+
throw new NotImplementedException();
288+
}
289+
}
290+
291+
#endregion Private helpers
292+
}
293+
}

0 commit comments

Comments
 (0)