diff --git a/.gitignore b/.gitignore
index 4de9ba1a81..267f3af4bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ dab-config*.json
# Local-Only files
.env
+/docs/design/McpToolRegistryHotReload.md
# Verify test files
*.received.*
\ No newline at end of file
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs
index f688eeb80a..2d23c6585c 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs
@@ -2,7 +2,6 @@
// Licensed under the MIT License.
using Azure.DataApiBuilder.Config.ObjectModel;
-using Azure.DataApiBuilder.Mcp.Model;
using Microsoft.Extensions.Logging;
namespace Azure.DataApiBuilder.Mcp.Core
@@ -18,16 +17,16 @@ public class CustomMcpToolFactory
///
/// The runtime configuration containing entity definitions.
/// Optional logger for diagnostic information.
- /// Enumerable of custom tools generated from configuration.
- public static IEnumerable CreateCustomTools(RuntimeConfig config, ILogger? logger = null)
+ /// Enumerable of dynamic custom tools generated from configuration.
+ public static IEnumerable CreateCustomTools(RuntimeConfig config, ILogger? logger = null)
{
if (config.Entities == null)
{
logger?.LogWarning("No entities found in runtime configuration for custom tool generation.");
- return Enumerable.Empty();
+ return Enumerable.Empty();
}
- List customTools = new();
+ List customTools = new();
foreach ((string entityName, Entity entity) in config.Entities)
{
@@ -48,10 +47,11 @@ public static IEnumerable CreateCustomTools(RuntimeConfig config, ILog
}
catch (Exception ex)
{
- logger?.LogError(
- ex,
- "Failed to create custom tool for entity '{EntityName}'. Skipping.",
- entityName);
+ // Preserve entity context without logging here. The caller owns failure
+ // logging and can include whether startup failed or a snapshot was retained.
+ throw new InvalidOperationException(
+ $"Failed to create custom MCP tool for entity '{entityName}'.",
+ ex);
}
}
}
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
index 573e6ea6a9..1d78e81680 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
@@ -12,6 +12,7 @@
using Azure.DataApiBuilder.Core.Resolvers;
using Azure.DataApiBuilder.Core.Resolvers.Factories;
using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
using Azure.DataApiBuilder.Mcp.Model;
using Azure.DataApiBuilder.Mcp.Utils;
using Azure.DataApiBuilder.Service.Exceptions;
@@ -28,13 +29,8 @@ namespace Azure.DataApiBuilder.Mcp.Core
///
/// Dynamic custom MCP tool generated from stored procedure entity configuration.
/// Each custom tool represents a single stored procedure exposed as a dedicated MCP tool.
- ///
- /// Note: The entity configuration is captured at tool construction time. If the RuntimeConfig
- /// is hot-reloaded, GetToolMetadata() will return cached metadata (name, description, parameters)
- /// from the original configuration. This is acceptable because:
- /// 1. MCP clients typically call tools/list once at startup
- /// 2. ExecuteAsync always validates against the current runtime configuration
- /// 3. Cached metadata improves performance for repeated metadata requests
+ /// A new instance is created for each MCP registry generation so its cached metadata remains
+ /// aligned with the runtime configuration used to advertise it.
///
public class DynamicCustomTool : IMcpTool
{
@@ -50,6 +46,7 @@ public DynamicCustomTool(string entityName, Entity entity)
{
EntityName = entityName ?? throw new ArgumentNullException(nameof(entityName));
_entity = entity ?? throw new ArgumentNullException(nameof(entity));
+ ToolName = ConvertToToolName(entityName);
// Validate that this is a stored procedure
if (_entity.Source.Type != EntitySourceType.StoredProcedure)
@@ -65,6 +62,12 @@ public DynamicCustomTool(string entityName, Entity entity)
///
public ToolType ToolType { get; } = ToolType.Custom;
+ ///
+ /// Returns true because creates an instance only when
+ /// the source entity has mcp.custom-tool enabled for the candidate configuration.
+ /// Each registry generation recreates that membership, so an extant dynamic tool is
+ /// enabled by construction. Execution still revalidates enablement against current state.
+ ///
public bool IsEnabled(RuntimeConfig config) => true;
///
@@ -73,16 +76,38 @@ public DynamicCustomTool(string entityName, Entity entity)
public string EntityName { get; }
///
- /// Initializes the tool's input schema using DB metadata from the service provider.
- /// Called after DI initialization to enrich the tool schema with DB-discovered parameters
- /// and type information that aren't available at construction time.
- /// Falls back silently to config-based schema if DB metadata is unavailable.
+ /// Gets the normalized MCP tool name without materializing the complete metadata schema.
+ ///
+ internal string ToolName { get; }
+
+ ///
+ /// Initializes the input schema using an explicit configuration and metadata-provider
+ /// generation. Falls back to config-based metadata when database metadata is unavailable.
+ ///
+ public bool InitializeMetadata(
+ RuntimeConfig config,
+ IMetadataProviderFactory metadataProviderFactory)
+ {
+ return InitializeMetadata(config, metadataProviderFactory, out _);
+ }
+
+ ///
+ /// Initializes the input schema using an explicit configuration and metadata-provider
+ /// generation and reports why configuration metadata was used when database enrichment
+ /// is unavailable.
///
- /// The application service provider with initialized metadata providers.
- public void InitializeMetadata(IServiceProvider serviceProvider)
+ public bool InitializeMetadata(
+ RuntimeConfig config,
+ IMetadataProviderFactory metadataProviderFactory,
+ out string fallbackReason)
{
- ArgumentNullException.ThrowIfNull(serviceProvider);
- _cachedInputSchema = BuildInputSchemaFromDbMetadata(serviceProvider);
+ ArgumentNullException.ThrowIfNull(config);
+ ArgumentNullException.ThrowIfNull(metadataProviderFactory);
+ _cachedInputSchema = BuildInputSchemaFromDbMetadata(
+ config,
+ metadataProviderFactory,
+ out fallbackReason);
+ return _cachedInputSchema.HasValue;
}
///
@@ -90,15 +115,14 @@ public void InitializeMetadata(IServiceProvider serviceProvider)
///
public Tool GetToolMetadata()
{
- string toolName = ConvertToToolName(EntityName);
- string description = _entity.Description ?? $"Executes the {toolName} stored procedure";
+ string description = _entity.Description ?? $"Executes the {ToolName} stored procedure";
// Build input schema based on parameters
JsonElement inputSchema = BuildInputSchema();
return new Tool
{
- Name = toolName,
+ Name = ToolName,
Description = description,
InputSchema = inputSchema
};
@@ -113,7 +137,7 @@ public async Task ExecuteAsync(
CancellationToken cancellationToken = default)
{
ILogger? logger = serviceProvider.GetService>();
- string toolName = GetToolMetadata().Name;
+ string toolName = ToolName;
try
{
@@ -259,6 +283,10 @@ public async Task ExecuteAsync(
cancellationToken.ThrowIfCancellationRequested();
queryResult = await queryEngine.ExecuteAsync(context, dataSourceName).ConfigureAwait(false);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (DataApiBuilderException dabEx)
{
logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, EntityName);
@@ -322,33 +350,32 @@ private JsonElement BuildInputSchema()
/// Builds the input schema from DB metadata (StoredProcedureDefinition.Parameters).
/// Returns null if metadata cannot be resolved (caller should fall back to config-based schema).
///
- private JsonElement? BuildInputSchemaFromDbMetadata(IServiceProvider serviceProvider)
+ private JsonElement? BuildInputSchemaFromDbMetadata(
+ RuntimeConfig config,
+ IMetadataProviderFactory metadataProviderFactory,
+ out string fallbackReason)
{
- RuntimeConfigProvider? configProvider = serviceProvider.GetService();
- if (configProvider is null)
- {
- return null;
- }
-
- RuntimeConfig config = configProvider.GetConfig();
-
if (!McpMetadataHelper.TryResolveMetadata(
EntityName,
config,
- serviceProvider,
+ metadataProviderFactory,
out _,
out DatabaseObject dbObject,
out _,
- out _))
+ out fallbackReason))
{
return null;
}
if (dbObject is not DatabaseStoredProcedure storedProcedure)
{
+ fallbackReason =
+ $"Database object '{dbObject.FullName}' for entity '{EntityName}' is not a stored procedure.";
return null;
}
+ fallbackReason = string.Empty;
+
StoredProcedureDefinition spDefinition = storedProcedure.StoredProcedureDefinition;
if (spDefinition.Parameters is null || spDefinition.Parameters.Count == 0)
{
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
index 20040588fe..f40497a0bb 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
@@ -2,8 +2,6 @@
// Licensed under the MIT License.
using System.Text.Json;
-using Azure.DataApiBuilder.Config.ObjectModel;
-using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Mcp.Model;
using Azure.DataApiBuilder.Mcp.Utils;
using Microsoft.Extensions.DependencyInjection;
@@ -32,13 +30,9 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se
throw new InvalidOperationException("Tool registry is not available.");
}
- RuntimeConfigProvider runtimeConfigProvider = request.Services!.GetRequiredService();
- RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
- List tools = toolRegistry.GetEnabledTools(runtimeConfig).ToList();
-
return ValueTask.FromResult(new ListToolsResult
{
- Tools = tools
+ Tools = toolRegistry.GetAdvertisedTools().ToList()
});
})
.WithCallToolHandler(async (RequestContext request, CancellationToken ct) =>
@@ -97,6 +91,10 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se
options.ServerInfo = new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION };
options.Capabilities ??= new();
options.Capabilities.Tools ??= new();
+ // WithListToolsHandler enables tool discovery, but HTTP session broadcast is not
+ // implemented. Do not promise list-change notifications to HTTP clients. Stdio
+ // advertises and implements this capability in its separate initialize handler.
+ options.Capabilities.Tools.ListChanged = false;
options.ServerInstructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null;
});
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
index c88cae148d..e22490d110 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Mcp.Model;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
namespace Azure.DataApiBuilder.Mcp.Core
{
@@ -33,14 +34,17 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service
// Register core MCP services
services.AddSingleton();
- services.AddHostedService();
+ services.AddSingleton();
+ services.AddSingleton(serviceProvider =>
+ serviceProvider.GetRequiredService());
+ services.AddSingleton(serviceProvider =>
+ serviceProvider.GetRequiredService());
- // Auto-discover and register all MCP tools
+ // Auto-discover MCP tool implementations from this assembly. Configuration-generated
+ // DynamicCustomTool objects are created separately by McpToolRegistryRefreshService;
+ // independently registered IMcpTool extensions remain in DI across generations.
RegisterAllMcpTools(services);
- // Register custom tools from configuration
- RegisterCustomTools(services, runtimeConfig);
-
// Configure MCP server and propagate runtime description to MCP initialize instructions.
services.ConfigureMcpServer(runtimeConfig.Runtime?.Mcp?.Description);
@@ -66,16 +70,5 @@ private static void RegisterAllMcpTools(IServiceCollection services)
}
}
- ///
- /// Registers custom MCP tools generated from stored procedure entity configurations.
- ///
- private static void RegisterCustomTools(IServiceCollection services, RuntimeConfig config)
- {
- // Create custom tools and register each as a singleton
- foreach (IMcpTool customTool in CustomMcpToolFactory.CreateCustomTools(config))
- {
- services.AddSingleton(customTool);
- }
- }
}
}
diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
index aef3b63dcc..0f25f3b253 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
@@ -28,6 +28,7 @@ public class McpStdioServer : IMcpStdioServer
private readonly McpToolRegistry _toolRegistry;
private readonly IServiceProvider _serviceProvider;
private readonly McpStdoutWriter _stdoutWriter;
+ private readonly IMcpStdioToolListChangedNotifier? _toolListChangedNotifier;
private readonly TextReader? _inputReader;
private readonly string _protocolVersion;
@@ -50,6 +51,7 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
// notifications/message frames are serialized through one lock.
// Falls back to a fresh instance if DI didn't register one (defensive).
_stdoutWriter = _serviceProvider.GetService() ?? new McpStdoutWriter();
+ _toolListChangedNotifier = _serviceProvider.GetService();
// Allow protocol version to be configured via IConfiguration, using centralized defaults.
IConfiguration? configuration = _serviceProvider.GetService();
@@ -66,6 +68,7 @@ public async Task RunAsync(CancellationToken cancellationToken)
// By default read via Console.In so the loop honors the configured
// Console.InputEncoding in stdio mode.
TextReader reader = _inputReader ?? Console.In;
+ bool initializeResponseWritten = false;
while (!cancellationToken.IsCancellationRequested)
{
@@ -128,9 +131,20 @@ public async Task RunAsync(CancellationToken cancellationToken)
{
case "initialize":
HandleInitialize(id, root);
+ // This assignment is reached only after WriteResult succeeds.
+ initializeResponseWritten = true;
break;
case "notifications/initialized":
+ // This notification completes the MCP handshake only after the
+ // server successfully wrote its initialize response. Ignore an
+ // out-of-order notification rather than enabling capabilities the
+ // client has not negotiated.
+ if (initializeResponseWritten)
+ {
+ _toolListChangedNotifier?.MarkInitialized();
+ }
+
break;
case "tools/list":
@@ -183,6 +197,7 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
string? clientRequestedProtocolVersion = GetClientProtocolVersion(root);
string negotiatedProtocolVersion =
McpProtocolDefaults.ResolveInitializeResponseProtocolVersion(_protocolVersion, clientRequestedProtocolVersion);
+ bool supportsToolListChanged = _toolListChangedNotifier is not null;
// Get the description from runtime config if available
string? description = null;
@@ -212,7 +227,7 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
protocolVersion = negotiatedProtocolVersion,
capabilities = new
{
- tools = new { listChanged = true },
+ tools = new { listChanged = supportsToolListChanged },
logging = new { }
},
serverInfo = new
@@ -230,7 +245,7 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
protocolVersion = negotiatedProtocolVersion,
capabilities = new
{
- tools = new { listChanged = true },
+ tools = new { listChanged = supportsToolListChanged },
logging = new { }
},
serverInfo = new
@@ -248,7 +263,7 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
protocolVersion = negotiatedProtocolVersion,
capabilities = new
{
- tools = new { listChanged = true },
+ tools = new { listChanged = supportsToolListChanged },
logging = new { }
},
serverInfo = new
@@ -287,16 +302,9 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
private void HandleListTools(JsonElement? id)
{
List