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
};
}