Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b2e0d65
docs(mcp): design tool registry hot reload
aaronburtle Jul 30, 2026
d572ff8
refactor(mcp): add atomic tool registry snapshots
aaronburtle Jul 30, 2026
8a3ce27
feat(mcp): refresh tool registry on config reload
aaronburtle Jul 30, 2026
d6f49af
feat(mcp): notify stdio clients when tools change
aaronburtle Jul 31, 2026
a45bb41
fix(mcp): initialize tool schemas after metadata
aaronburtle Jul 31, 2026
7d9b1e4
fix(mcp): enforce registry discovery invariants
aaronburtle Jul 31, 2026
3724bf9
test(mcp): cover registry reload transports and failures
aaronburtle Jul 31, 2026
5dc1e83
fix(config): serialize hot reload generations
aaronburtle Jul 31, 2026
4c702c2
fix(mcp): serialize initial registry construction
aaronburtle Jul 31, 2026
35e6325
fix(mcp): harden registry notifications and APIs
aaronburtle Jul 31, 2026
95f0ca6
improve code quality, eliminate unused methods
aaronburtle Jul 31, 2026
3c450ce
cleanup code remove more unused code
aaronburtle Jul 31, 2026
2793e80
Merge remote-tracking branch 'origin/main' into dev/aaronburtle/tool-…
aaronburtle Jul 31, 2026
57d08c5
fix(mcp): address hot-reload review findings
aaronburtle Jul 31, 2026
77436d6
dont block on shutdown
aaronburtle Jul 31, 2026
a15cdb5
add a proper shutdown mechanism
aaronburtle Jul 31, 2026
ae624e6
Merge main and harden MCP hot-reload lifecycle
aaronburtle Aug 1, 2026
4fe5bf3
fix: finalize reload and query cancellation cleanup
aaronburtle Aug 1, 2026
f34bb48
docs: record metadata provider cancellation break
aaronburtle Aug 1, 2026
6408539
fix(mcp): ignore schema set ordering in discovery
aaronburtle Aug 1, 2026
06fbe87
style(mcp): resolve formatter diagnostics
aaronburtle Aug 1, 2026
662aec9
style: fix newline and import ordering
aaronburtle Aug 1, 2026
08004e2
fix(tests): update cancellation and reload assertions
aaronburtle Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,141 changes: 1,141 additions & 0 deletions docs/design/McpToolRegistryHotReload.md

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,16 +17,16 @@ public class CustomMcpToolFactory
/// </summary>
/// <param name="config">The runtime configuration containing entity definitions.</param>
/// <param name="logger">Optional logger for diagnostic information.</param>
/// <returns>Enumerable of custom tools generated from configuration.</returns>
public static IEnumerable<IMcpTool> CreateCustomTools(RuntimeConfig config, ILogger? logger = null)
/// <returns>Enumerable of dynamic custom tools generated from configuration.</returns>
public static IEnumerable<DynamicCustomTool> 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<IMcpTool>();
return Enumerable.Empty<DynamicCustomTool>();
}

List<IMcpTool> customTools = new();
List<DynamicCustomTool> customTools = new();

foreach ((string entityName, Entity entity) in config.Entities)
{
Expand All @@ -48,10 +47,11 @@ public static IEnumerable<IMcpTool> 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);
}
}
}
Expand Down
87 changes: 57 additions & 30 deletions src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,13 +29,8 @@ namespace Azure.DataApiBuilder.Mcp.Core
/// <summary>
/// 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.
/// </summary>
public class DynamicCustomTool : IMcpTool
{
Expand All @@ -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)
Expand All @@ -65,6 +62,12 @@ public DynamicCustomTool(string entityName, Entity entity)
/// </summary>
public ToolType ToolType { get; } = ToolType.Custom;

/// <summary>
/// Returns true because <see cref="CustomMcpToolFactory"/> creates an instance only when
/// the source entity has <c>mcp.custom-tool</c> 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.
/// </summary>
public bool IsEnabled(RuntimeConfig config) => true;

/// <summary>
Expand All @@ -73,32 +76,53 @@ public DynamicCustomTool(string entityName, Entity entity)
public string EntityName { get; }

/// <summary>
/// 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.
/// </summary>
internal string ToolName { get; }

/// <summary>
/// Initializes the input schema using an explicit configuration and metadata-provider
/// generation. Falls back to config-based metadata when database metadata is unavailable.
/// </summary>
public bool InitializeMetadata(
RuntimeConfig config,
IMetadataProviderFactory metadataProviderFactory)
{
return InitializeMetadata(config, metadataProviderFactory, out _);
}

/// <summary>
/// Initializes the input schema using an explicit configuration and metadata-provider
/// generation and reports why configuration metadata was used when database enrichment
/// is unavailable.
/// </summary>
/// <param name="serviceProvider">The application service provider with initialized metadata providers.</param>
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;
}

/// <summary>
/// Gets the metadata for this custom tool, including name, description, and input schema.
/// </summary>
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
};
Expand All @@ -113,7 +137,7 @@ public async Task<CallToolResult> ExecuteAsync(
CancellationToken cancellationToken = default)
{
ILogger<DynamicCustomTool>? logger = serviceProvider.GetService<ILogger<DynamicCustomTool>>();
string toolName = GetToolMetadata().Name;
string toolName = ToolName;

try
{
Expand Down Expand Up @@ -259,6 +283,10 @@ public async Task<CallToolResult> 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);
Expand Down Expand Up @@ -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).
/// </summary>
private JsonElement? BuildInputSchemaFromDbMetadata(IServiceProvider serviceProvider)
private JsonElement? BuildInputSchemaFromDbMetadata(
RuntimeConfig config,
IMetadataProviderFactory metadataProviderFactory,
out string fallbackReason)
{
RuntimeConfigProvider? configProvider = serviceProvider.GetService<RuntimeConfigProvider>();
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)
{
Expand Down
12 changes: 5 additions & 7 deletions src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,13 +30,9 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se
throw new InvalidOperationException("Tool registry is not available.");
}

RuntimeConfigProvider runtimeConfigProvider = request.Services!.GetRequiredService<RuntimeConfigProvider>();
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
List<Tool> tools = toolRegistry.GetEnabledTools(runtimeConfig).ToList();

return ValueTask.FromResult(new ListToolsResult
{
Tools = tools
Tools = toolRegistry.GetAdvertisedTools().ToList()
});
})
.WithCallToolHandler(async (RequestContext<CallToolRequestParams> request, CancellationToken ct) =>
Expand Down Expand Up @@ -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;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -33,14 +34,17 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service

// Register core MCP services
services.AddSingleton<McpToolRegistry>();
services.AddHostedService<McpToolRegistryInitializer>();
services.AddSingleton<McpToolRegistryRefreshService>();
services.AddSingleton<IMcpToolRegistryRefreshService>(serviceProvider =>
serviceProvider.GetRequiredService<McpToolRegistryRefreshService>());
services.AddSingleton<IHostedService>(serviceProvider =>
serviceProvider.GetRequiredService<McpToolRegistryRefreshService>());

// 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);

Expand All @@ -66,16 +70,5 @@ private static void RegisterAllMcpTools(IServiceCollection services)
}
}

/// <summary>
/// Registers custom MCP tools generated from stored procedure entity configurations.
/// </summary>
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<IMcpTool>(customTool);
}
}
}
}
30 changes: 19 additions & 11 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<McpStdoutWriter>() ?? new McpStdoutWriter();
_toolListChangedNotifier = _serviceProvider.GetService<IMcpStdioToolListChangedNotifier>();

// Allow protocol version to be configured via IConfiguration, using centralized defaults.
IConfiguration? configuration = _serviceProvider.GetService<IConfiguration>();
Expand All @@ -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)
{
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -287,16 +302,9 @@ private void HandleInitialize(JsonElement? id, JsonElement root)
private void HandleListTools(JsonElement? id)
{
List<object> toolsWire = new();
int count = 0;

// Resolve runtime config to filter out disabled tools.
RuntimeConfigProvider runtimeConfigProvider = _serviceProvider.GetRequiredService<RuntimeConfigProvider>();
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
IEnumerable<Tool> tools = _toolRegistry.GetEnabledTools(runtimeConfig);

foreach (Tool tool in tools)
foreach (Tool tool in _toolRegistry.GetAdvertisedTools())
{
count++;
toolsWire.Add(new
{
name = tool.Name,
Expand Down
Loading