diff --git a/.pipelines/cosmos-pipelines.yml b/.pipelines/cosmos-pipelines.yml index 61d066aac7..72f792b4d8 100644 --- a/.pipelines/cosmos-pipelines.yml +++ b/.pipelines/cosmos-pipelines.yml @@ -49,6 +49,8 @@ steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index ab79529030..fe03b5e549 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -42,6 +42,8 @@ jobs: steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from @@ -170,6 +172,8 @@ jobs: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index f5e330b073..753f896f6b 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -43,6 +43,8 @@ jobs: steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from @@ -174,6 +176,8 @@ jobs: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from diff --git a/.pipelines/mysql-pipelines.yml b/.pipelines/mysql-pipelines.yml index 35d1fb1ac4..155da99417 100644 --- a/.pipelines/mysql-pipelines.yml +++ b/.pipelines/mysql-pipelines.yml @@ -41,6 +41,8 @@ jobs: steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from diff --git a/.pipelines/pg-pipelines.yml b/.pipelines/pg-pipelines.yml index c98430a23f..3268f4435b 100644 --- a/.pipelines/pg-pipelines.yml +++ b/.pipelines/pg-pipelines.yml @@ -36,6 +36,8 @@ jobs: steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here. # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from diff --git a/.pipelines/templates/build-pipelines.yml b/.pipelines/templates/build-pipelines.yml index 5f166084a3..9f0dd06846 100644 --- a/.pipelines/templates/build-pipelines.yml +++ b/.pipelines/templates/build-pipelines.yml @@ -9,6 +9,8 @@ steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' # If this is a release, do not append the build number at the end as it will # generate the prerelease nuget version. diff --git a/.pipelines/templates/static-tools.yml b/.pipelines/templates/static-tools.yml index 14bc25764f..4d44fa08a2 100644 --- a/.pipelines/templates/static-tools.yml +++ b/.pipelines/templates/static-tools.yml @@ -16,6 +16,8 @@ jobs: steps: - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' + inputs: + nuGetServiceConnections: 'EngThriveNugetFeedAccessForSqlDab' - checkout: self # self represents the repo where the initial Pipelines YAML file was found clean: true # if true, execute `execute git clean -ffdx && git reset --hard HEAD` before fetching diff --git a/Nuget.config b/Nuget.config index 704c9d13ba..9d04e37970 100644 --- a/Nuget.config +++ b/Nuget.config @@ -3,7 +3,26 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj index c6a8b7bf21..78dcdef8ed 100644 --- a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj +++ b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj @@ -4,6 +4,8 @@ net8.0 enable enable + + $(NoWarn);NU1603 @@ -11,8 +13,7 @@ - - + diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index bcba1a50e4..5a3b2df506 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp.Core { @@ -16,80 +17,86 @@ namespace Azure.DataApiBuilder.Mcp.Core internal static class McpServerConfiguration { /// - /// Configures the MCP server with tool capabilities + /// Configures the MCP server with tool capabilities. /// internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services) { - services.AddMcpServer(options => + services.AddMcpServer() + .WithListToolsHandler((RequestContext request, CancellationToken ct) => { - options.ServerInfo = new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION }; - options.Capabilities = new() + McpToolRegistry? toolRegistry = request.Services?.GetRequiredService(); + if (toolRegistry == null) { - Tools = new() - { - ListToolsHandler = (request, ct) => - { - McpToolRegistry? toolRegistry = request.Services?.GetRequiredService(); - if (toolRegistry == null) - { - throw new InvalidOperationException("Tool registry is not available."); - } + throw new InvalidOperationException("Tool registry is not available."); + } - List tools = toolRegistry.GetAllTools().ToList(); + List tools = toolRegistry.GetAllTools().ToList(); - return ValueTask.FromResult(new ListToolsResult - { - Tools = tools - }); - }, - CallToolHandler = async (request, ct) => - { - McpToolRegistry? toolRegistry = request.Services?.GetRequiredService(); - if (toolRegistry == null) - { - throw new InvalidOperationException("Tool registry is not available."); - } - - string? toolName = request.Params?.Name; - if (string.IsNullOrEmpty(toolName)) - { - throw new McpException("Tool name is required."); - } + return ValueTask.FromResult(new ListToolsResult + { + Tools = tools + }); + }) + .WithCallToolHandler(async (RequestContext request, CancellationToken ct) => + { + McpToolRegistry? toolRegistry = request.Services?.GetRequiredService(); + if (toolRegistry == null) + { + throw new InvalidOperationException("Tool registry is not available."); + } - if (!toolRegistry.TryGetTool(toolName, out IMcpTool? tool)) - { - throw new McpException($"Unknown tool: '{toolName}'"); - } + string? toolName = request.Params?.Name; + if (string.IsNullOrEmpty(toolName)) + { + throw new McpException("Tool name is required."); + } - JsonDocument? arguments = null; - try - { - if (request.Params?.Arguments != null) - { - // Convert IReadOnlyDictionary to JsonDocument - Dictionary jsonObject = new(); - foreach (KeyValuePair kvp in request.Params.Arguments) - { - jsonObject[kvp.Key] = kvp.Value; - } + if (!toolRegistry.TryGetTool(toolName, out IMcpTool? tool)) + { + throw new McpException($"Unknown tool: '{toolName}'"); + } - string json = JsonSerializer.Serialize(jsonObject); - arguments = JsonDocument.Parse(json); - } + if (tool is null || request.Services is null) + { + throw new InvalidOperationException("Tool or service provider unexpectedly null."); + } - return await McpTelemetryHelper.ExecuteWithTelemetryAsync( - tool!, toolName, arguments, request.Services!, ct); - } - finally - { - arguments?.Dispose(); - } + JsonDocument? arguments = null; + try + { + if (request.Params?.Arguments != null) + { + // Convert IReadOnlyDictionary to JsonDocument + Dictionary jsonObject = new(); + foreach (KeyValuePair kvp in request.Params.Arguments) + { + jsonObject[kvp.Key] = kvp.Value; } + + string json = JsonSerializer.Serialize(jsonObject); + arguments = JsonDocument.Parse(json); } - }; + + return await McpTelemetryHelper.ExecuteWithTelemetryAsync( + tool, toolName, arguments, request.Services, ct); + } + finally + { + arguments?.Dispose(); + } }) .WithHttpTransport(); + // Configure underlying MCP server options defensively to avoid overwriting any defaults + services.PostConfigure(options => + { + options.ServerInfo ??= new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION }; + options.ServerInfo.Name = McpProtocolDefaults.MCP_SERVER_NAME; + options.ServerInfo.Version = McpProtocolDefaults.MCP_SERVER_VERSION; + options.Capabilities ??= new(); + options.Capabilities.Tools ??= new(); + }); + return services; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs index 0ba182b6db..626ddc9125 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs @@ -37,6 +37,14 @@ public void RegisterTool(IMcpTool tool) // Check for duplicate tool names (case-insensitive) if (_tools.TryGetValue(toolName, out IMcpTool? existingTool)) { + // If the same tool instance is already registered, skip silently. + // This can happen when both McpToolRegistryInitializer (hosted service) + // and McpStdioHelper register tools during stdio mode startup. + if (ReferenceEquals(existingTool, tool)) + { + return; + } + string existingToolType = existingTool.ToolType == ToolType.BuiltIn ? "built-in" : "custom"; string newToolType = tool.ToolType == ToolType.BuiltIn ? "built-in" : "custom"; diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs index 49cacef2c3..401f270f42 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs @@ -34,7 +34,7 @@ public static CallToolResult BuildSuccessResult( { Content = new List { - new TextContentBlock { Type = "text", Text = output } + new TextContentBlock { Text = output } } }; } @@ -67,7 +67,7 @@ public static CallToolResult BuildErrorResult( { Content = new List { - new TextContentBlock { Type = "text", Text = output } + new TextContentBlock { Text = output } }, IsError = true }; diff --git a/src/Core/Telemetry/TelemetryTracesHelper.cs b/src/Core/Telemetry/TelemetryTracesHelper.cs index a6b0ef2b0d..152a2e6e68 100644 --- a/src/Core/Telemetry/TelemetryTracesHelper.cs +++ b/src/Core/Telemetry/TelemetryTracesHelper.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Net; using Azure.DataApiBuilder.Config.ObjectModel; -using OpenTelemetry.Trace; using Kestral = Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpMethod; namespace Azure.DataApiBuilder.Core.Telemetry @@ -104,8 +103,8 @@ public static void TrackMainControllerActivityFinishedWithException( { if (activity.IsAllDataRequested) { - activity.SetStatus(Status.Error.WithDescription(ex.Message)); - activity.RecordException(ex); + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.AddException(ex); activity.SetTag("error.type", ex.GetType().Name); activity.SetTag("error.message", ex.Message); activity.SetTag("status.code", statusCode); @@ -174,7 +173,7 @@ public static void TrackMcpToolExecutionFinishedWithException( if (activity.IsAllDataRequested) { activity.SetStatus(ActivityStatusCode.Error, ex.Message); - activity.RecordException(ex); + activity.AddException(ex); activity.SetTag("error.type", ex.GetType().Name); activity.SetTag("error.message", ex.Message); diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 60b48241fc..441d8ae3b0 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -38,16 +38,15 @@ - - - - - - - + + + + + + - + diff --git a/src/Service.Tests/Mcp/McpToolRegistryTests.cs b/src/Service.Tests/Mcp/McpToolRegistryTests.cs index c8fa6a9768..7bbd91341c 100644 --- a/src/Service.Tests/Mcp/McpToolRegistryTests.cs +++ b/src/Service.Tests/Mcp/McpToolRegistryTests.cs @@ -141,6 +141,50 @@ public void RegisterTool_WithDifferentCasing_ThrowsException() Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); } + /// + /// Test that registering the same tool instance twice is silently ignored (idempotent). + /// This supports stdio mode where both McpToolRegistryInitializer and McpStdioHelper may register the same tools. + /// + [TestMethod] + public void RegisterTool_SameInstanceTwice_IsIdempotent() + { + // Arrange + McpToolRegistry registry = new(); + IMcpTool tool = new MockMcpTool("my_tool", ToolType.BuiltIn); + + // Act - Register the same instance twice + registry.RegisterTool(tool); + registry.RegisterTool(tool); + + // Assert - Tool should be registered only once + IEnumerable allTools = registry.GetAllTools(); + Assert.AreEqual(1, allTools.Count()); + } + + /// + /// Test that registering a different instance with the same name throws an exception, + /// even though a same-instance re-registration would be allowed. + /// + [TestMethod] + public void RegisterTool_DifferentInstanceSameName_ThrowsException() + { + // Arrange + McpToolRegistry registry = new(); + IMcpTool tool1 = new MockMcpTool("my_tool", ToolType.BuiltIn); + IMcpTool tool2 = new MockMcpTool("my_tool", ToolType.BuiltIn); + + // Act - Register first instance + registry.RegisterTool(tool1); + + // Assert - Different instance with same name should throw + DataApiBuilderException exception = Assert.ThrowsException( + () => registry.RegisterTool(tool2) + ); + + Assert.IsTrue(exception.Message.Contains("Duplicate MCP tool name 'my_tool' detected")); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); + } + /// /// Test that GetAllTools returns all registered tools. /// diff --git a/src/Service.Tests/UnitTests/McpTelemetryTests.cs b/src/Service.Tests/UnitTests/McpTelemetryTests.cs index 18c043d4dd..9a8130f012 100644 --- a/src/Service.Tests/UnitTests/McpTelemetryTests.cs +++ b/src/Service.Tests/UnitTests/McpTelemetryTests.cs @@ -104,7 +104,7 @@ private static CallToolResult CreateToolResult(string text = "result", bool isEr { return new CallToolResult { - Content = new List { new TextContentBlock { Type = "text", Text = text } }, + Content = new List { new TextContentBlock { Text = text } }, IsError = isError }; }