From 28980767400553c4713b89436807bfbe028bcbbe Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Wed, 27 Aug 2025 15:06:14 -0600 Subject: [PATCH 01/63] Just before logging --- .../Azure.DataApiBuilder.Mcp.csproj | 18 +++++++ .../StartupExtensions.cs | 48 +++++++++++++++++++ src/Azure.DataApiBuilder.sln | 6 +++ src/Directory.Packages.props | 2 + .../Azure.DataApiBuilder.Service.csproj | 3 +- src/Service/Startup.cs | 5 ++ 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj create mode 100644 src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj new file mode 100644 index 0000000000..4aecc24710 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs b/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs new file mode 100644 index 0000000000..483037eba6 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp +{ + public class Jerry { } + + public static class StartupExtensions + { + public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider, ILoggerFactory loggerFactory) + { + ILogger logger = loggerFactory.CreateLogger(); + + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + logger.LogError("Can't get config."); + } + + services + .AddMcpServer() + .WithToolsFromAssembly() + .WithHttpTransport(); + return services; + } + + public static IEndpointRouteBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + { + endpoints.MapMcp(); + return endpoints; + } + } + + [McpServerToolType] + public static class Tools + { + [McpServerTool] + public static string Echo(string message) => message; + } +} diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index e7f61fa3ed..aa3c8e2bad 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -31,6 +31,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Core", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Product", "Product\Azure.DataApiBuilder.Product.csproj", "{E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,10 @@ Global {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Release|Any CPU.Build.0 = Release|Any CPU + {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index da600c9f63..3ae4ef02b3 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -28,6 +28,8 @@ + + diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index 9f1558e504..6ea9c8dad2 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -102,6 +102,7 @@ + diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index ce6b3077a4..702e41bf06 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -24,6 +24,7 @@ using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Core.Telemetry; +using Azure.DataApiBuilder.Mcp; using Azure.DataApiBuilder.Service.Controllers; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.HealthCheck; @@ -238,6 +239,10 @@ public void ConfigureServices(IServiceCollection services) return loggerFactory.CreateLogger(); }); + ILoggerFactory? x = CreateLoggerFactoryForHostedAndNonHostedScenario(serviceProvider, logLevelInit); + services.AddDabMcpServer(configProvider, x); + + services.AddSingleton>(implementationFactory: (serviceProvider) => { LogLevelInitializer logLevelInit = new(MinimumLogLevel, typeof(ISqlMetadataProvider).FullName, _configProvider, _hotReloadEventHandler); From 17e97281f59712ce4e639177164e52a3a4e0f0f5 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 10:02:49 -0600 Subject: [PATCH 02/63] Rearrange --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 42 +++++++++++ .../Health/CheckResult.cs | 25 +++++++ .../Health/Extensions.cs | 42 +++++++++++ .../Health/McpCheck.cs | 72 +++++++++++++++++++ .../StartupExtensions.cs | 48 ------------- .../Tools/DmlTools.cs | 33 +++++++++ .../Tools/Extensions.cs | 33 +++++++++ src/Config/ObjectModel/RuntimeConfig.cs | 19 +++++ src/Service/Startup.cs | 11 +-- 9 files changed, 273 insertions(+), 52 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Extensions.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs new file mode 100644 index 0000000000..89afa01414 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +using Azure.DataApiBuilder.Mcp.Health; +using Azure.DataApiBuilder.Mcp.Tools; + +namespace Azure.DataApiBuilder.Mcp +{ + public static class Extensions + { + private static McpOptions _mcpOptions = default!; + + public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) + { + if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); + } + + services.AddDmlTools(_mcpOptions); + + IMcpServerBuilder mcp = services.AddMcpServer(); + mcp.WithHttpTransport(); + return services; + } + + + public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + { + endpoints.MapMcp(); + endpoints.MapDabHealthChecks("/jerry"); + return endpoints; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs new file mode 100644 index 0000000000..ba1ce1a6b0 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Health; + +public record CheckResult(string Name, bool IsHealthy, string? Message, Dictionary Tags) +{ + public string Status => IsHealthy ? "Healthy" : "Unhealthy"; + + public object ToReport() + { + return IsHealthy ? new + { + Name, + Status, + Tags + } : new + { + Name, + Status, + Tags, + Message + }; + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs new file mode 100644 index 0000000000..c9a98c3048 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Azure.DataApiBuilder.Mcp.Health; + +public static class Extensions +{ + public static IEndpointRouteBuilder MapDabHealthChecks(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + { + endpoints.MapMcp(); + endpoints.MapHealthChecks(pattern, new() + { + ResponseWriter = async (context, report) => + { + CheckResult mcpCheck = await McpCheck.CheckAsync(context.RequestServices); + + var response = new + { + Status = mcpCheck.IsHealthy ? "Healthy" : "Unhealthy", + Timestamp = DateTime.UtcNow, + Checks = new object[] { + mcpCheck.ToReport() + } + }; + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + })); + } + }); + return endpoints; + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs new file mode 100644 index 0000000000..92894c237f --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Azure.DataApiBuilder.Mcp.Health; + +public class McpCheck +{ + private readonly static string _name = "MCP Server Tools"; + + public static async Task CheckAsync(IServiceProvider serviceProvider) + { + try + { + ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); + + // Get the MCP server endpoint + HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; + if (request == null) + { + return new CheckResult( + Name: _name, + IsHealthy: false, + Message: "HttpContext not available", + Tags: [] + ); + } + + string scheme = request.Scheme; + string host = request.Host.Value; + string endpoint = $"{scheme}://{host}"; + + IMcpClient mcpClient = await McpClientFactory.CreateAsync( + new SseClientTransport(new() + { + Endpoint = new Uri(endpoint), + Name = "HealthCheck" + }), + clientOptions: new() + { + Capabilities = new() { } + }, + loggerFactory: loggerFactory); + + IList mcpTools = await mcpClient.ListToolsAsync(); + string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); + + return new CheckResult( + Name: _name, + IsHealthy: toolNames.Length != 0, + Message: toolNames.Length != 0 ? "Okay" : "No tools found", + Tags: new Dictionary + { + { "endpoint", endpoint }, + { "tools", string.Join(", ", toolNames) } + }); + } + catch (Exception ex) + { + return new CheckResult( + Name: _name, + IsHealthy: false, + Message: ex.Message, + Tags: new Dictionary() + ); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs b/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs deleted file mode 100644 index 483037eba6..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/StartupExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Configurations; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp -{ - public class Jerry { } - - public static class StartupExtensions - { - public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider, ILoggerFactory loggerFactory) - { - ILogger logger = loggerFactory.CreateLogger(); - - if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) - { - logger.LogError("Can't get config."); - } - - services - .AddMcpServer() - .WithToolsFromAssembly() - .WithHttpTransport(); - return services; - } - - public static IEndpointRouteBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") - { - endpoints.MapMcp(); - return endpoints; - } - } - - [McpServerToolType] - public static class Tools - { - [McpServerTool] - public static string Echo(string message) => message; - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs new file mode 100644 index 0000000000..58cc3569e6 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using System.Diagnostics; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +[McpServerToolType] +public static class DmlTools +{ + private static readonly ILogger _logger; + + static DmlTools() + { + _logger = LoggerFactory.Create(builder => + { + builder.AddConsole(); + }).CreateLogger(nameof(DmlTools)); + } + + [McpServerTool] + public static string Echo(string message) + { + _logger.LogInformation("Echo tool called with message: {message}", message); + using (Activity activity = new("MCP")) + { + activity.SetTag("tool", nameof(Echo)); + return message; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs new file mode 100644 index 0000000000..8388e835df --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static class Extensions +{ + public static void AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) + { + HashSet DmlToolNames = mcpOptions.DmlTools + .Select(x => x.ToString()).ToHashSet(); + + foreach (MethodInfo method in typeof(DmlTools).GetMethods()) + { + if (DmlToolNames.Contains(method.Name)) + { + services.AddMcpTool(method); + } + } + } + + public static void AddMcpTool(this IServiceCollection services, MethodInfo method) + { + Func factory = (services) => McpServerTool + .Create(method, options: new() { Services = services, SerializerOptions = default }); + _ = services.AddSingleton(factory); + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 7cf8159952..5f4176b797 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,8 +11,27 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; +public record AiOptions +{ + public McpOptions? Mcp { get; init; } = new(); +} + +public record McpOptions +{ + public bool Enabled { get; init; } = true; + public string Path { get; init; } = "/mcp"; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.Echo]; +} + +public enum McpDmlTool +{ + Echo +} + public record RuntimeConfig { + public AiOptions? Ai { get; init; } = new(); + [JsonPropertyName("$schema")] public string Schema { get; init; } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 702e41bf06..d147bdc80a 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -239,10 +239,6 @@ public void ConfigureServices(IServiceCollection services) return loggerFactory.CreateLogger(); }); - ILoggerFactory? x = CreateLoggerFactoryForHostedAndNonHostedScenario(serviceProvider, logLevelInit); - services.AddDabMcpServer(configProvider, x); - - services.AddSingleton>(implementationFactory: (serviceProvider) => { LogLevelInitializer logLevelInit = new(MinimumLogLevel, typeof(ISqlMetadataProvider).FullName, _configProvider, _hotReloadEventHandler); @@ -457,6 +453,10 @@ public void ConfigureServices(IServiceCollection services) } services.AddSingleton(); + + // special for MCP + services.AddDabMcpServer(configProvider); + services.AddControllers(); } @@ -680,6 +680,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC { endpoints.MapControllers(); + // Special for MCP + endpoints.MapDabMcp(); + endpoints .MapGraphQL() .WithOptions(new GraphQLServerOptions From 6e1abe5d267be86122f56126b68bc8d257e8d3b0 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 10:19:16 -0600 Subject: [PATCH 03/63] git should be ignored --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 56bd0e435d..a922bf369d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ dab-config*.json .env # Verify test files -*.received.* \ No newline at end of file +*.received.* +/src/git From 26b40c1a9866f3522a53cfa774423df9396529c0 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 10:38:48 -0600 Subject: [PATCH 04/63] Testing GQL Schema --- .../Tools/DmlTools.cs | 44 +++++++++++++++++++ src/Config/ObjectModel/RuntimeConfig.cs | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 58cc3569e6..2490c81de3 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -4,6 +4,9 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using System.Diagnostics; +using HotChocolate.Execution; +using HotChocolate; +using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Tools; @@ -30,4 +33,45 @@ public static string Echo(string message) return message; } } + + [McpServerTool] + public static async Task GetGraphQLSchema(IServiceProvider services) + { + _logger.LogInformation("GetGraphQLSchema tool called"); + + using (Activity activity = new("MCP")) + { + activity.SetTag("tool", nameof(GetGraphQLSchema)); + + try + { + // Get the GraphQL request executor resolver from services + IRequestExecutorResolver? requestExecutorResolver = services.GetService(typeof(IRequestExecutorResolver)) as IRequestExecutorResolver; + + if (requestExecutorResolver == null) + { + _logger.LogWarning("IRequestExecutorResolver not found in service container"); + return "IRequestExecutorResolver not available"; + } + + // Get the GraphQL request executor + IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); + + // Get the schema from the request executor + ISchema schema = requestExecutor.Schema; + + // Return the schema as SDL (Schema Definition Language) + string schemaString = schema.ToString(); + + _logger.LogInformation("Successfully retrieved GraphQL schema with {length} characters", schemaString.Length); + + return schemaString; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve GraphQL schema"); + return $"Error retrieving GraphQL schema: {ex.Message}"; + } + } + } } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 5f4176b797..277440ffbc 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -25,7 +25,8 @@ public record McpOptions public enum McpDmlTool { - Echo + Echo, + GetGraphQLSchema } public record RuntimeConfig From a0177721ab6b6045dce6b0fcc623f45c2a5093c8 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 11:53:40 -0600 Subject: [PATCH 05/63] broken but nice --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 6 +- .../Health/Extensions.cs | 8 +- .../Health/McpCheck.cs | 152 ++++++++++++++++-- .../Tools/DmlTools.cs | 17 +- .../Tools/Extensions.cs | 23 ++- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 6 files changed, 168 insertions(+), 40 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 89afa01414..257c601ec4 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -26,8 +26,10 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service services.AddDmlTools(_mcpOptions); - IMcpServerBuilder mcp = services.AddMcpServer(); - mcp.WithHttpTransport(); + services + .AddMcpServer() + .WithHttpTransport(); + return services; } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs index c9a98c3048..3a1e77c22f 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -18,15 +18,13 @@ public static IEndpointRouteBuilder MapDabHealthChecks(this IEndpointRouteBuilde { ResponseWriter = async (context, report) => { - CheckResult mcpCheck = await McpCheck.CheckAsync(context.RequestServices); + CheckResult[] mcpChecks = await McpCheck.CheckAllAsync(context.RequestServices); var response = new { - Status = mcpCheck.IsHealthy ? "Healthy" : "Unhealthy", + Status = mcpChecks.All(c => c.IsHealthy) ? "Healthy" : "Unhealthy", Timestamp = DateTime.UtcNow, - Checks = new object[] { - mcpCheck.ToReport() - } + Checks = mcpChecks.Select(c => c.ToReport()).ToArray() }; context.Response.ContentType = "application/json"; diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs index 92894c237f..eeb6ba63ec 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -4,29 +4,97 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; using ModelContextProtocol.Client; namespace Azure.DataApiBuilder.Mcp.Health; public class McpCheck { - private readonly static string _name = "MCP Server Tools"; + private readonly static string _serviceRegistrationCheckName = "MCP Server Tools - Service Registration"; + private readonly static string _clientConnectionCheckName = "MCP Server Tools - Client Connection"; - public static async Task CheckAsync(IServiceProvider serviceProvider) + /// + /// Performs comprehensive MCP health checks including both service registration and client connection + /// + public static async Task CheckAllAsync(IServiceProvider serviceProvider) + { + CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); + CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); + + return new[] { serviceRegistrationCheck, clientConnectionCheck }; + } + + /// + /// Checks if MCP tools are properly registered in the service provider + /// + public static CheckResult CheckServiceRegistration(IServiceProvider serviceProvider) + { + try + { + ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); + ILogger logger = loggerFactory.CreateLogger(); + + logger.LogInformation("Checking MCP server tools service registration"); + + // Check if MCP tools are registered in the service provider + IEnumerable mcpTools = serviceProvider.GetServices(); + string[] toolNames = mcpTools + .Select(t => t.ProtocolTool.Name) + .OrderBy(t => t).ToArray(); + + logger.LogInformation("Found {ToolCount} registered MCP tools in services: {Tools}", toolNames.Length, string.Join(", ", toolNames)); + + return new CheckResult( + Name: _serviceRegistrationCheckName, + IsHealthy: toolNames.Length != 0, + Message: toolNames.Length != 0 ? "Tools registered in services" : "No tools registered in services", + Tags: new Dictionary + { + { "check_type", "service_registration" }, + { "tools", string.Join(", ", toolNames) }, + { "tool_count", toolNames.Length.ToString() } + }); + } + catch (Exception ex) + { + return new CheckResult( + Name: _serviceRegistrationCheckName, + IsHealthy: false, + Message: $"Service registration check failed: {ex.Message}", + Tags: new Dictionary + { + { "check_type", "service_registration" }, + { "error_type", "general_error" }, + { "error_message", ex.Message } + } + ); + } + } + + /// + /// Checks if MCP client can successfully connect and list tools from the MCP server + /// + public static async Task CheckClientConnectionAsync(IServiceProvider serviceProvider) { try { ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); + ILogger logger = loggerFactory.CreateLogger(); // Get the MCP server endpoint HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; if (request == null) { return new CheckResult( - Name: _name, + Name: _clientConnectionCheckName, IsHealthy: false, - Message: "HttpContext not available", - Tags: [] + Message: "HttpContext not available for client connection test", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "no_http_context" } + } ); } @@ -34,6 +102,8 @@ public static async Task CheckAsync(IServiceProvider serviceProvide string host = request.Host.Value; string endpoint = $"{scheme}://{host}"; + logger.LogInformation("Testing MCP client connection to endpoint: {Endpoint}", endpoint); + IMcpClient mcpClient = await McpClientFactory.CreateAsync( new SseClientTransport(new() { @@ -46,27 +116,87 @@ public static async Task CheckAsync(IServiceProvider serviceProvide }, loggerFactory: loggerFactory); + logger.LogInformation("MCP client created successfully, listing tools..."); + IList mcpTools = await mcpClient.ListToolsAsync(); string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); + logger.LogInformation("Found {ToolCount} tools via MCP client: {Tools}", toolNames.Length, string.Join(", ", toolNames)); + return new CheckResult( - Name: _name, + Name: _clientConnectionCheckName, IsHealthy: toolNames.Length != 0, - Message: toolNames.Length != 0 ? "Okay" : "No tools found", + Message: toolNames.Length != 0 ? "Client successfully connected and listed tools" : "Client connected but no tools found", Tags: new Dictionary { + { "check_type", "client_connection" }, { "endpoint", endpoint }, - { "tools", string.Join(", ", toolNames) } + { "tools", string.Join(", ", toolNames) }, + { "tool_count", toolNames.Length.ToString() } }); } + catch (HttpRequestException httpEx) when (httpEx.Message.Contains("500")) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: "MCP SSE endpoint returned 500 error - endpoint may not be properly configured", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "http_500" }, + { "error_message", httpEx.Message } + } + ); + } + catch (HttpRequestException httpEx) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: $"HTTP error connecting to MCP server: {httpEx.Message}", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "http_error" }, + { "error_message", httpEx.Message } + } + ); + } + catch (TaskCanceledException tcEx) when (tcEx.InnerException is TimeoutException) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: "Timeout connecting to MCP server", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "timeout" } + } + ); + } catch (Exception ex) { return new CheckResult( - Name: _name, + Name: _clientConnectionCheckName, IsHealthy: false, - Message: ex.Message, - Tags: new Dictionary() + Message: $"Client connection check failed: {ex.Message}", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "general_error" }, + { "error_message", ex.Message } + } ); } } + + /// + /// Legacy method for backward compatibility - returns service registration check + /// + public static Task CheckAsync(IServiceProvider serviceProvider) + { + return Task.FromResult(CheckServiceRegistration(serviceProvider)); + } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 2490c81de3..6b3e1b0f14 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using HotChocolate.Execution; using HotChocolate; -using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Tools; @@ -38,33 +37,33 @@ public static string Echo(string message) public static async Task GetGraphQLSchema(IServiceProvider services) { _logger.LogInformation("GetGraphQLSchema tool called"); - + using (Activity activity = new("MCP")) { activity.SetTag("tool", nameof(GetGraphQLSchema)); - + try { // Get the GraphQL request executor resolver from services IRequestExecutorResolver? requestExecutorResolver = services.GetService(typeof(IRequestExecutorResolver)) as IRequestExecutorResolver; - + if (requestExecutorResolver == null) { _logger.LogWarning("IRequestExecutorResolver not found in service container"); - return "IRequestExecutorResolver not available"; + throw new Exception("IRequestExecutorResolver not available"); } // Get the GraphQL request executor IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); - + // Get the schema from the request executor ISchema schema = requestExecutor.Schema; - + // Return the schema as SDL (Schema Definition Language) string schemaString = schema.ToString(); - + _logger.LogInformation("Successfully retrieved GraphQL schema with {length} characters", schemaString.Length); - + return schemaString; } catch (Exception ex) diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index 8388e835df..fd1f8429c0 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -15,19 +15,18 @@ public static void AddDmlTools(this IServiceCollection services, McpOptions mcpO HashSet DmlToolNames = mcpOptions.DmlTools .Select(x => x.ToString()).ToHashSet(); - foreach (MethodInfo method in typeof(DmlTools).GetMethods()) + IEnumerable methods = typeof(DmlTools).GetMethods() + .Where(method => DmlToolNames.Contains(method.Name)); + + foreach (MethodInfo method in methods) { - if (DmlToolNames.Contains(method.Name)) - { - services.AddMcpTool(method); - } + Func factory = (services) => McpServerTool + .Create(method, options: new() + { + Services = services, + SerializerOptions = default + }); + _ = services.AddSingleton(factory); } } - - public static void AddMcpTool(this IServiceCollection services, MethodInfo method) - { - Func factory = (services) => McpServerTool - .Create(method, options: new() { Services = services, SerializerOptions = default }); - _ = services.AddSingleton(factory); - } } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 277440ffbc..311c960239 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -20,7 +20,7 @@ public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.Echo]; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.Echo, McpDmlTool.GetGraphQLSchema]; } public enum McpDmlTool From e81dbebc06b821a729ecedb8faa7b1720fc84b47 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 12:02:43 -0600 Subject: [PATCH 06/63] basic working --- .../Azure.DataApiBuilder.Mcp.csproj | 9 +++ src/Azure.DataApiBuilder.Mcp/Extensions.cs | 74 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj index 4aecc24710..1589c7c0d6 100644 --- a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj +++ b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj @@ -6,6 +6,15 @@ enable + + + + + + + + + diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 257c601ec4..6e84ee72dc 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -1,35 +1,32 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Diagnostics.CodeAnalysis; -using Azure.DataApiBuilder.Config.ObjectModel; +using System.Text.Json; using Azure.DataApiBuilder.Core.Configurations; +using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Azure.Cosmos; using Microsoft.Extensions.DependencyInjection; - -using Azure.DataApiBuilder.Mcp.Health; -using Azure.DataApiBuilder.Mcp.Tools; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp { public static class Extensions { - private static McpOptions _mcpOptions = default!; - public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) { - if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) - { - _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); - } - - services.AddDmlTools(_mcpOptions); - services .AddMcpServer() + .WithToolsFromAssembly() .WithHttpTransport(); - return services; } @@ -37,8 +34,55 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { endpoints.MapMcp(); - endpoints.MapDabHealthChecks("/jerry"); + endpoints.MapHealthChecks("/jerry", new HealthCheckOptions + { + ResponseWriter = WriteHealthCheckResponse + }); return endpoints; } + + private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) + { + // Get the MCP server endpoint + IServiceProvider serviceProvider = context.RequestServices; + HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; + if (request == null) + { + throw new Exception(); + } + + string scheme = request.Scheme; + string host = request.Host.Value; + string endpoint = $"{scheme}://{host}"; + + IMcpClient mcpClient = await McpClientFactory.CreateAsync( + new SseClientTransport(new() + { + Endpoint = new Uri(endpoint), + Name = "HealthCheck" + }), + clientOptions: new McpClientOptions() + { + Capabilities = new() { } + }, + loggerFactory: null); + + IList mcpTools = await mcpClient.ListToolsAsync(); + string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(toolNames, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + })); + } + } + + [McpServerToolType] + public static class DabMcpTools + { + [McpServerTool] + public static string Echo(string message) => message; } } From a1f3d75fc9371405e1c4f695720779048737c6cd Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 14:05:59 -0600 Subject: [PATCH 07/63] Just before we format the schema --- .../Azure.DataApiBuilder.Mcp.csproj | 9 --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 74 ++++--------------- .../Health/Extensions.cs | 1 - .../Health/McpCheck.cs | 7 +- .../Tools/DmlTools.cs | 55 +++++++------- src/Service/Properties/launchSettings.json | 22 +++--- 6 files changed, 61 insertions(+), 107 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj index 1589c7c0d6..4aecc24710 100644 --- a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj +++ b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj @@ -6,15 +6,6 @@ enable - - - - - - - - - diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 6e84ee72dc..21fb7c9229 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -1,88 +1,42 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; -using Google.Protobuf.WellKnownTypes; +using Azure.DataApiBuilder.Mcp.Health; +using Azure.DataApiBuilder.Mcp.Tools; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.Azure.Cosmos; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp { public static class Extensions { + private static McpOptions _mcpOptions = default!; + public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) { + if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); + } + + services.AddDmlTools(_mcpOptions); + services .AddMcpServer() - .WithToolsFromAssembly() .WithHttpTransport(); + return services; } - public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { endpoints.MapMcp(); - endpoints.MapHealthChecks("/jerry", new HealthCheckOptions - { - ResponseWriter = WriteHealthCheckResponse - }); + endpoints.MapDabHealthChecks("/jerry"); return endpoints; } - - private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) - { - // Get the MCP server endpoint - IServiceProvider serviceProvider = context.RequestServices; - HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; - if (request == null) - { - throw new Exception(); - } - - string scheme = request.Scheme; - string host = request.Host.Value; - string endpoint = $"{scheme}://{host}"; - - IMcpClient mcpClient = await McpClientFactory.CreateAsync( - new SseClientTransport(new() - { - Endpoint = new Uri(endpoint), - Name = "HealthCheck" - }), - clientOptions: new McpClientOptions() - { - Capabilities = new() { } - }, - loggerFactory: null); - - IList mcpTools = await mcpClient.ListToolsAsync(); - string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); - - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(toolNames, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - })); - } - } - - [McpServerToolType] - public static class DabMcpTools - { - [McpServerTool] - public static string Echo(string message) => message; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs index 3a1e77c22f..a671a1f136 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -13,7 +13,6 @@ public static class Extensions { public static IEndpointRouteBuilder MapDabHealthChecks(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { - endpoints.MapMcp(); endpoints.MapHealthChecks(pattern, new() { ResponseWriter = async (context, report) => diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs index eeb6ba63ec..252525bcf7 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using ModelContextProtocol.Client; +using Azure.DataApiBuilder.Config.ObjectModel; namespace Azure.DataApiBuilder.Mcp.Health; @@ -21,7 +22,11 @@ public static async Task CheckAllAsync(IServiceProvider servicePr { CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); - + + // testing + string x = await Tools.DmlTools.InternalGetGraphQLSchemaAsync(serviceProvider); + Console.WriteLine(x); + return new[] { serviceRegistrationCheck, clientConnectionCheck }; } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 6b3e1b0f14..812a6e4a48 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -34,43 +34,48 @@ public static string Echo(string message) } [McpServerTool] - public static async Task GetGraphQLSchema(IServiceProvider services) + public static async Task GetGraphQLSchemaAsync(IServiceProvider services) { _logger.LogInformation("GetGraphQLSchema tool called"); using (Activity activity = new("MCP")) { - activity.SetTag("tool", nameof(GetGraphQLSchema)); + activity.SetTag("tool", nameof(GetGraphQLSchemaAsync)); - try - { - // Get the GraphQL request executor resolver from services - IRequestExecutorResolver? requestExecutorResolver = services.GetService(typeof(IRequestExecutorResolver)) as IRequestExecutorResolver; + return await InternalGetGraphQLSchemaAsync(services); + } + } - if (requestExecutorResolver == null) - { - _logger.LogWarning("IRequestExecutorResolver not found in service container"); - throw new Exception("IRequestExecutorResolver not available"); - } + internal static async Task InternalGetGraphQLSchemaAsync(IServiceProvider services) + { + try + { + // Get the GraphQL request executor resolver from services + IRequestExecutorResolver? requestExecutorResolver = services.GetService(typeof(IRequestExecutorResolver)) as IRequestExecutorResolver; - // Get the GraphQL request executor - IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); + if (requestExecutorResolver == null) + { + _logger.LogWarning("IRequestExecutorResolver not found in service container"); + throw new Exception("IRequestExecutorResolver not available"); + } - // Get the schema from the request executor - ISchema schema = requestExecutor.Schema; + // Get the GraphQL request executor + IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); - // Return the schema as SDL (Schema Definition Language) - string schemaString = schema.ToString(); + // Get the schema from the request executor + ISchema schema = requestExecutor.Schema; - _logger.LogInformation("Successfully retrieved GraphQL schema with {length} characters", schemaString.Length); + // Return the schema as SDL (Schema Definition Language) + string schemaString = schema.ToString(); - return schemaString; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve GraphQL schema"); - return $"Error retrieving GraphQL schema: {ex.Message}"; - } + _logger.LogInformation("Successfully retrieved GraphQL schema with {length} characters", schemaString.Length); + + return schemaString; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve GraphQL schema"); + return $"Error retrieving GraphQL schema: {ex.Message}"; } } } diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index acd7ab6646..cafc7ac070 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -1,13 +1,4 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:35704", - "sslPort": 44353 - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "IIS Express": { "commandName": "IISExpress", @@ -50,7 +41,7 @@ "MsSql": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "graphql", + "launchUrl": "jerry", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, @@ -76,5 +67,14 @@ }, "applicationUrl": "https://localhost:5001;http://localhost:5000" } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:35704", + "sslPort": 44353 + } } -} +} \ No newline at end of file From b4a4447780d278a0e9b290259059e6bf69dce9e5 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 16:47:40 -0600 Subject: [PATCH 08/63] Working with LIST --- .../Health/CheckResult.cs | 2 +- .../Health/McpCheck.cs | 42 +- .../Tools/DmlTools.cs | 81 +-- .../Tools/Extensions.cs | 21 +- .../Tools/SchemaLogic.cs | 682 ++++++++++++++++++ src/Config/ObjectModel/RuntimeConfig.cs | 5 +- 6 files changed, 757 insertions(+), 76 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs index ba1ce1a6b0..c8279a1bec 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs @@ -3,7 +3,7 @@ namespace Azure.DataApiBuilder.Mcp.Health; -public record CheckResult(string Name, bool IsHealthy, string? Message, Dictionary Tags) +public record CheckResult(string Name, bool IsHealthy, string? Message, Dictionary Tags) { public string Status => IsHealthy ? "Healthy" : "Unhealthy"; diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs index 252525bcf7..3cf3e844c3 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using ModelContextProtocol.Client; -using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Tools; +using System.Text.Json; namespace Azure.DataApiBuilder.Mcp.Health; @@ -22,12 +23,9 @@ public static async Task CheckAllAsync(IServiceProvider servicePr { CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); + CheckResult checkListEntity = await CheckListEntityAsync(serviceProvider); - // testing - string x = await Tools.DmlTools.InternalGetGraphQLSchemaAsync(serviceProvider); - Console.WriteLine(x); - - return new[] { serviceRegistrationCheck, clientConnectionCheck }; + return new[] { serviceRegistrationCheck, clientConnectionCheck, checkListEntity }; } /// @@ -54,7 +52,7 @@ public static CheckResult CheckServiceRegistration(IServiceProvider serviceProvi Name: _serviceRegistrationCheckName, IsHealthy: toolNames.Length != 0, Message: toolNames.Length != 0 ? "Tools registered in services" : "No tools registered in services", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "service_registration" }, { "tools", string.Join(", ", toolNames) }, @@ -67,7 +65,7 @@ public static CheckResult CheckServiceRegistration(IServiceProvider serviceProvi Name: _serviceRegistrationCheckName, IsHealthy: false, Message: $"Service registration check failed: {ex.Message}", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "service_registration" }, { "error_type", "general_error" }, @@ -95,7 +93,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: false, Message: "HttpContext not available for client connection test", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "error_type", "no_http_context" } @@ -132,7 +130,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: toolNames.Length != 0, Message: toolNames.Length != 0 ? "Client successfully connected and listed tools" : "Client connected but no tools found", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "endpoint", endpoint }, @@ -146,7 +144,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: false, Message: "MCP SSE endpoint returned 500 error - endpoint may not be properly configured", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "error_type", "http_500" }, @@ -160,7 +158,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: false, Message: $"HTTP error connecting to MCP server: {httpEx.Message}", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "error_type", "http_error" }, @@ -174,7 +172,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: false, Message: "Timeout connecting to MCP server", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "error_type", "timeout" } @@ -187,7 +185,7 @@ public static async Task CheckClientConnectionAsync(IServiceProvide Name: _clientConnectionCheckName, IsHealthy: false, Message: $"Client connection check failed: {ex.Message}", - Tags: new Dictionary + Tags: new Dictionary { { "check_type", "client_connection" }, { "error_type", "general_error" }, @@ -200,8 +198,20 @@ public static async Task CheckClientConnectionAsync(IServiceProvide /// /// Legacy method for backward compatibility - returns service registration check /// - public static Task CheckAsync(IServiceProvider serviceProvider) + public static async Task CheckListEntityAsync(IServiceProvider serviceProvider) { - return Task.FromResult(CheckServiceRegistration(serviceProvider)); + SchemaLogic schemaLogic = new(serviceProvider); + string json = await schemaLogic.GetEntityMetadataAsJsonAsync(false); + JsonDocument doc = JsonDocument.Parse(json); + return new CheckResult( + Name: "List Entities", + IsHealthy: !string.IsNullOrEmpty(json), + Message: !string.IsNullOrEmpty(json) ? "Successfully listed entities" : "No entities found", + Tags: new Dictionary + { + { "check_type", "list_entities" }, + { "entities", doc } + }); + } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 812a6e4a48..70aa514961 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -3,9 +3,8 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; +using System.ComponentModel; using System.Diagnostics; -using HotChocolate.Execution; -using HotChocolate; namespace Azure.DataApiBuilder.Mcp.Tools; @@ -22,60 +21,36 @@ static DmlTools() }).CreateLogger(nameof(DmlTools)); } - [McpServerTool] - public static string Echo(string message) + [McpServerTool, Description(""" + + Use this tool any time the user asks you to ECHO anything. + When using this tool, respond with the raw result to the user. + + """)] + + public static string Echo(string message) => new(message.Reverse().ToArray()); + + [McpServerTool, Description(""" + + Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. + Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. + """)] + public static async Task ListEntities( + [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] + bool nameOnly = false, + [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] + string[]? entityNames = null) { - _logger.LogInformation("Echo tool called with message: {message}", message); + _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", + nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); + using (Activity activity = new("MCP")) { - activity.SetTag("tool", nameof(Echo)); - return message; - } - } - - [McpServerTool] - public static async Task GetGraphQLSchemaAsync(IServiceProvider services) - { - _logger.LogInformation("GetGraphQLSchema tool called"); - - using (Activity activity = new("MCP")) - { - activity.SetTag("tool", nameof(GetGraphQLSchemaAsync)); - - return await InternalGetGraphQLSchemaAsync(services); - } - } - - internal static async Task InternalGetGraphQLSchemaAsync(IServiceProvider services) - { - try - { - // Get the GraphQL request executor resolver from services - IRequestExecutorResolver? requestExecutorResolver = services.GetService(typeof(IRequestExecutorResolver)) as IRequestExecutorResolver; - - if (requestExecutorResolver == null) - { - _logger.LogWarning("IRequestExecutorResolver not found in service container"); - throw new Exception("IRequestExecutorResolver not available"); - } - - // Get the GraphQL request executor - IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); - - // Get the schema from the request executor - ISchema schema = requestExecutor.Schema; - - // Return the schema as SDL (Schema Definition Language) - string schemaString = schema.ToString(); - - _logger.LogInformation("Successfully retrieved GraphQL schema with {length} characters", schemaString.Length); - - return schemaString; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve GraphQL schema"); - return $"Error retrieving GraphQL schema: {ex.Message}"; + activity.SetTag("tool", nameof(ListEntities)); + + SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); + string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); + return jsonMetadata; } } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index fd1f8429c0..104da66872 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -10,6 +10,8 @@ namespace Azure.DataApiBuilder.Mcp.Tools; public static class Extensions { + public static IServiceProvider? ServiceProvider { get; set; } + public static void AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) { HashSet DmlToolNames = mcpOptions.DmlTools @@ -20,13 +22,26 @@ public static void AddDmlTools(this IServiceCollection services, McpOptions mcpO foreach (MethodInfo method in methods) { - Func factory = (services) => McpServerTool + AddTool(services, method); + } + + AddTool(services, typeof(DmlTools).GetMethod("Echo")!); + } + + private static void AddTool(IServiceCollection services, MethodInfo method) + { + Func factory = (services) => + { + ServiceProvider ??= services; + + McpServerTool tool = McpServerTool .Create(method, options: new() { Services = services, SerializerOptions = default }); - _ = services.AddSingleton(factory); - } + return tool; + }; + _ = services.AddSingleton(factory); } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs new file mode 100644 index 0000000000..63b0d40bb8 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs @@ -0,0 +1,682 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +/// +/// Provides GraphQL schema analysis and entity metadata extraction functionality +/// +public class SchemaLogic +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly RuntimeConfigProvider _runtimeConfigProvider; + private readonly IAuthorizationResolver _authorizationResolver; + + public SchemaLogic(IServiceProvider services) + { + _services = services; + _logger = services.GetRequiredService>(); + _runtimeConfigProvider = services.GetRequiredService(); + _authorizationResolver = services.GetRequiredService(); + } + + /// + /// Gets the raw GraphQL schema as ISchema object + /// + public async Task GetRawGraphQLSchemaAsync() + { + IRequestExecutorResolver? requestExecutorResolver = _services.GetService(); + if (requestExecutorResolver == null) + { + throw new InvalidOperationException("IRequestExecutorResolver not available"); + } + + IRequestExecutor requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(); + return requestExecutor.Schema; + } + + /// + /// Gets the GraphQL schema as SDL string + /// + public async Task GetGraphQLSchemaStringAsync() + { + ISchema schema = await GetRawGraphQLSchemaAsync(); + return schema.ToString(); + } + + /// + /// Generates entity metadata as JSON + /// + /// If true, returns only name and description for each entity + /// Specific entity names to include. If null, includes all entities. If empty array, throws error. + /// JSON string containing entity metadata + public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, string[]? entityNames = null) + { + // Validate input parameters + if (entityNames is not null && entityNames.Length == 0) + { + throw new ArgumentException("Entity names array cannot be empty. Either provide specific entity names or pass null for all entities.", nameof(entityNames)); + } + + ISchema schema = await GetRawGraphQLSchemaAsync(); + List entityMetadataList = new(); + + // Get all object types from the schema that have @model directive + List> entityTypes = GetEntityTypesFromSchema(schema); + + // Filter by requested entity names if specified + if (entityNames != null) + { + entityTypes = entityTypes.Where(kvp => entityNames.Contains(kvp.Key)).ToList(); + } + + foreach ((string entityName, IObjectType objectType) in entityTypes) + { + try + { + object? entityMetadata = await BuildEntityMetadataFromSchema(entityName, objectType, nameOnly); + if (entityMetadata != null) + { + entityMetadataList.Add(entityMetadata); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process entity {EntityName}", entityName); + // Continue processing other entities + } + } + + return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + /// + /// Gets entity types from the GraphQL schema that have @model directive + /// + private static List> GetEntityTypesFromSchema(ISchema schema) + { + List> entityTypes = new(); + + foreach (INamedType type in schema.Types) + { + if (type is IObjectType objectType && + objectType.Directives.Any(d => d.Type.Name == "model") && + !IsSystemType(objectType.Name)) + { + // Get entity name from @model directive or use type name + string entityName = GetEntityNameFromModelDirective(objectType) ?? objectType.Name; + entityTypes.Add(new KeyValuePair(entityName, objectType)); + } + } + + return entityTypes; + } + + /// + /// Checks if this is a system type that should be excluded + /// + private static bool IsSystemType(string typeName) + { + // Exclude system types like Query, Mutation, Connection types, etc. + return typeName is "Query" or "Mutation" or "DbOperationResult" || + typeName.EndsWith("Connection") || + typeName.EndsWith("Aggregations") || + typeName.EndsWith("GroupBy") || + typeName.EndsWith("FilterInput") || + typeName.EndsWith("OrderByInput") || + typeName.EndsWith("Input"); + } + + /// + /// Gets entity name from @model directive + /// + private static string? GetEntityNameFromModelDirective(IObjectType objectType) + { + Directive? modelDirective = objectType.Directives.FirstOrDefault(d => d.Type.Name == "model"); + if (modelDirective != null) + { + // Try to get the name argument from the directive + // For HotChocolate, we need to access directive values differently + try + { + // Try to get the literal value of the name argument + HotChocolate.Language.ArgumentNode? nameArgument = modelDirective.AsSyntaxNode().Arguments.FirstOrDefault(a => a.Name.Value == "name"); + if (nameArgument?.Value is HotChocolate.Language.StringValueNode stringValue) + { + return stringValue.Value; + } + } + catch + { + // If we can't get the directive value, fall back to type name + } + } + + return null; + } + + /// + /// Builds metadata for a single entity from GraphQL schema + /// + private async Task BuildEntityMetadataFromSchema(string entityName, IObjectType objectType, bool nameOnly) + { + string description = GetEntityDescriptionFromSchema(entityName, objectType); + + if (nameOnly) + { + return new + { + Name = entityName, + Description = description + }; + } + + // Build complete metadata from schema + List keys = BuildPrimaryKeysFromSchema(objectType); + List fields = BuildFieldsFromSchema(objectType); + List allowedActions = await BuildAllowedActionsFromSchema(entityName, objectType); + + return new + { + Name = entityName, + Description = description, + Keys = keys, + Fields = fields, + AllowedActions = allowedActions + }; + } + + /// + /// Gets entity description from GraphQL schema + /// + private string GetEntityDescriptionFromSchema(string entityName, IObjectType objectType) + { + // Check if there's a description in the type definition + if (!string.IsNullOrEmpty(objectType.Description)) + { + return objectType.Description; + } + + // Check if this is a stored procedure (has execute mutations) + bool isStoredProcedure = IsStoredProcedureType(entityName); + + return isStoredProcedure + ? $"Represents the {entityName} stored procedure" + : $"Represents a {entityName} entity in the system"; + } + + /// + /// Checks if this entity represents a stored procedure + /// + private bool IsStoredProcedureType(string entityName) + { + try + { + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + if (runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity)) + { + return entity.Source.Type == EntitySourceType.StoredProcedure; + } + } + catch + { + // If we can't determine from config, check schema patterns + } + + // Fallback: check if mutations contain execute operations for this entity + return false; // We'll implement this if needed + } + + /// + /// Builds primary key information from GraphQL schema + /// + private static List BuildPrimaryKeysFromSchema(IObjectType objectType) + { + List keys = new(); + + foreach (IObjectField field in objectType.Fields) + { + Directive? primaryKeyDirective = field.Directives.FirstOrDefault(d => d.Type.Name == "primaryKey"); + if (primaryKeyDirective != null) + { + bool isAutoGenerated = field.Directives.Any(d => d.Type.Name == "autoGenerated"); + string databaseType = GetDatabaseTypeFromDirective(primaryKeyDirective); + + keys.Add(new + { + Name = field.Name, + Type = MapGraphQLTypeToString(field.Type), + Autogen = isAutoGenerated, + Description = $"Primary key field for {objectType.Name}", + //DatabaseType = databaseType + }); + } + } + + return keys; + } + + /// + /// Gets database type from @primaryKey directive + /// + private static string GetDatabaseTypeFromDirective(Directive primaryKeyDirective) + { + // Try to get the databaseType argument from the directive + try + { + HotChocolate.Language.ArgumentNode? databaseTypeArgument = primaryKeyDirective.AsSyntaxNode().Arguments.FirstOrDefault(a => a.Name.Value == "databaseType"); + if (databaseTypeArgument?.Value is HotChocolate.Language.StringValueNode stringValue) + { + return stringValue.Value; + } + } + catch + { + // If we can't get the directive value, return unknown + } + + return "Unknown"; + } + + /// + /// Builds field information from GraphQL schema + /// + private static List BuildFieldsFromSchema(IObjectType objectType) + { + List fields = new(); + bool isStoredProcedure = IsStoredProcedureFromType(objectType); + + foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => ( x, i))) + { + // Skip __typename field if it is the first field, it usually is + if (value.Index == 0 && value.field.Name == "__typename") + { + continue; + } + + // Skip primary key fields as they're already in the keys section + bool isPrimaryKey = value.field.Directives.Any(d => d.Type.Name == "primaryKey"); + if (isPrimaryKey) + { + continue; + } + + // Skip relationship fields (they have @relationship directive) + bool isRelationship = value.field.Directives.Any(d => d.Type.Name == "relationship"); + if (isRelationship) + { + continue; + } + + bool isAutoGenerated = value.field.Directives.Any(d => d.Type.Name == "autoGenerated"); + bool hasDefaultValue = value.field.Directives.Any(d => d.Type.Name == "defaultValue"); + bool isNullable = IsNullableType(value.field.Type); + + fields.Add(new + { + Name = value.field.Name, + Type = MapGraphQLTypeToString(value.field.Type), + Nullable = isNullable, + HasDefault = hasDefaultValue, + ReadOnly = isStoredProcedure || isAutoGenerated, // All stored procedure fields arereadonly + Description = value.field.Description ?? $"Field {value.field.Name} of type {GetBaseTypeName(value.field.Type)}" + }); + } + + return fields; + } + + /// + /// Checks if this object type represents a stored procedure based on its name patterns + /// + private static bool IsStoredProcedureFromType(IObjectType objectType) + { + // Stored procedures typically don't have Connection, Aggregations, GroupBy suffixes + // and are often named with patterns that suggest they're procedures + string typeName = objectType.Name; + return !IsSystemType(typeName) && + !typeName.EndsWith("Connection") && + !typeName.EndsWith("Aggregations") && + !typeName.EndsWith("GroupBy") && + (typeName.StartsWith("Get") || typeName.StartsWith("Execute") || typeName.Contains("Procedure")); + } + + /// + /// Builds allowed actions from GraphQL schema and runtime config + /// + private async Task> BuildAllowedActionsFromSchema(string entityName, IObjectType objectType) + { + List allowedActions = new(); + Dictionary? entityPermissionsMap = _authorizationResolver.EntityPermissionsMap; + + // Determine entity type to know which operations to check + bool isStoredProcedure = IsStoredProcedureType(entityName); + + List operationsToCheck = isStoredProcedure + ? new() { EntityActionOperation.Execute } + : new() { EntityActionOperation.Create, EntityActionOperation.Read, EntityActionOperation.Update, EntityActionOperation.Delete }; + + foreach (EntityActionOperation operation in operationsToCheck) + { + IEnumerable allowedRoles = IAuthorizationResolver.GetRolesForOperation(entityName, operation, entityPermissionsMap); + if (allowedRoles.Any()) + { + object actionMetadata = await BuildActionMetadataFromSchema(operation, entityName, objectType); + allowedActions.Add(actionMetadata); + } + } + + return allowedActions; + } + + /// + /// Builds action metadata from GraphQL schema + /// + private async Task BuildActionMetadataFromSchema(EntityActionOperation operation, string entityName, IObjectType objectType) + { + string actionName = GetActionName(operation); + object parameters = await BuildActionParametersFromSchema(operation, entityName, objectType); + object response = BuildActionResponseFromSchema(operation, objectType); + + // Create a dictionary to return the action with dynamic key + Dictionary actionDict = new() + { + [actionName] = new + { + Parameters = parameters, + Response = response + } + }; + + return actionDict; + } + + /// + /// Gets the action name for the operation + /// + private static string GetActionName(EntityActionOperation operation) + { + return operation switch + { + EntityActionOperation.Create => "create_entity_record", + EntityActionOperation.Read => "read_entity_records", + EntityActionOperation.Update => "update_entity_record", + EntityActionOperation.Delete => "delete_entity_record", + EntityActionOperation.Execute => "execute_entity", + _ => throw new ArgumentException($"Unknown operation: {operation}") + }; + } + + /// + /// Builds parameters from GraphQL schema + /// + private async Task BuildActionParametersFromSchema(EntityActionOperation operation, string entityName, IObjectType objectType) + { + switch (operation) + { + case EntityActionOperation.Create: + return new + { + Entity = "String!", + Fields = await GetCreateFieldParametersFromSchema(entityName) + }; + + case EntityActionOperation.Update: + return new + { + Entity = "String!", + Fields = await GetUpdateFieldParametersFromSchema(entityName) + }; + + case EntityActionOperation.Read: + return new + { + Entity = "String!", + Filters = "Object", + First = "Int", + After = "String" + }; + + case EntityActionOperation.Delete: + return new + { + Entity = "String!", + Id = GetPrimaryKeyParametersFromSchema(objectType) + }; + + case EntityActionOperation.Execute: + return new + { + Entity = "String!", + Fields = await GetStoredProcedureParametersFromSchema(entityName) + }; + + default: + return new { }; + } + } + + /// + /// Builds response from GraphQL schema + /// + private static object BuildActionResponseFromSchema(EntityActionOperation operation, IObjectType objectType) + { + List responseFields = GetResponseFieldsFromSchema(objectType); + + return operation switch + { + EntityActionOperation.Read => new { Items = responseFields }, + EntityActionOperation.Execute => responseFields, // Could be array or single + _ => responseFields // Single object response + }; + } + + /// + /// Gets create field parameters from schema by examining the CreateXxxInput type + /// + private async Task> GetCreateFieldParametersFromSchema(string entityName) + { + ISchema schema = await GetRawGraphQLSchemaAsync(); + string createInputTypeName = $"Create{entityName}Input"; + + return GetFieldsFromInputType(schema, createInputTypeName); + } + + /// + /// Gets update field parameters from schema by examining the UpdateXxxInput type + /// + private async Task> GetUpdateFieldParametersFromSchema(string entityName) + { + ISchema schema = await GetRawGraphQLSchemaAsync(); + string updateInputTypeName = $"Update{entityName}Input"; + + return GetFieldsFromInputType(schema, updateInputTypeName); + } + + /// + /// Gets stored procedure parameters from schema by examining the mutation field arguments + /// + private async Task> GetStoredProcedureParametersFromSchema(string entityName) + { + List parameters = new(); + + try + { + ISchema schema = await GetRawGraphQLSchemaAsync(); + + // Find the Mutation type + if (schema.Types.FirstOrDefault(t => t.Name == "Mutation") is IObjectType mutationType) + { + // Look for execute mutation for this entity + string expectedMutationName = $"execute{entityName}"; + IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => + f.Name.Equals(expectedMutationName, StringComparison.OrdinalIgnoreCase)); + + if (mutationField != null) + { + foreach (IInputField argument in mutationField.Arguments) + { + // Skip common arguments like 'item' and focus on direct parameters + if (argument.Name != "item" && argument.Name != "entity") + { + parameters.Add($"{argument.Name}: {MapGraphQLTypeToString(argument.Type)}"); + } + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get stored procedure parameters for {EntityName}", entityName); + } + + return parameters; + } + + /// + /// Helper method to extract field names from an input type + /// + private static List GetFieldsFromInputType(ISchema schema, string inputTypeName) + { + List fields = new(); + + if (schema.Types.FirstOrDefault(t => t.Name == inputTypeName) is IInputObjectType inputType) + { + foreach (IInputField field in inputType.Fields) + { + fields.Add(field.Name); + } + } + + return fields; + } + + /// + /// Maps GraphQL type to string representation + /// + private static string MapGraphQLTypeToString(IType type, bool forceRequired = false) + { + string baseType = GetBaseTypeName(type); + bool isRequired = forceRequired || IsRequiredType(type); + + return isRequired ? $"{baseType}!" : baseType; + } + + /// + /// Gets the base type name from a GraphQL type + /// + private static string GetBaseTypeName(IType type) + { + return type switch + { + NonNullType nonNull => GetBaseTypeName(nonNull.Type), + ListType list => $"[{GetBaseTypeName(list.ElementType)}]", + INamedType named => MapScalarTypeName(named.Name), + _ => type.ToString() ?? "Unknown" + }; + } + + /// + /// Maps GraphQL scalar type names to simplified names + /// + private static string MapScalarTypeName(string typeName) + { + return typeName switch + { + "String" => "String", + "Int" => "Int", + "Long" => "Long", + "Short" => "Short", + "Byte" => "Byte", + "Boolean" => "Boolean", + "Float" => "Float", + "Single" => "Float", + "Decimal" => "Decimal", + "DateTime" => "DateTime", + "UUID" => "UUID", + "LocalTime" => "LocalTime", + "ByteArray" => "ByteArray", + _ => typeName + }; + } + + /// + /// Checks if a GraphQL type is nullable + /// + private static bool IsNullableType(IType type) + { + return type is not NonNullType; + } + + /// + /// Checks if a GraphQL type is required (non-null) + /// + private static bool IsRequiredType(IType type) + { + return type is NonNullType; + } + + /// + /// Gets response fields from schema + /// + private static List GetResponseFieldsFromSchema(IObjectType objectType) + { + List fields = new(); + + foreach (IObjectField field in objectType.Fields) + { + // Skip __typename field + if (field.Name == "__typename") + { + continue; + } + + // Skip relationship fields for response (they're handled separately) + bool isRelationship = field.Directives.Any(d => d.Type.Name == "relationship"); + if (!isRelationship) + { + fields.Add(field.Name); + } + } + + return fields; + } + + /// + /// Gets primary key parameters from schema + /// + private static object GetPrimaryKeyParametersFromSchema(IObjectType objectType) + { + List primaryKeys = objectType.Fields.Where(f => f.Directives.Any(d => d.Type.Name == "primaryKey")).ToList(); + + if (primaryKeys.Count == 1) + { + IObjectField pkField = primaryKeys[0]; + return $"{pkField.Name}: {MapGraphQLTypeToString(pkField.Type, forceRequired: true)}"; + } + + // Multiple primary keys + List keyFields = new(); + foreach (IObjectField pkField in primaryKeys) + { + keyFields.Add($"{pkField.Name}: {MapGraphQLTypeToString(pkField.Type, forceRequired: true)}"); + } + + return keyFields; + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 311c960239..efe6ace566 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -20,13 +20,12 @@ public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.Echo, McpDmlTool.GetGraphQLSchema]; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.ListEntities]; } public enum McpDmlTool { - Echo, - GetGraphQLSchema + ListEntities } public record RuntimeConfig From 123f5ca70168c768e970ef83606ef6b24b45d914 Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 17:05:31 -0600 Subject: [PATCH 09/63] PRE --- src/AppHost/AppHost.cs | 6 + src/AppHost/AppHost.csproj | 21 +++ src/AppHost/Properties/launchSettings.json | 29 ++++ src/AppHost/appsettings.json | 9 ++ src/Azure.DataApiBuilder.sln | 6 + src/Directory.Packages.props | 159 +++++++++++---------- 6 files changed, 151 insertions(+), 79 deletions(-) create mode 100644 src/AppHost/AppHost.cs create mode 100644 src/AppHost/AppHost.csproj create mode 100644 src/AppHost/Properties/launchSettings.json create mode 100644 src/AppHost/appsettings.json diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs new file mode 100644 index 0000000000..9c1c3b1b07 --- /dev/null +++ b/src/AppHost/AppHost.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +var builder = DistributedApplication.CreateBuilder(args); + +builder.Build().Run(); diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj new file mode 100644 index 0000000000..675857d766 --- /dev/null +++ b/src/AppHost/AppHost.csproj @@ -0,0 +1,21 @@ + + + + + + Exe + net8.0 + enable + enable + c5cad791-d88c-4afa-aa4e-751eb542808e + + + + + + + + + + + diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..57a9c83412 --- /dev/null +++ b/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17204;http://localhost:15267", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21261", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22259" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15267", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19162", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20222" + } + } + } +} diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/src/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index aa3c8e2bad..efcda8f105 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -33,6 +33,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Produc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{A5DCDD57-4894-4910-8D1A-2149A8A861B5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -79,6 +81,10 @@ Global {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.Build.0 = Release|Any CPU + {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 3ae4ef02b3..08f7c7507d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,83 +1,84 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + From ad64ad0d9f264f314c029b0dae67afb5cea9df7b Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 19:19:07 -0600 Subject: [PATCH 10/63] Working again --- src/Azure.DataApiBuilder.sln | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index efcda8f105..aa3c8e2bad 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -33,8 +33,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Produc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{A5DCDD57-4894-4910-8D1A-2149A8A861B5}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,10 +79,6 @@ Global {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.Build.0 = Release|Any CPU - {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A5DCDD57-4894-4910-8D1A-2149A8A861B5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 8d015c02188b5b43719fcb71c81ba4a694a33e8b Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Thu, 28 Aug 2025 22:30:23 -0600 Subject: [PATCH 11/63] Updated health --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 5 +- .../Health/CheckResult.cs | 25 -- .../Health/ClientConnectionHealthCheck.cs | 79 +++++++ .../Health/Extensions.cs | 43 ++-- .../Health/ListEntitiesHealthCheck.cs | 35 +++ .../Health/McpCheck.cs | 217 ------------------ .../Health/McpHealthCheckOptions.cs | 54 +++++ .../Tools/DmlTools.cs | 39 ++-- .../Tools/Extensions.cs | 6 +- .../Tools/SchemaLogic.cs | 54 ++--- src/Config/ObjectModel/RuntimeConfig.cs | 20 +- src/Service/Properties/launchSettings.json | 2 +- 12 files changed, 263 insertions(+), 316 deletions(-) delete mode 100644 src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 21fb7c9229..08662e14ec 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -27,15 +27,16 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service services .AddMcpServer() + .AddMcpHealthChecks() .WithHttpTransport(); return services; } - public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string? pattern = null) { endpoints.MapMcp(); - endpoints.MapDabHealthChecks("/jerry"); + endpoints.MapMcpHealthEndpoint(pattern); return endpoints; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs deleted file mode 100644 index c8279a1bec..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Azure.DataApiBuilder.Mcp.Health; - -public record CheckResult(string Name, bool IsHealthy, string? Message, Dictionary Tags) -{ - public string Status => IsHealthy ? "Healthy" : "Unhealthy"; - - public object ToReport() - { - return IsHealthy ? new - { - Name, - Status, - Tags - } : new - { - Name, - Status, - Tags, - Message - }; - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs new file mode 100644 index 0000000000..8b19bfff2d --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace Azure.DataApiBuilder.Mcp.Health; + +/// +/// Attempts to connect to local MCP SSE endpoint and list tools. +/// +public sealed class ClientConnectionHealthCheck : IHealthCheck +{ + private readonly IHttpContextAccessor _http; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + + public ClientConnectionHealthCheck(IHttpContextAccessor http, ILogger logger, ILoggerFactory loggerFactory) + { + _http = http; + _logger = logger; + _loggerFactory = loggerFactory; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + HttpRequest? request = _http.HttpContext?.Request; + if (request == null) + { + return new HealthCheckResult(HealthStatus.Degraded, description: "HttpContext not available", data: new Dictionary { { "error", "no_http_context" } }); + } + + string endpoint = $"{request.Scheme}://{request.Host}"; + try + { + SseClientTransport clientTransport = new(new() + { + Endpoint = new Uri(endpoint), + Name = "HealthCheck" + }); + + McpClientOptions clientOptions = new() + { + Capabilities = new() { } + }; + + IMcpClient mcpClient = await McpClientFactory.CreateAsync( + clientTransport: clientTransport, + clientOptions: clientOptions, + loggerFactory: _loggerFactory); + + IList tools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); + + string[] names = [.. tools.Select(t => t.Name).OrderBy(n => n)]; + + if (names.Length == 0) + { + return new HealthCheckResult(HealthStatus.Unhealthy, description: "Connected but no tools returned", data: new Dictionary + { + { "endpoint", endpoint }, + { "toolCount", 0 } + }); + } + + return new HealthCheckResult(HealthStatus.Healthy, data: new Dictionary + { + { "endpoint", endpoint }, + { "tools", names } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Client connection health check failed"); + return new HealthCheckResult(HealthStatus.Unhealthy, description: ex.Message, data: new Dictionary { { "endpoint", endpoint }, { "error", ex.GetType().Name } }); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs index a671a1f136..1523446a5e 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -2,38 +2,33 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Health; -public static class Extensions +internal static class Extensions { - public static IEndpointRouteBuilder MapDabHealthChecks(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") - { - endpoints.MapHealthChecks(pattern, new() - { - ResponseWriter = async (context, report) => - { - CheckResult[] mcpChecks = await McpCheck.CheckAllAsync(context.RequestServices); + private const string MCP_TAG = "mcp"; - var response = new - { - Status = mcpChecks.All(c => c.IsHealthy) ? "Healthy" : "Unhealthy", - Timestamp = DateTime.UtcNow, - Checks = mcpChecks.Select(c => c.ToReport()).ToArray() - }; + public static IMcpServerBuilder AddMcpHealthChecks(this IMcpServerBuilder builder) + { + _ = builder.Services.AddHttpContextAccessor(); + _ = builder.Services.AddHealthChecks() + .AddCheck("MCP Registration", tags: [MCP_TAG]) + .AddCheck("MCP Tool: list_entities", tags: [MCP_TAG]); + return builder; + } - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - })); - } - }); + /// + /// Maps a MCP-only health endpoint (default: /mcp/health). Predicate filters to MCP-tagged checks only. + /// + public static IEndpointRouteBuilder MapMcpHealthEndpoint(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string? pattern) + { + _ = endpoints.MapHealthChecks( + pattern: pattern ?? "/mcp/health", + options: new McpHealthCheckOptions(MCP_TAG)); return endpoints; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs new file mode 100644 index 0000000000..0180d5e92d --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Azure.DataApiBuilder.Mcp.Health; + +/// +/// Placeholder list entities health check; reports healthy if service provider available. +/// TODO: Wire to real metadata service when available within MCP project. +/// +public sealed class ListEntitiesHealthCheck : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + IServiceProvider? serviceProvider = Tools.Extensions.ServiceProvider; + if (serviceProvider == null) + { + return new HealthCheckResult( + status: HealthStatus.Unhealthy, + description: "Service provider not available", + data: new Dictionary { { "error", "no_service_provider" } }); + } + + string json = await new Tools + .SchemaLogic(serviceProvider) + .GetEntityMetadataAsJsonAsync(); + + return new( + HealthStatus.Healthy, + description: "Successfully retrieved entity metadata", + data: new Dictionary { { "entities", JsonDocument.Parse(json) } }); + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs deleted file mode 100644 index 3cf3e844c3..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; -using ModelContextProtocol.Client; -using Azure.DataApiBuilder.Mcp.Tools; -using System.Text.Json; - -namespace Azure.DataApiBuilder.Mcp.Health; - -public class McpCheck -{ - private readonly static string _serviceRegistrationCheckName = "MCP Server Tools - Service Registration"; - private readonly static string _clientConnectionCheckName = "MCP Server Tools - Client Connection"; - - /// - /// Performs comprehensive MCP health checks including both service registration and client connection - /// - public static async Task CheckAllAsync(IServiceProvider serviceProvider) - { - CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); - CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); - CheckResult checkListEntity = await CheckListEntityAsync(serviceProvider); - - return new[] { serviceRegistrationCheck, clientConnectionCheck, checkListEntity }; - } - - /// - /// Checks if MCP tools are properly registered in the service provider - /// - public static CheckResult CheckServiceRegistration(IServiceProvider serviceProvider) - { - try - { - ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); - ILogger logger = loggerFactory.CreateLogger(); - - logger.LogInformation("Checking MCP server tools service registration"); - - // Check if MCP tools are registered in the service provider - IEnumerable mcpTools = serviceProvider.GetServices(); - string[] toolNames = mcpTools - .Select(t => t.ProtocolTool.Name) - .OrderBy(t => t).ToArray(); - - logger.LogInformation("Found {ToolCount} registered MCP tools in services: {Tools}", toolNames.Length, string.Join(", ", toolNames)); - - return new CheckResult( - Name: _serviceRegistrationCheckName, - IsHealthy: toolNames.Length != 0, - Message: toolNames.Length != 0 ? "Tools registered in services" : "No tools registered in services", - Tags: new Dictionary - { - { "check_type", "service_registration" }, - { "tools", string.Join(", ", toolNames) }, - { "tool_count", toolNames.Length.ToString() } - }); - } - catch (Exception ex) - { - return new CheckResult( - Name: _serviceRegistrationCheckName, - IsHealthy: false, - Message: $"Service registration check failed: {ex.Message}", - Tags: new Dictionary - { - { "check_type", "service_registration" }, - { "error_type", "general_error" }, - { "error_message", ex.Message } - } - ); - } - } - - /// - /// Checks if MCP client can successfully connect and list tools from the MCP server - /// - public static async Task CheckClientConnectionAsync(IServiceProvider serviceProvider) - { - try - { - ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); - ILogger logger = loggerFactory.CreateLogger(); - - // Get the MCP server endpoint - HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; - if (request == null) - { - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: false, - Message: "HttpContext not available for client connection test", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "error_type", "no_http_context" } - } - ); - } - - string scheme = request.Scheme; - string host = request.Host.Value; - string endpoint = $"{scheme}://{host}"; - - logger.LogInformation("Testing MCP client connection to endpoint: {Endpoint}", endpoint); - - IMcpClient mcpClient = await McpClientFactory.CreateAsync( - new SseClientTransport(new() - { - Endpoint = new Uri(endpoint), - Name = "HealthCheck" - }), - clientOptions: new() - { - Capabilities = new() { } - }, - loggerFactory: loggerFactory); - - logger.LogInformation("MCP client created successfully, listing tools..."); - - IList mcpTools = await mcpClient.ListToolsAsync(); - string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); - - logger.LogInformation("Found {ToolCount} tools via MCP client: {Tools}", toolNames.Length, string.Join(", ", toolNames)); - - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: toolNames.Length != 0, - Message: toolNames.Length != 0 ? "Client successfully connected and listed tools" : "Client connected but no tools found", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "endpoint", endpoint }, - { "tools", string.Join(", ", toolNames) }, - { "tool_count", toolNames.Length.ToString() } - }); - } - catch (HttpRequestException httpEx) when (httpEx.Message.Contains("500")) - { - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: false, - Message: "MCP SSE endpoint returned 500 error - endpoint may not be properly configured", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "error_type", "http_500" }, - { "error_message", httpEx.Message } - } - ); - } - catch (HttpRequestException httpEx) - { - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: false, - Message: $"HTTP error connecting to MCP server: {httpEx.Message}", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "error_type", "http_error" }, - { "error_message", httpEx.Message } - } - ); - } - catch (TaskCanceledException tcEx) when (tcEx.InnerException is TimeoutException) - { - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: false, - Message: "Timeout connecting to MCP server", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "error_type", "timeout" } - } - ); - } - catch (Exception ex) - { - return new CheckResult( - Name: _clientConnectionCheckName, - IsHealthy: false, - Message: $"Client connection check failed: {ex.Message}", - Tags: new Dictionary - { - { "check_type", "client_connection" }, - { "error_type", "general_error" }, - { "error_message", ex.Message } - } - ); - } - } - - /// - /// Legacy method for backward compatibility - returns service registration check - /// - public static async Task CheckListEntityAsync(IServiceProvider serviceProvider) - { - SchemaLogic schemaLogic = new(serviceProvider); - string json = await schemaLogic.GetEntityMetadataAsJsonAsync(false); - JsonDocument doc = JsonDocument.Parse(json); - return new CheckResult( - Name: "List Entities", - IsHealthy: !string.IsNullOrEmpty(json), - Message: !string.IsNullOrEmpty(json) ? "Successfully listed entities" : "No entities found", - Tags: new Dictionary - { - { "check_type", "list_entities" }, - { "entities", doc } - }); - - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs new file mode 100644 index 0000000000..c4ca9d71d8 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Azure.DataApiBuilder.Mcp.Health; + +/// +/// Writes a simplified MCP health report in a consistent JSON format. +/// +public class McpHealthCheckOptions : HealthCheckOptions +{ + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public McpHealthCheckOptions(string tag) + { + ResultStatusCodes.Clear(); + ResultStatusCodes.Add(HealthStatus.Healthy, StatusCodes.Status200OK); + ResultStatusCodes.Add(HealthStatus.Degraded, StatusCodes.Status200OK); + ResultStatusCodes.Add(HealthStatus.Unhealthy, StatusCodes.Status503ServiceUnavailable); + + AllowCachingResponses = true; + + ResponseWriter = WriteAsync; + + Predicate = r => r.Tags.Contains(tag); + } + + private Task WriteAsync(HttpContext context, HealthReport report) + { + var response = new + { + status = report.Status.ToString(), + timestamp = DateTime.UtcNow, + checks = report.Entries.Select(kvp => new + { + name = kvp.Key, + status = kvp.Value.Status.ToString(), + description = kvp.Value.Description, + data = kvp.Value.Data + }) + }; + + context.Response.ContentType = "application/json"; + return context.Response.WriteAsync(JsonSerializer.Serialize(response, _jsonOptions)); + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 70aa514961..2ecb6db475 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; using System.ComponentModel; using System.Diagnostics; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp.Tools; @@ -27,15 +27,15 @@ Use this tool any time the user asks you to ECHO anything. When using this tool, respond with the raw result to the user. """)] - - public static string Echo(string message) => new(message.Reverse().ToArray()); + + public static string Echo(string message) => new([.. message.Reverse()]); [McpServerTool, Description(""" Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. """)] - public static async Task ListEntities( + public static async Task ListEntitiesAsync( [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] bool nameOnly = false, [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] @@ -43,14 +43,25 @@ public static async Task ListEntities( { _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); - - using (Activity activity = new("MCP")) - { - activity.SetTag("tool", nameof(ListEntities)); - - SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); - string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); - return jsonMetadata; - } + + using Activity activity = new("MCP"); + activity.SetTag("tool", nameof(ListEntitiesAsync)); + + SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); + string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); + return jsonMetadata; } + + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task CreateEntityRecordAsync() => throw new NotImplementedException(); + + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task ReadEntityRecordsAsync() => throw new NotImplementedException(); + + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task UpdateEntityRecordAsync() => throw new NotImplementedException(); + + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task DeleteEntityRecordAsync() => throw new NotImplementedException(); + } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index 104da66872..52d79d5695 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -8,7 +8,7 @@ namespace Azure.DataApiBuilder.Mcp.Tools; -public static class Extensions +internal static class Extensions { public static IServiceProvider? ServiceProvider { get; set; } @@ -42,6 +42,10 @@ private static void AddTool(IServiceCollection services, MethodInfo method) }); return tool; }; + + // Register the tool both as the factory function and as the McpServerTool type + // so that ServiceRegistrationHealthCheck can find it _ = services.AddSingleton(factory); + _ = services.AddSingleton(factory); } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs index 63b0d40bb8..c5a16cfc69 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs @@ -70,7 +70,7 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st } ISchema schema = await GetRawGraphQLSchemaAsync(); - List entityMetadataList = new(); + List entityMetadataList = []; // Get all object types from the schema that have @model directive List> entityTypes = GetEntityTypesFromSchema(schema); @@ -98,8 +98,8 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st } } - return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions - { + return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions + { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); @@ -110,11 +110,11 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st /// private static List> GetEntityTypesFromSchema(ISchema schema) { - List> entityTypes = new(); + List> entityTypes = []; foreach (INamedType type in schema.Types) { - if (type is IObjectType objectType && + if (type is IObjectType objectType && objectType.Directives.Any(d => d.Type.Name == "model") && !IsSystemType(objectType.Name)) { @@ -214,8 +214,8 @@ private string GetEntityDescriptionFromSchema(string entityName, IObjectType obj // Check if this is a stored procedure (has execute mutations) bool isStoredProcedure = IsStoredProcedureType(entityName); - - return isStoredProcedure + + return isStoredProcedure ? $"Represents the {entityName} stored procedure" : $"Represents a {entityName} entity in the system"; } @@ -247,7 +247,7 @@ private bool IsStoredProcedureType(string entityName) /// private static List BuildPrimaryKeysFromSchema(IObjectType objectType) { - List keys = new(); + List keys = []; foreach (IObjectField field in objectType.Fields) { @@ -256,7 +256,7 @@ private static List BuildPrimaryKeysFromSchema(IObjectType objectType) { bool isAutoGenerated = field.Directives.Any(d => d.Type.Name == "autoGenerated"); string databaseType = GetDatabaseTypeFromDirective(primaryKeyDirective); - + keys.Add(new { Name = field.Name, @@ -298,10 +298,10 @@ private static string GetDatabaseTypeFromDirective(Directive primaryKeyDirective /// private static List BuildFieldsFromSchema(IObjectType objectType) { - List fields = new(); + List fields = []; bool isStoredProcedure = IsStoredProcedureFromType(objectType); - foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => ( x, i))) + foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => (x, i))) { // Skip __typename field if it is the first field, it usually is if (value.Index == 0 && value.field.Name == "__typename") @@ -349,9 +349,9 @@ private static bool IsStoredProcedureFromType(IObjectType objectType) // Stored procedures typically don't have Connection, Aggregations, GroupBy suffixes // and are often named with patterns that suggest they're procedures string typeName = objectType.Name; - return !IsSystemType(typeName) && - !typeName.EndsWith("Connection") && - !typeName.EndsWith("Aggregations") && + return !IsSystemType(typeName) && + !typeName.EndsWith("Connection") && + !typeName.EndsWith("Aggregations") && !typeName.EndsWith("GroupBy") && (typeName.StartsWith("Get") || typeName.StartsWith("Execute") || typeName.Contains("Procedure")); } @@ -361,13 +361,13 @@ private static bool IsStoredProcedureFromType(IObjectType objectType) /// private async Task> BuildAllowedActionsFromSchema(string entityName, IObjectType objectType) { - List allowedActions = new(); + List allowedActions = []; Dictionary? entityPermissionsMap = _authorizationResolver.EntityPermissionsMap; // Determine entity type to know which operations to check bool isStoredProcedure = IsStoredProcedureType(entityName); - - List operationsToCheck = isStoredProcedure + + List operationsToCheck = isStoredProcedure ? new() { EntityActionOperation.Execute } : new() { EntityActionOperation.Create, EntityActionOperation.Read, EntityActionOperation.Update, EntityActionOperation.Delete }; @@ -414,7 +414,7 @@ private static string GetActionName(EntityActionOperation operation) return operation switch { EntityActionOperation.Create => "create_entity_record", - EntityActionOperation.Read => "read_entity_records", + EntityActionOperation.Read => "read_entity_records", EntityActionOperation.Update => "update_entity_record", EntityActionOperation.Delete => "delete_entity_record", EntityActionOperation.Execute => "execute_entity", @@ -493,7 +493,7 @@ private async Task> GetCreateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string createInputTypeName = $"Create{entityName}Input"; - + return GetFieldsFromInputType(schema, createInputTypeName); } @@ -504,7 +504,7 @@ private async Task> GetUpdateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string updateInputTypeName = $"Update{entityName}Input"; - + return GetFieldsFromInputType(schema, updateInputTypeName); } @@ -513,18 +513,18 @@ private async Task> GetUpdateFieldParametersFromSchema(string entit /// private async Task> GetStoredProcedureParametersFromSchema(string entityName) { - List parameters = new(); + List parameters = []; try { ISchema schema = await GetRawGraphQLSchemaAsync(); - + // Find the Mutation type if (schema.Types.FirstOrDefault(t => t.Name == "Mutation") is IObjectType mutationType) { // Look for execute mutation for this entity string expectedMutationName = $"execute{entityName}"; - IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => + IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => f.Name.Equals(expectedMutationName, StringComparison.OrdinalIgnoreCase)); if (mutationField != null) @@ -553,7 +553,7 @@ private async Task> GetStoredProcedureParametersFromSchema(string e /// private static List GetFieldsFromInputType(ISchema schema, string inputTypeName) { - List fields = new(); + List fields = []; if (schema.Types.FirstOrDefault(t => t.Name == inputTypeName) is IInputObjectType inputType) { @@ -573,7 +573,7 @@ private static string MapGraphQLTypeToString(IType type, bool forceRequired = fa { string baseType = GetBaseTypeName(type); bool isRequired = forceRequired || IsRequiredType(type); - + return isRequired ? $"{baseType}!" : baseType; } @@ -636,7 +636,7 @@ private static bool IsRequiredType(IType type) /// private static List GetResponseFieldsFromSchema(IObjectType objectType) { - List fields = new(); + List fields = []; foreach (IObjectField field in objectType.Fields) { @@ -671,7 +671,7 @@ private static object GetPrimaryKeyParametersFromSchema(IObjectType objectType) } // Multiple primary keys - List keyFields = new(); + List keyFields = []; foreach (IObjectField pkField in primaryKeys) { keyFields.Add($"{pkField.Name}: {MapGraphQLTypeToString(pkField.Type, forceRequired: true)}"); diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index efe6ace566..a9be4c8191 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -20,12 +20,22 @@ public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.ListEntities]; + public McpDmlTool[] DmlTools { get; init; } = [ + McpDmlTool.ListEntitiesAsync, + McpDmlTool.CreateEntityRecordAsync, + McpDmlTool.ReadEntityRecordsAsync, + McpDmlTool.UpdateEntityRecordAsync, + McpDmlTool.DeleteEntityRecordAsync + ]; } public enum McpDmlTool { - ListEntities + ListEntitiesAsync, + CreateEntityRecordAsync, + ReadEntityRecordsAsync, + UpdateEntityRecordAsync, + DeleteEntityRecordAsync } public record RuntimeConfig @@ -594,11 +604,11 @@ public static bool IsHotReloadable() public bool IsMultipleCreateOperationEnabled() { return Enum.GetNames(typeof(MultipleCreateSupportingDatabaseType)).Any(x => x.Equals(DataSource.DatabaseType.ToString(), StringComparison.OrdinalIgnoreCase)) && - (Runtime is not null && + Runtime is not null && Runtime.GraphQL is not null && Runtime.GraphQL.MultipleMutationOptions is not null && Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions is not null && - Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); + Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled; } public uint DefaultPageSize() @@ -649,7 +659,7 @@ public uint GetPaginationLimit(int? first) } else { - return (first == -1 ? maxPageSize : (uint)first); + return first == -1 ? maxPageSize : (uint)first; } } else diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index cafc7ac070..695eadb36a 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -41,7 +41,7 @@ "MsSql": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "jerry", + "launchUrl": "mcp/health", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, From db72d8766c19d5d12b7e2f985db81c0036eabe2c Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 3 Sep 2025 18:47:55 +0530 Subject: [PATCH 12/63] JSON based Dynamic tools configuration based --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 78 ++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 21fb7c9229..82370a9773 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Health; @@ -9,6 +10,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp { @@ -23,19 +27,83 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); } + // Register domain tools services.AddDmlTools(_mcpOptions); - services - .AddMcpServer() - .WithHttpTransport(); + // Register MCP server with dynamic tool handlers + services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "MyServer", Version = "1.0.0" }; + + options.Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability + { + ListToolsHandler = (request, ct) => + ValueTask.FromResult(new ListToolsResult + { + Tools = + [ + new Tool + { + Name = "echonew", + Description = "Echoes the input back to the client.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { ""message"": { ""type"": ""string"" } }, + ""required"": [""message""] + }" + ) + }, + new Tool + { + Name = "list_entities", + Description = "Lists all entities in the database." + } + ] + }), + + CallToolHandler = (request, ct) => + { + if (request.Params?.Name == "echonew" && + request.Params.Arguments?.TryGetValue("message", out JsonElement messageEl) == true) + { + string? msg = messageEl.ValueKind == JsonValueKind.String + ? messageEl.GetString() + : messageEl.ToString(); + + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Echo: {msg}" }] + }); + } + else if (request.Params?.Name == "list_entities") + { + // Call the ListEntities tool method from DmlTools + Task listEntitiesTask = DmlTools.ListEntities(); + listEntitiesTask.Wait(); // Wait for the async method to complete + string entitiesJson = listEntitiesTask.Result; + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] + }); + } + + throw new McpException($"Unknown tool: '{request.Params?.Name}'"); + } + } + }; + }) + .WithHttpTransport(); return services; } public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { - endpoints.MapMcp(); - endpoints.MapDabHealthChecks("/jerry"); + endpoints.MapMcp("/mcp"); + endpoints.MapDabHealthChecks("/mcp/health"); return endpoints; } } From 3948822118f182e4b5ed00f77b11cadab841354a Mon Sep 17 00:00:00 2001 From: Jerry Nixon Date: Mon, 8 Sep 2025 16:10:18 -0600 Subject: [PATCH 13/63] Tweaking tooling adding tests --- src/.filenesting.json | 10 +++ .../Azure.DataApiBuilder.Mcp.Tests.csproj | 40 +++++++++++ .../McpExtensionsTests.cs | 15 +++++ src/Azure.DataApiBuilder.Mcp/Extensions.cs | 7 +- .../{Tools => GraphQL}/SchemaLogic.cs | 16 ++--- .../McpRegistrationCheck.cs} | 8 +-- .../Health/Extensions.cs | 4 +- .../Health/ListEntitiesHealthCheck.cs | 35 ---------- .../Health/McpHealthCheckOptions.cs | 18 +++-- .../Tools/Dml.CreateEntityRecordAsync.cs | 13 ++++ .../Tools/Dml.DeleteEntityRecordAsync.cs | 13 ++++ .../Tools/Dml.DescribeEntitiesAsync.cs | 38 +++++++++++ .../Tools/Dml.Echo.cs | 16 +++++ .../Tools/Dml.ReadEntityRecordsAsync.cs | 13 ++++ .../Tools/Dml.UpdateEntityRecordAsync.cs | 13 ++++ src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs | 21 ++++++ .../Tools/DmlTools.cs | 67 ------------------- .../Tools/Extensions.cs | 18 ++--- src/Azure.DataApiBuilder.sln | 6 ++ src/Config/ObjectModel/RuntimeConfig.cs | 11 +-- 20 files changed, 235 insertions(+), 147 deletions(-) create mode 100644 src/.filenesting.json create mode 100644 src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj create mode 100644 src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs rename src/Azure.DataApiBuilder.Mcp/{Tools => GraphQL}/SchemaLogic.cs (98%) rename src/Azure.DataApiBuilder.Mcp/Health/{ClientConnectionHealthCheck.cs => Checks/McpRegistrationCheck.cs} (89%) delete mode 100644 src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs diff --git a/src/.filenesting.json b/src/.filenesting.json new file mode 100644 index 0000000000..b49a827bd4 --- /dev/null +++ b/src/.filenesting.json @@ -0,0 +1,10 @@ +{ + "help": "https://go.microsoft.com/fwlink/?linkid=866610" + + "root": true, + "dependentFileProviders": { + "add": { + "addedExtension": {} + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj b/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj new file mode 100644 index 0000000000..3a3dd3ed68 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs b/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs new file mode 100644 index 0000000000..9bed2d3f1b --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Tests +{ + [TestClass] + public class McpExtensionsTests + { + [TestMethod] + public void Test() + { + Assert.IsTrue(true); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 08662e14ec..8aca67dbfa 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -20,12 +20,11 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service { if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); + _mcpOptions = runtimeConfig?.Mcp ?? throw new NullReferenceException("Configuration is required."); } - services.AddDmlTools(_mcpOptions); - - services + _ = services + .AddDmlTools(_mcpOptions) .AddMcpServer() .AddMcpHealthChecks() .WithHttpTransport(); diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs similarity index 98% rename from src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs rename to src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs index c5a16cfc69..df5bca227d 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs +++ b/src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs @@ -331,9 +331,9 @@ private static List BuildFieldsFromSchema(IObjectType objectType) { Name = value.field.Name, Type = MapGraphQLTypeToString(value.field.Type), - Nullable = isNullable, - HasDefault = hasDefaultValue, - ReadOnly = isStoredProcedure || isAutoGenerated, // All stored procedure fields arereadonly + Required = !isNullable, + // HasDefault = hasDefaultValue, + // ReadOnly = isStoredProcedure || isAutoGenerated, // All stored procedure fields arereadonly Description = value.field.Description ?? $"Field {value.field.Name} of type {GetBaseTypeName(value.field.Type)}" }); } @@ -432,21 +432,21 @@ private async Task BuildActionParametersFromSchema(EntityActionOperation case EntityActionOperation.Create: return new { - Entity = "String!", + Entity = entityName, Fields = await GetCreateFieldParametersFromSchema(entityName) }; case EntityActionOperation.Update: return new { - Entity = "String!", + Entity = entityName, Fields = await GetUpdateFieldParametersFromSchema(entityName) }; case EntityActionOperation.Read: return new { - Entity = "String!", + Entity = entityName, Filters = "Object", First = "Int", After = "String" @@ -455,14 +455,14 @@ private async Task BuildActionParametersFromSchema(EntityActionOperation case EntityActionOperation.Delete: return new { - Entity = "String!", + Entity = entityName, Id = GetPrimaryKeyParametersFromSchema(objectType) }; case EntityActionOperation.Execute: return new { - Entity = "String!", + Entity = entityName, Fields = await GetStoredProcedureParametersFromSchema(entityName) }; diff --git a/src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs similarity index 89% rename from src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs rename to src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs index 8b19bfff2d..6e621a3bdf 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/ClientConnectionHealthCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs @@ -6,18 +6,18 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; -namespace Azure.DataApiBuilder.Mcp.Health; +namespace Azure.DataApiBuilder.Mcp.Health.Checks; /// /// Attempts to connect to local MCP SSE endpoint and list tools. /// -public sealed class ClientConnectionHealthCheck : IHealthCheck +public sealed class McpRegistrationCheck : IHealthCheck { private readonly IHttpContextAccessor _http; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; - public ClientConnectionHealthCheck(IHttpContextAccessor http, ILogger logger, ILoggerFactory loggerFactory) + public McpRegistrationCheck(IHttpContextAccessor http, ILogger logger, ILoggerFactory loggerFactory) { _http = http; _logger = logger; diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs index 1523446a5e..1ed8da0b30 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Mcp.Health.Checks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -16,8 +17,7 @@ public static IMcpServerBuilder AddMcpHealthChecks(this IMcpServerBuilder builde { _ = builder.Services.AddHttpContextAccessor(); _ = builder.Services.AddHealthChecks() - .AddCheck("MCP Registration", tags: [MCP_TAG]) - .AddCheck("MCP Tool: list_entities", tags: [MCP_TAG]); + .AddCheck("MCP Registration", tags: [MCP_TAG]); return builder; } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs deleted file mode 100644 index 0180d5e92d..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Health/ListEntitiesHealthCheck.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Azure.DataApiBuilder.Mcp.Health; - -/// -/// Placeholder list entities health check; reports healthy if service provider available. -/// TODO: Wire to real metadata service when available within MCP project. -/// -public sealed class ListEntitiesHealthCheck : IHealthCheck -{ - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - IServiceProvider? serviceProvider = Tools.Extensions.ServiceProvider; - if (serviceProvider == null) - { - return new HealthCheckResult( - status: HealthStatus.Unhealthy, - description: "Service provider not available", - data: new Dictionary { { "error", "no_service_provider" } }); - } - - string json = await new Tools - .SchemaLogic(serviceProvider) - .GetEntityMetadataAsJsonAsync(); - - return new( - HealthStatus.Healthy, - description: "Successfully retrieved entity metadata", - data: new Dictionary { { "entities", JsonDocument.Parse(json) } }); - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs index c4ca9d71d8..261324a9e0 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs @@ -21,20 +21,17 @@ public class McpHealthCheckOptions : HealthCheckOptions public McpHealthCheckOptions(string tag) { - ResultStatusCodes.Clear(); - ResultStatusCodes.Add(HealthStatus.Healthy, StatusCodes.Status200OK); - ResultStatusCodes.Add(HealthStatus.Degraded, StatusCodes.Status200OK); - ResultStatusCodes.Add(HealthStatus.Unhealthy, StatusCodes.Status503ServiceUnavailable); - AllowCachingResponses = true; - ResponseWriter = WriteAsync; - Predicate = r => r.Tags.Contains(tag); } - private Task WriteAsync(HttpContext context, HealthReport report) + private async Task WriteAsync(HttpContext context, HealthReport report) { + string json = await new Tools + .SchemaLogic(context.RequestServices) + .GetEntityMetadataAsJsonAsync(); + var response = new { status = report.Status.ToString(), @@ -45,10 +42,11 @@ private Task WriteAsync(HttpContext context, HealthReport report) status = kvp.Value.Status.ToString(), description = kvp.Value.Description, data = kvp.Value.Data - }) + }), + describe_entities = JsonDocument.Parse(json) }; context.Response.ContentType = "application/json"; - return context.Response.WriteAsync(JsonSerializer.Serialize(response, _jsonOptions)); + await context.Response.WriteAsync(JsonSerializer.Serialize(response, _jsonOptions)); } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs new file mode 100644 index 0000000000..f446b25113 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task CreateEntityRecordAsync() => throw new NotImplementedException(); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs new file mode 100644 index 0000000000..ea01e161f8 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task DeleteEntityRecordAsync() => throw new NotImplementedException(); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs new file mode 100644 index 0000000000..1d75b70af2 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description(""" + Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. + Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. + """)] + public static async Task DescribeEntitiesAsync( + [Description(""" + This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description. + """)] + bool nameOnly = false, + [Description(""" + This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter. + """)] + + string[]? entityNames = null) + { + _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", + nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); + + using Activity activity = new("MCP"); + activity.SetTag("tool", nameof(DescribeEntitiesAsync)); + + SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); + string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); + return jsonMetadata; + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs new file mode 100644 index 0000000000..22517a6ffa --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description(""" + Use this tool any time the user asks you to ECHO anything. + When using this tool, respond with the raw result to the user. + """)] + public static string Echo(string message) => new([.. message.Reverse()]); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs new file mode 100644 index 0000000000..4e45ba8490 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task ReadEntityRecordsAsync() => throw new NotImplementedException(); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs new file mode 100644 index 0000000000..8284c4a677 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +public static partial class Dml +{ + [McpServerTool, Description("Do not use this as it is not functional.")] + public static Task UpdateEntityRecordAsync() => throw new NotImplementedException(); +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs new file mode 100644 index 0000000000..5d82b9ebf1 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +[McpServerToolType] +public static partial class Dml +{ + private static readonly ILogger _logger; + + static Dml() + { + _logger = LoggerFactory.Create(builder => + { + builder.AddConsole(); + }).CreateLogger(nameof(Dml)); + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs deleted file mode 100644 index 2ecb6db475..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using System.Diagnostics; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -[McpServerToolType] -public static class DmlTools -{ - private static readonly ILogger _logger; - - static DmlTools() - { - _logger = LoggerFactory.Create(builder => - { - builder.AddConsole(); - }).CreateLogger(nameof(DmlTools)); - } - - [McpServerTool, Description(""" - - Use this tool any time the user asks you to ECHO anything. - When using this tool, respond with the raw result to the user. - - """)] - - public static string Echo(string message) => new([.. message.Reverse()]); - - [McpServerTool, Description(""" - - Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. - Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. - """)] - public static async Task ListEntitiesAsync( - [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] - bool nameOnly = false, - [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] - string[]? entityNames = null) - { - _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", - nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); - - using Activity activity = new("MCP"); - activity.SetTag("tool", nameof(ListEntitiesAsync)); - - SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); - string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); - return jsonMetadata; - } - - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task CreateEntityRecordAsync() => throw new NotImplementedException(); - - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task ReadEntityRecordsAsync() => throw new NotImplementedException(); - - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task UpdateEntityRecordAsync() => throw new NotImplementedException(); - - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task DeleteEntityRecordAsync() => throw new NotImplementedException(); - -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index 52d79d5695..a3aba99b28 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -12,12 +12,12 @@ internal static class Extensions { public static IServiceProvider? ServiceProvider { get; set; } - public static void AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) + public static IServiceCollection AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) { HashSet DmlToolNames = mcpOptions.DmlTools .Select(x => x.ToString()).ToHashSet(); - IEnumerable methods = typeof(DmlTools).GetMethods() + IEnumerable methods = typeof(Dml).GetMethods() .Where(method => DmlToolNames.Contains(method.Name)); foreach (MethodInfo method in methods) @@ -25,12 +25,15 @@ public static void AddDmlTools(this IServiceCollection services, McpOptions mcpO AddTool(services, method); } - AddTool(services, typeof(DmlTools).GetMethod("Echo")!); + // this is special during development + AddTool(services, typeof(Dml).GetMethod("Echo") ?? throw new Exception("Echo method not found")); + + return services; } private static void AddTool(IServiceCollection services, MethodInfo method) { - Func factory = (services) => + McpServerTool factory(IServiceProvider services) { ServiceProvider ??= services; @@ -41,11 +44,8 @@ private static void AddTool(IServiceCollection services, MethodInfo method) SerializerOptions = default }); return tool; - }; - - // Register the tool both as the factory function and as the McpServerTool type - // so that ServiceRegistrationHealthCheck can find it + } + _ = services.AddSingleton(factory); - _ = services.AddSingleton(factory); } } diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index aa3c8e2bad..51fb9d5ebd 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -33,6 +33,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Produc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp.Tests", "Azure.DataApiBuilder.Mcp.Tests\Azure.DataApiBuilder.Mcp.Tests.csproj", "{57ABA46C-F821-4C90-925C-5D755198942D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -79,6 +81,10 @@ Global {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.Build.0 = Release|Any CPU + {57ABA46C-F821-4C90-925C-5D755198942D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57ABA46C-F821-4C90-925C-5D755198942D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57ABA46C-F821-4C90-925C-5D755198942D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57ABA46C-F821-4C90-925C-5D755198942D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a9be4c8191..0c3fa1d010 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,17 +11,12 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record AiOptions -{ - public McpOptions? Mcp { get; init; } = new(); -} - public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; public McpDmlTool[] DmlTools { get; init; } = [ - McpDmlTool.ListEntitiesAsync, + McpDmlTool.DescribeEntitiesAsync, McpDmlTool.CreateEntityRecordAsync, McpDmlTool.ReadEntityRecordsAsync, McpDmlTool.UpdateEntityRecordAsync, @@ -31,7 +26,7 @@ public record McpOptions public enum McpDmlTool { - ListEntitiesAsync, + DescribeEntitiesAsync, CreateEntityRecordAsync, ReadEntityRecordsAsync, UpdateEntityRecordAsync, @@ -40,7 +35,7 @@ public enum McpDmlTool public record RuntimeConfig { - public AiOptions? Ai { get; init; } = new(); + public McpOptions? Mcp { get; init; } = new(); [JsonPropertyName("$schema")] public string Schema { get; init; } From 66bba27e1dfc5e04de4e79476c75142b4aeafeb2 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 9 Sep 2025 21:23:08 +0530 Subject: [PATCH 14/63] Revert "Updated mege" This reverts commit 04b19f9e5667d52471702b0fb81b07179ef0a38e, reversing changes made to 3948822118f182e4b5ed00f77b11cadab841354a. --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 836884a53a..8aca67dbfa 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Health; @@ -10,9 +9,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp { @@ -38,8 +34,8 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string? pattern = null) { - endpoints.MapMcp(pattern); - endpoints.MapMcpHealthEndpoint(pattern & "/health"); + endpoints.MapMcp(); + endpoints.MapMcpHealthEndpoint(pattern); return endpoints; } } From ba6909f5b855910cb7722c01422267f041bd10ed Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 9 Sep 2025 21:27:49 +0530 Subject: [PATCH 15/63] Revert "Merge branch 'jerry-mcp-core' of https://github.com/Azure/data-api-builder into jerry-mcp-core" This reverts commit 0b649dbbc877a8ad009ac31eb4a082664d49bce3, reversing changes made to 2240172f97cc734f9bc0effc16b6755a8cc144e8. --- src/.filenesting.json | 10 - .../Azure.DataApiBuilder.Mcp.Tests.csproj | 40 ---- .../McpExtensionsTests.cs | 15 -- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 78 ++++++- .../Health/CheckResult.cs | 25 ++ .../Health/Checks/McpRegistrationCheck.cs | 79 ------- .../Health/Extensions.cs | 43 ++-- .../Health/McpCheck.cs | 217 ++++++++++++++++++ .../Health/McpHealthCheckOptions.cs | 52 ----- .../Tools/Dml.CreateEntityRecordAsync.cs | 13 -- .../Tools/Dml.DeleteEntityRecordAsync.cs | 13 -- .../Tools/Dml.DescribeEntitiesAsync.cs | 38 --- .../Tools/Dml.Echo.cs | 16 -- .../Tools/Dml.ReadEntityRecordsAsync.cs | 13 -- .../Tools/Dml.UpdateEntityRecordAsync.cs | 13 -- src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs | 21 -- .../Tools/DmlTools.cs | 56 +++++ .../Tools/Extensions.cs | 16 +- .../{GraphQL => Tools}/SchemaLogic.cs | 70 +++--- src/Azure.DataApiBuilder.sln | 6 - src/Config/ObjectModel/RuntimeConfig.cs | 27 +-- src/Service/Properties/launchSettings.json | 2 +- 22 files changed, 446 insertions(+), 417 deletions(-) delete mode 100644 src/.filenesting.json delete mode 100644 src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj delete mode 100644 src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs rename src/Azure.DataApiBuilder.Mcp/{GraphQL => Tools}/SchemaLogic.cs (95%) diff --git a/src/.filenesting.json b/src/.filenesting.json deleted file mode 100644 index b49a827bd4..0000000000 --- a/src/.filenesting.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "help": "https://go.microsoft.com/fwlink/?linkid=866610" - - "root": true, - "dependentFileProviders": { - "add": { - "addedExtension": {} - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj b/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj deleted file mode 100644 index 3a3dd3ed68..0000000000 --- a/src/Azure.DataApiBuilder.Mcp.Tests/Azure.DataApiBuilder.Mcp.Tests.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - net8.0 - enable - enable - - false - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - diff --git a/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs b/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs deleted file mode 100644 index 9bed2d3f1b..0000000000 --- a/src/Azure.DataApiBuilder.Mcp.Tests/McpExtensionsTests.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Azure.DataApiBuilder.Mcp.Tests -{ - [TestClass] - public class McpExtensionsTests - { - [TestMethod] - public void Test() - { - Assert.IsTrue(true); - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index 8aca67dbfa..b455321c7c 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -20,19 +20,83 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service { if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - _mcpOptions = runtimeConfig?.Mcp ?? throw new NullReferenceException("Configuration is required."); + _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); } - _ = services - .AddDmlTools(_mcpOptions) - .AddMcpServer() - .AddMcpHealthChecks() - .WithHttpTransport(); + // Register domain tools + services.AddDmlTools(_mcpOptions); + + // Register MCP server with dynamic tool handlers + services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "MyServer", Version = "1.0.0" }; + + options.Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability + { + ListToolsHandler = (request, ct) => + ValueTask.FromResult(new ListToolsResult + { + Tools = + [ + new Tool + { + Name = "echonew", + Description = "Echoes the input back to the client.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { ""message"": { ""type"": ""string"" } }, + ""required"": [""message""] + }" + ) + }, + new Tool + { + Name = "list_entities", + Description = "Lists all entities in the database." + } + ] + }), + + CallToolHandler = (request, ct) => + { + if (request.Params?.Name == "echonew" && + request.Params.Arguments?.TryGetValue("message", out JsonElement messageEl) == true) + { + string? msg = messageEl.ValueKind == JsonValueKind.String + ? messageEl.GetString() + : messageEl.ToString(); + + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Echo: {msg}" }] + }); + } + else if (request.Params?.Name == "list_entities") + { + // Call the ListEntities tool method from DmlTools + Task listEntitiesTask = DmlTools.ListEntities(); + listEntitiesTask.Wait(); // Wait for the async method to complete + string entitiesJson = listEntitiesTask.Result; + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] + }); + } + + throw new McpException($"Unknown tool: '{request.Params?.Name}'"); + } + } + }; + }) + .WithHttpTransport(); return services; } - public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string? pattern = null) + public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { endpoints.MapMcp(); endpoints.MapMcpHealthEndpoint(pattern); diff --git a/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs new file mode 100644 index 0000000000..c8279a1bec --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/CheckResult.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Health; + +public record CheckResult(string Name, bool IsHealthy, string? Message, Dictionary Tags) +{ + public string Status => IsHealthy ? "Healthy" : "Unhealthy"; + + public object ToReport() + { + return IsHealthy ? new + { + Name, + Status, + Tags + } : new + { + Name, + Status, + Tags, + Message + }; + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs deleted file mode 100644 index 6e621a3bdf..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Health/Checks/McpRegistrationCheck.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; - -namespace Azure.DataApiBuilder.Mcp.Health.Checks; - -/// -/// Attempts to connect to local MCP SSE endpoint and list tools. -/// -public sealed class McpRegistrationCheck : IHealthCheck -{ - private readonly IHttpContextAccessor _http; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - - public McpRegistrationCheck(IHttpContextAccessor http, ILogger logger, ILoggerFactory loggerFactory) - { - _http = http; - _logger = logger; - _loggerFactory = loggerFactory; - } - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - HttpRequest? request = _http.HttpContext?.Request; - if (request == null) - { - return new HealthCheckResult(HealthStatus.Degraded, description: "HttpContext not available", data: new Dictionary { { "error", "no_http_context" } }); - } - - string endpoint = $"{request.Scheme}://{request.Host}"; - try - { - SseClientTransport clientTransport = new(new() - { - Endpoint = new Uri(endpoint), - Name = "HealthCheck" - }); - - McpClientOptions clientOptions = new() - { - Capabilities = new() { } - }; - - IMcpClient mcpClient = await McpClientFactory.CreateAsync( - clientTransport: clientTransport, - clientOptions: clientOptions, - loggerFactory: _loggerFactory); - - IList tools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); - - string[] names = [.. tools.Select(t => t.Name).OrderBy(n => n)]; - - if (names.Length == 0) - { - return new HealthCheckResult(HealthStatus.Unhealthy, description: "Connected but no tools returned", data: new Dictionary - { - { "endpoint", endpoint }, - { "toolCount", 0 } - }); - } - - return new HealthCheckResult(HealthStatus.Healthy, data: new Dictionary - { - { "endpoint", endpoint }, - { "tools", names } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Client connection health check failed"); - return new HealthCheckResult(HealthStatus.Unhealthy, description: ex.Message, data: new Dictionary { { "endpoint", endpoint }, { "error", ex.GetType().Name } }); - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs index 1ed8da0b30..a671a1f136 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/Extensions.cs @@ -2,33 +2,38 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; -using Azure.DataApiBuilder.Mcp.Health.Checks; +using System.Text.Json; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Health; -internal static class Extensions +public static class Extensions { - private const string MCP_TAG = "mcp"; - - public static IMcpServerBuilder AddMcpHealthChecks(this IMcpServerBuilder builder) + public static IEndpointRouteBuilder MapDabHealthChecks(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { - _ = builder.Services.AddHttpContextAccessor(); - _ = builder.Services.AddHealthChecks() - .AddCheck("MCP Registration", tags: [MCP_TAG]); - return builder; - } + endpoints.MapHealthChecks(pattern, new() + { + ResponseWriter = async (context, report) => + { + CheckResult[] mcpChecks = await McpCheck.CheckAllAsync(context.RequestServices); - /// - /// Maps a MCP-only health endpoint (default: /mcp/health). Predicate filters to MCP-tagged checks only. - /// - public static IEndpointRouteBuilder MapMcpHealthEndpoint(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string? pattern) - { - _ = endpoints.MapHealthChecks( - pattern: pattern ?? "/mcp/health", - options: new McpHealthCheckOptions(MCP_TAG)); + var response = new + { + Status = mcpChecks.All(c => c.IsHealthy) ? "Healthy" : "Unhealthy", + Timestamp = DateTime.UtcNow, + Checks = mcpChecks.Select(c => c.ToReport()).ToArray() + }; + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + })); + } + }); return endpoints; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs new file mode 100644 index 0000000000..3cf3e844c3 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using ModelContextProtocol.Client; +using Azure.DataApiBuilder.Mcp.Tools; +using System.Text.Json; + +namespace Azure.DataApiBuilder.Mcp.Health; + +public class McpCheck +{ + private readonly static string _serviceRegistrationCheckName = "MCP Server Tools - Service Registration"; + private readonly static string _clientConnectionCheckName = "MCP Server Tools - Client Connection"; + + /// + /// Performs comprehensive MCP health checks including both service registration and client connection + /// + public static async Task CheckAllAsync(IServiceProvider serviceProvider) + { + CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); + CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); + CheckResult checkListEntity = await CheckListEntityAsync(serviceProvider); + + return new[] { serviceRegistrationCheck, clientConnectionCheck, checkListEntity }; + } + + /// + /// Checks if MCP tools are properly registered in the service provider + /// + public static CheckResult CheckServiceRegistration(IServiceProvider serviceProvider) + { + try + { + ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); + ILogger logger = loggerFactory.CreateLogger(); + + logger.LogInformation("Checking MCP server tools service registration"); + + // Check if MCP tools are registered in the service provider + IEnumerable mcpTools = serviceProvider.GetServices(); + string[] toolNames = mcpTools + .Select(t => t.ProtocolTool.Name) + .OrderBy(t => t).ToArray(); + + logger.LogInformation("Found {ToolCount} registered MCP tools in services: {Tools}", toolNames.Length, string.Join(", ", toolNames)); + + return new CheckResult( + Name: _serviceRegistrationCheckName, + IsHealthy: toolNames.Length != 0, + Message: toolNames.Length != 0 ? "Tools registered in services" : "No tools registered in services", + Tags: new Dictionary + { + { "check_type", "service_registration" }, + { "tools", string.Join(", ", toolNames) }, + { "tool_count", toolNames.Length.ToString() } + }); + } + catch (Exception ex) + { + return new CheckResult( + Name: _serviceRegistrationCheckName, + IsHealthy: false, + Message: $"Service registration check failed: {ex.Message}", + Tags: new Dictionary + { + { "check_type", "service_registration" }, + { "error_type", "general_error" }, + { "error_message", ex.Message } + } + ); + } + } + + /// + /// Checks if MCP client can successfully connect and list tools from the MCP server + /// + public static async Task CheckClientConnectionAsync(IServiceProvider serviceProvider) + { + try + { + ILoggerFactory loggerFactory = serviceProvider.GetRequiredService(); + ILogger logger = loggerFactory.CreateLogger(); + + // Get the MCP server endpoint + HttpRequest? request = serviceProvider.GetService()?.HttpContext?.Request; + if (request == null) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: "HttpContext not available for client connection test", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "no_http_context" } + } + ); + } + + string scheme = request.Scheme; + string host = request.Host.Value; + string endpoint = $"{scheme}://{host}"; + + logger.LogInformation("Testing MCP client connection to endpoint: {Endpoint}", endpoint); + + IMcpClient mcpClient = await McpClientFactory.CreateAsync( + new SseClientTransport(new() + { + Endpoint = new Uri(endpoint), + Name = "HealthCheck" + }), + clientOptions: new() + { + Capabilities = new() { } + }, + loggerFactory: loggerFactory); + + logger.LogInformation("MCP client created successfully, listing tools..."); + + IList mcpTools = await mcpClient.ListToolsAsync(); + string[] toolNames = mcpTools.Select(t => t.Name).OrderBy(t => t).ToArray(); + + logger.LogInformation("Found {ToolCount} tools via MCP client: {Tools}", toolNames.Length, string.Join(", ", toolNames)); + + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: toolNames.Length != 0, + Message: toolNames.Length != 0 ? "Client successfully connected and listed tools" : "Client connected but no tools found", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "endpoint", endpoint }, + { "tools", string.Join(", ", toolNames) }, + { "tool_count", toolNames.Length.ToString() } + }); + } + catch (HttpRequestException httpEx) when (httpEx.Message.Contains("500")) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: "MCP SSE endpoint returned 500 error - endpoint may not be properly configured", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "http_500" }, + { "error_message", httpEx.Message } + } + ); + } + catch (HttpRequestException httpEx) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: $"HTTP error connecting to MCP server: {httpEx.Message}", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "http_error" }, + { "error_message", httpEx.Message } + } + ); + } + catch (TaskCanceledException tcEx) when (tcEx.InnerException is TimeoutException) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: "Timeout connecting to MCP server", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "timeout" } + } + ); + } + catch (Exception ex) + { + return new CheckResult( + Name: _clientConnectionCheckName, + IsHealthy: false, + Message: $"Client connection check failed: {ex.Message}", + Tags: new Dictionary + { + { "check_type", "client_connection" }, + { "error_type", "general_error" }, + { "error_message", ex.Message } + } + ); + } + } + + /// + /// Legacy method for backward compatibility - returns service registration check + /// + public static async Task CheckListEntityAsync(IServiceProvider serviceProvider) + { + SchemaLogic schemaLogic = new(serviceProvider); + string json = await schemaLogic.GetEntityMetadataAsJsonAsync(false); + JsonDocument doc = JsonDocument.Parse(json); + return new CheckResult( + Name: "List Entities", + IsHealthy: !string.IsNullOrEmpty(json), + Message: !string.IsNullOrEmpty(json) ? "Successfully listed entities" : "No entities found", + Tags: new Dictionary + { + { "check_type", "list_entities" }, + { "entities", doc } + }); + + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs deleted file mode 100644 index 261324a9e0..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpHealthCheckOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Azure.DataApiBuilder.Mcp.Health; - -/// -/// Writes a simplified MCP health report in a consistent JSON format. -/// -public class McpHealthCheckOptions : HealthCheckOptions -{ - private readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; - - public McpHealthCheckOptions(string tag) - { - AllowCachingResponses = true; - ResponseWriter = WriteAsync; - Predicate = r => r.Tags.Contains(tag); - } - - private async Task WriteAsync(HttpContext context, HealthReport report) - { - string json = await new Tools - .SchemaLogic(context.RequestServices) - .GetEntityMetadataAsJsonAsync(); - - var response = new - { - status = report.Status.ToString(), - timestamp = DateTime.UtcNow, - checks = report.Entries.Select(kvp => new - { - name = kvp.Key, - status = kvp.Value.Status.ToString(), - description = kvp.Value.Description, - data = kvp.Value.Data - }), - describe_entities = JsonDocument.Parse(json) - }; - - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(response, _jsonOptions)); - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs deleted file mode 100644 index f446b25113..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.CreateEntityRecordAsync.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task CreateEntityRecordAsync() => throw new NotImplementedException(); -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs deleted file mode 100644 index ea01e161f8..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DeleteEntityRecordAsync.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task DeleteEntityRecordAsync() => throw new NotImplementedException(); -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs deleted file mode 100644 index 1d75b70af2..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.DescribeEntitiesAsync.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using System.Diagnostics; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description(""" - Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. - Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. - """)] - public static async Task DescribeEntitiesAsync( - [Description(""" - This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description. - """)] - bool nameOnly = false, - [Description(""" - This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter. - """)] - - string[]? entityNames = null) - { - _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", - nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); - - using Activity activity = new("MCP"); - activity.SetTag("tool", nameof(DescribeEntitiesAsync)); - - SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); - string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); - return jsonMetadata; - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs deleted file mode 100644 index 22517a6ffa..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.Echo.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description(""" - Use this tool any time the user asks you to ECHO anything. - When using this tool, respond with the raw result to the user. - """)] - public static string Echo(string message) => new([.. message.Reverse()]); -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs deleted file mode 100644 index 4e45ba8490..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.ReadEntityRecordsAsync.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task ReadEntityRecordsAsync() => throw new NotImplementedException(); -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs deleted file mode 100644 index 8284c4a677..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.UpdateEntityRecordAsync.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static partial class Dml -{ - [McpServerTool, Description("Do not use this as it is not functional.")] - public static Task UpdateEntityRecordAsync() => throw new NotImplementedException(); -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs deleted file mode 100644 index 5d82b9ebf1..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Dml.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -[McpServerToolType] -public static partial class Dml -{ - private static readonly ILogger _logger; - - static Dml() - { - _logger = LoggerFactory.Create(builder => - { - builder.AddConsole(); - }).CreateLogger(nameof(Dml)); - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs new file mode 100644 index 0000000000..70aa514961 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Diagnostics; + +namespace Azure.DataApiBuilder.Mcp.Tools; + +[McpServerToolType] +public static class DmlTools +{ + private static readonly ILogger _logger; + + static DmlTools() + { + _logger = LoggerFactory.Create(builder => + { + builder.AddConsole(); + }).CreateLogger(nameof(DmlTools)); + } + + [McpServerTool, Description(""" + + Use this tool any time the user asks you to ECHO anything. + When using this tool, respond with the raw result to the user. + + """)] + + public static string Echo(string message) => new(message.Reverse().ToArray()); + + [McpServerTool, Description(""" + + Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. + Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. + """)] + public static async Task ListEntities( + [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] + bool nameOnly = false, + [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] + string[]? entityNames = null) + { + _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", + nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); + + using (Activity activity = new("MCP")) + { + activity.SetTag("tool", nameof(ListEntities)); + + SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); + string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); + return jsonMetadata; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index a3aba99b28..104da66872 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -8,16 +8,16 @@ namespace Azure.DataApiBuilder.Mcp.Tools; -internal static class Extensions +public static class Extensions { public static IServiceProvider? ServiceProvider { get; set; } - public static IServiceCollection AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) + public static void AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) { HashSet DmlToolNames = mcpOptions.DmlTools .Select(x => x.ToString()).ToHashSet(); - IEnumerable methods = typeof(Dml).GetMethods() + IEnumerable methods = typeof(DmlTools).GetMethods() .Where(method => DmlToolNames.Contains(method.Name)); foreach (MethodInfo method in methods) @@ -25,15 +25,12 @@ public static IServiceCollection AddDmlTools(this IServiceCollection services, M AddTool(services, method); } - // this is special during development - AddTool(services, typeof(Dml).GetMethod("Echo") ?? throw new Exception("Echo method not found")); - - return services; + AddTool(services, typeof(DmlTools).GetMethod("Echo")!); } private static void AddTool(IServiceCollection services, MethodInfo method) { - McpServerTool factory(IServiceProvider services) + Func factory = (services) => { ServiceProvider ??= services; @@ -44,8 +41,7 @@ McpServerTool factory(IServiceProvider services) SerializerOptions = default }); return tool; - } - + }; _ = services.AddSingleton(factory); } } diff --git a/src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs similarity index 95% rename from src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs rename to src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs index df5bca227d..63b0d40bb8 100644 --- a/src/Azure.DataApiBuilder.Mcp/GraphQL/SchemaLogic.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs @@ -70,7 +70,7 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st } ISchema schema = await GetRawGraphQLSchemaAsync(); - List entityMetadataList = []; + List entityMetadataList = new(); // Get all object types from the schema that have @model directive List> entityTypes = GetEntityTypesFromSchema(schema); @@ -98,8 +98,8 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st } } - return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions - { + return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions + { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); @@ -110,11 +110,11 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st /// private static List> GetEntityTypesFromSchema(ISchema schema) { - List> entityTypes = []; + List> entityTypes = new(); foreach (INamedType type in schema.Types) { - if (type is IObjectType objectType && + if (type is IObjectType objectType && objectType.Directives.Any(d => d.Type.Name == "model") && !IsSystemType(objectType.Name)) { @@ -214,8 +214,8 @@ private string GetEntityDescriptionFromSchema(string entityName, IObjectType obj // Check if this is a stored procedure (has execute mutations) bool isStoredProcedure = IsStoredProcedureType(entityName); - - return isStoredProcedure + + return isStoredProcedure ? $"Represents the {entityName} stored procedure" : $"Represents a {entityName} entity in the system"; } @@ -247,7 +247,7 @@ private bool IsStoredProcedureType(string entityName) /// private static List BuildPrimaryKeysFromSchema(IObjectType objectType) { - List keys = []; + List keys = new(); foreach (IObjectField field in objectType.Fields) { @@ -256,7 +256,7 @@ private static List BuildPrimaryKeysFromSchema(IObjectType objectType) { bool isAutoGenerated = field.Directives.Any(d => d.Type.Name == "autoGenerated"); string databaseType = GetDatabaseTypeFromDirective(primaryKeyDirective); - + keys.Add(new { Name = field.Name, @@ -298,10 +298,10 @@ private static string GetDatabaseTypeFromDirective(Directive primaryKeyDirective /// private static List BuildFieldsFromSchema(IObjectType objectType) { - List fields = []; + List fields = new(); bool isStoredProcedure = IsStoredProcedureFromType(objectType); - foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => (x, i))) + foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => ( x, i))) { // Skip __typename field if it is the first field, it usually is if (value.Index == 0 && value.field.Name == "__typename") @@ -331,9 +331,9 @@ private static List BuildFieldsFromSchema(IObjectType objectType) { Name = value.field.Name, Type = MapGraphQLTypeToString(value.field.Type), - Required = !isNullable, - // HasDefault = hasDefaultValue, - // ReadOnly = isStoredProcedure || isAutoGenerated, // All stored procedure fields arereadonly + Nullable = isNullable, + HasDefault = hasDefaultValue, + ReadOnly = isStoredProcedure || isAutoGenerated, // All stored procedure fields arereadonly Description = value.field.Description ?? $"Field {value.field.Name} of type {GetBaseTypeName(value.field.Type)}" }); } @@ -349,9 +349,9 @@ private static bool IsStoredProcedureFromType(IObjectType objectType) // Stored procedures typically don't have Connection, Aggregations, GroupBy suffixes // and are often named with patterns that suggest they're procedures string typeName = objectType.Name; - return !IsSystemType(typeName) && - !typeName.EndsWith("Connection") && - !typeName.EndsWith("Aggregations") && + return !IsSystemType(typeName) && + !typeName.EndsWith("Connection") && + !typeName.EndsWith("Aggregations") && !typeName.EndsWith("GroupBy") && (typeName.StartsWith("Get") || typeName.StartsWith("Execute") || typeName.Contains("Procedure")); } @@ -361,13 +361,13 @@ private static bool IsStoredProcedureFromType(IObjectType objectType) /// private async Task> BuildAllowedActionsFromSchema(string entityName, IObjectType objectType) { - List allowedActions = []; + List allowedActions = new(); Dictionary? entityPermissionsMap = _authorizationResolver.EntityPermissionsMap; // Determine entity type to know which operations to check bool isStoredProcedure = IsStoredProcedureType(entityName); - - List operationsToCheck = isStoredProcedure + + List operationsToCheck = isStoredProcedure ? new() { EntityActionOperation.Execute } : new() { EntityActionOperation.Create, EntityActionOperation.Read, EntityActionOperation.Update, EntityActionOperation.Delete }; @@ -414,7 +414,7 @@ private static string GetActionName(EntityActionOperation operation) return operation switch { EntityActionOperation.Create => "create_entity_record", - EntityActionOperation.Read => "read_entity_records", + EntityActionOperation.Read => "read_entity_records", EntityActionOperation.Update => "update_entity_record", EntityActionOperation.Delete => "delete_entity_record", EntityActionOperation.Execute => "execute_entity", @@ -432,21 +432,21 @@ private async Task BuildActionParametersFromSchema(EntityActionOperation case EntityActionOperation.Create: return new { - Entity = entityName, + Entity = "String!", Fields = await GetCreateFieldParametersFromSchema(entityName) }; case EntityActionOperation.Update: return new { - Entity = entityName, + Entity = "String!", Fields = await GetUpdateFieldParametersFromSchema(entityName) }; case EntityActionOperation.Read: return new { - Entity = entityName, + Entity = "String!", Filters = "Object", First = "Int", After = "String" @@ -455,14 +455,14 @@ private async Task BuildActionParametersFromSchema(EntityActionOperation case EntityActionOperation.Delete: return new { - Entity = entityName, + Entity = "String!", Id = GetPrimaryKeyParametersFromSchema(objectType) }; case EntityActionOperation.Execute: return new { - Entity = entityName, + Entity = "String!", Fields = await GetStoredProcedureParametersFromSchema(entityName) }; @@ -493,7 +493,7 @@ private async Task> GetCreateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string createInputTypeName = $"Create{entityName}Input"; - + return GetFieldsFromInputType(schema, createInputTypeName); } @@ -504,7 +504,7 @@ private async Task> GetUpdateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string updateInputTypeName = $"Update{entityName}Input"; - + return GetFieldsFromInputType(schema, updateInputTypeName); } @@ -513,18 +513,18 @@ private async Task> GetUpdateFieldParametersFromSchema(string entit /// private async Task> GetStoredProcedureParametersFromSchema(string entityName) { - List parameters = []; + List parameters = new(); try { ISchema schema = await GetRawGraphQLSchemaAsync(); - + // Find the Mutation type if (schema.Types.FirstOrDefault(t => t.Name == "Mutation") is IObjectType mutationType) { // Look for execute mutation for this entity string expectedMutationName = $"execute{entityName}"; - IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => + IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => f.Name.Equals(expectedMutationName, StringComparison.OrdinalIgnoreCase)); if (mutationField != null) @@ -553,7 +553,7 @@ private async Task> GetStoredProcedureParametersFromSchema(string e /// private static List GetFieldsFromInputType(ISchema schema, string inputTypeName) { - List fields = []; + List fields = new(); if (schema.Types.FirstOrDefault(t => t.Name == inputTypeName) is IInputObjectType inputType) { @@ -573,7 +573,7 @@ private static string MapGraphQLTypeToString(IType type, bool forceRequired = fa { string baseType = GetBaseTypeName(type); bool isRequired = forceRequired || IsRequiredType(type); - + return isRequired ? $"{baseType}!" : baseType; } @@ -636,7 +636,7 @@ private static bool IsRequiredType(IType type) /// private static List GetResponseFieldsFromSchema(IObjectType objectType) { - List fields = []; + List fields = new(); foreach (IObjectField field in objectType.Fields) { @@ -671,7 +671,7 @@ private static object GetPrimaryKeyParametersFromSchema(IObjectType objectType) } // Multiple primary keys - List keyFields = []; + List keyFields = new(); foreach (IObjectField pkField in primaryKeys) { keyFields.Add($"{pkField.Name}: {MapGraphQLTypeToString(pkField.Type, forceRequired: true)}"); diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index 51fb9d5ebd..aa3c8e2bad 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -33,8 +33,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Produc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp.Tests", "Azure.DataApiBuilder.Mcp.Tests\Azure.DataApiBuilder.Mcp.Tests.csproj", "{57ABA46C-F821-4C90-925C-5D755198942D}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,10 +79,6 @@ Global {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.Build.0 = Release|Any CPU - {57ABA46C-F821-4C90-925C-5D755198942D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57ABA46C-F821-4C90-925C-5D755198942D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57ABA46C-F821-4C90-925C-5D755198942D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57ABA46C-F821-4C90-925C-5D755198942D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 0c3fa1d010..efe6ace566 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,31 +11,26 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; +public record AiOptions +{ + public McpOptions? Mcp { get; init; } = new(); +} + public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [ - McpDmlTool.DescribeEntitiesAsync, - McpDmlTool.CreateEntityRecordAsync, - McpDmlTool.ReadEntityRecordsAsync, - McpDmlTool.UpdateEntityRecordAsync, - McpDmlTool.DeleteEntityRecordAsync - ]; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.ListEntities]; } public enum McpDmlTool { - DescribeEntitiesAsync, - CreateEntityRecordAsync, - ReadEntityRecordsAsync, - UpdateEntityRecordAsync, - DeleteEntityRecordAsync + ListEntities } public record RuntimeConfig { - public McpOptions? Mcp { get; init; } = new(); + public AiOptions? Ai { get; init; } = new(); [JsonPropertyName("$schema")] public string Schema { get; init; } @@ -599,11 +594,11 @@ public static bool IsHotReloadable() public bool IsMultipleCreateOperationEnabled() { return Enum.GetNames(typeof(MultipleCreateSupportingDatabaseType)).Any(x => x.Equals(DataSource.DatabaseType.ToString(), StringComparison.OrdinalIgnoreCase)) && - Runtime is not null && + (Runtime is not null && Runtime.GraphQL is not null && Runtime.GraphQL.MultipleMutationOptions is not null && Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions is not null && - Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled; + Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); } public uint DefaultPageSize() @@ -654,7 +649,7 @@ public uint GetPaginationLimit(int? first) } else { - return first == -1 ? maxPageSize : (uint)first; + return (first == -1 ? maxPageSize : (uint)first); } } else diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index 695eadb36a..cafc7ac070 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -41,7 +41,7 @@ "MsSql": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "mcp/health", + "launchUrl": "jerry", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, From ddca78bbde9ca31f1c2b3fb6efa9f507e992dd06 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 9 Sep 2025 22:58:59 +0530 Subject: [PATCH 16/63] DAB MCP Runtime (#2866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? - Introduces a dedicated runtime layer for MCP ## What is this change? - Adds a runtime initialization module that centralizes configuration load, validation, DI registration, and service/materialization order. - Refactors existing startup code into clearer phases; removes duplicated wiring. - Improves logging structure and prepares for future feature flags / layered config overrides. - Cleans up deprecated code paths (overall net +901 / −130 LOC). - No intentional breaking changes (verify before merge). ## How was this tested? - [ ] Integration Tests - [ ] Unit Tests ## Sample Request(s) - Discover tools: POST `http://localhost:5000/mcp` JSON body ``` { "jsonrpc": "2.0", "id": "1", "method": "tools/list", "params": {} } ``` - List entities: POST `http://localhost:5000/mcp` JSON body ``` { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_entities" } } ``` --------- Co-authored-by: Rahul Nishant <53243582+ranishan@users.noreply.github.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh Munde --- CODEOWNERS | 2 +- dab_aca_deploy.ps1 | 91 ++++++ dab_aci_deploy.ps1 | 64 +++++ installcredprovider.ps1 | 259 ++++++++++++++++++ schemas/dab.draft.schema.json | 59 +++- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 92 +++++-- .../Health/McpCheck.cs | 16 +- .../Tools/DmlTools.cs | 25 +- .../Tools/Extensions.cs | 41 ++- .../Tools/SchemaLogic.cs | 47 ++-- src/Cli.Tests/ExporterTests.cs | 6 +- src/Cli.Tests/ModuleInitializer.cs | 2 + src/Cli.Tests/UpdateEntityTests.cs | 4 +- src/Cli/Commands/ConfigureOptions.cs | 46 ++++ src/Cli/Commands/InitOptions.cs | 15 + src/Cli/ConfigGenerator.cs | 144 +++++++++- src/Config/ObjectModel/ApiType.cs | 1 + src/Config/ObjectModel/McpRuntimeOptions.cs | 55 ++++ src/Config/ObjectModel/RuntimeConfig.cs | 23 +- src/Config/ObjectModel/RuntimeOptions.cs | 3 + .../Configurations/RuntimeConfigValidator.cs | 35 +++ .../MetadataProviders/SqlMetadataProvider.cs | 13 + .../Helpers/RuntimeConfigAuthHelper.cs | 1 + .../Authorization/AuthorizationHelpers.cs | 1 + .../AuthorizationResolverUnitTests.cs | 1 + .../Caching/HealthEndpointCachingTests.cs | 1 + .../AuthenticationConfigValidatorUnitTests.cs | 1 + .../Configuration/ConfigurationTests.cs | 171 ++++++++---- .../Configuration/HealthEndpointRolesTests.cs | 1 + .../Configuration/HealthEndpointTests.cs | 66 +++-- .../AuthorizationResolverHotReloadTests.cs | 1 + .../Telemetry/AzureLogAnalyticsTests.cs | 4 +- .../Configuration/Telemetry/FileSinkTests.cs | 2 +- .../Telemetry/OpenTelemetryTests.cs | 2 +- .../Configuration/Telemetry/TelemetryTests.cs | 2 +- .../CosmosTests/MutationTests.cs | 6 +- src/Service.Tests/CosmosTests/QueryTests.cs | 3 +- .../SchemaGeneratorFactoryTests.cs | 2 +- .../MultipleMutationBuilderTests.cs | 1 + .../DwSqlGraphQLQueryTests.cs | 2 + src/Service.Tests/SqlTests/SqlTestHelper.cs | 1 + .../UnitTests/ConfigValidationUnitTests.cs | 51 +++- .../UnitTests/DbExceptionParserUnitTests.cs | 2 + .../MultiSourceQueryExecutionUnitTests.cs | 2 + .../UnitTests/MySqlQueryExecutorUnitTests.cs | 1 + .../PostgreSqlQueryExecutorUnitTests.cs | 1 + .../UnitTests/RequestValidatorUnitTests.cs | 1 + .../UnitTests/RestServiceUnitTests.cs | 1 + .../UnitTests/SqlMetadataProviderUnitTests.cs | 100 +++++++ .../UnitTests/SqlQueryExecutorUnitTests.cs | 7 + src/Service.Tests/dab-config.MsSql.json | 42 ++- src/Service/Startup.cs | 2 +- .../AzureLogAnalyticsCustomLogCollector.cs | 16 +- 53 files changed, 1359 insertions(+), 179 deletions(-) create mode 100644 dab_aca_deploy.ps1 create mode 100644 dab_aci_deploy.ps1 create mode 100644 installcredprovider.ps1 create mode 100644 src/Config/ObjectModel/McpRuntimeOptions.cs diff --git a/CODEOWNERS b/CODEOWNERS index ed2b4835ef..c31746b5e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,7 +1,7 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, # review when someone opens a pull request. -* @Aniruddh25 @aaronburtle @anushakolan @RubenCerna2079 @souvikghosh04 @ravishetye @neeraj-sharma2592 @sourabh1007 @vadeveka @Alekhya-Polavarapu @rusamant +* @Aniruddh25 @aaronburtle @anushakolan @RubenCerna2079 @souvikghosh04 @akashkumar58 @neeraj-sharma2592 @sourabh1007 @vadeveka @Alekhya-Polavarapu @rusamant code_of_conduct.md @jerrynixon contributing.md @jerrynixon diff --git a/dab_aca_deploy.ps1 b/dab_aca_deploy.ps1 new file mode 100644 index 0000000000..93c19650a5 --- /dev/null +++ b/dab_aca_deploy.ps1 @@ -0,0 +1,91 @@ +# === CONFIGURABLE VARIABLES === +$subscriptionId = "f33eb08a-3fe1-40e6-a9b6-2a9c376c616f" +$resourceGroup = "soghdabdevrg" +$location = "eastus" +$acrName = "dabdevacr" +$acaName = "dabdevaca" +$acrImageName = "dabdevacrimg" +$acrImageTag = "latest" +$dockerfile = "Dockerfile" +$containerPort = 1234 +$sqlServerName = "dabdevsqlserver" +$sqlAdminUser = "dabdevsqluser" +$sqlAdminPassword = "DabUserAdmin1$" +$sqlDbName = "dabdevsqldb" + +# === Authenticate and Setup === +az account set --subscription $subscriptionId +az group create --name $resourceGroup --location $location + +# === Create ACR === +az acr create ` + --resource-group $resourceGroup ` + --name $acrName ` + --sku Basic ` + --admin-enabled true + +$acrLoginServer = az acr show --name $acrName --query "loginServer" -o tsv +$acrPassword = az acr credential show --name $acrName --query "passwords[0].value" -o tsv + +# === Build and Push Docker Image === +docker build -f $dockerfile -t "${acrImageName}:${acrImageTag}" . +docker tag "${acrImageName}:${acrImageTag}" "${acrLoginServer}/${acrImageName}:${acrImageTag}" +az acr login --name $acrName +docker push "${acrLoginServer}/${acrImageName}:${acrImageTag}" + +# === Create SQL Server and Database === +az sql server create ` + --name $sqlServerName ` + --resource-group $resourceGroup ` + --location $location ` + --admin-user $sqlAdminUser ` + --admin-password $sqlAdminPassword + +az sql db create ` + --resource-group $resourceGroup ` + --server $sqlServerName ` + --name $sqlDbName ` + --service-objective S0 + +az sql server firewall-rule create ` + --resource-group $resourceGroup ` + --server $sqlServerName ` + --name "AllowAzureServices" ` + --start-ip-address 0.0.0.0 ` + --end-ip-address 255.255.255.255 + +# === Create ACA Environment === +$envName = "${acaName}Env" +az containerapp env create ` + --name $envName ` + --resource-group $resourceGroup ` + --location $location + +# === Deploy Container App with Fixed Port === +az containerapp create ` + --name $acaName ` + --resource-group $resourceGroup ` + --environment $envName ` + --image "${acrLoginServer}/${acrImageName}:${acrImageTag}" ` + --target-port $containerPort ` + --ingress external ` + --transport auto ` + --registry-server $acrLoginServer ` + --registry-username $acrName ` + --registry-password $acrPassword ` + --env-vars ` + ASPNETCORE_URLS="http://+:$containerPort" ` + SQL_SERVER="$sqlServerName.database.windows.net" ` + SQL_DATABASE="$sqlDbName" ` + SQL_USER="$sqlAdminUser" ` + SQL_PASSWORD="$sqlAdminPassword" + +# === Output Public App URL === +$appUrl = az containerapp show ` + --name $acaName ` + --resource-group $resourceGroup ` + --query "properties.configuration.ingress.fqdn" -o tsv + +Write-Host "`n✅ Deployment complete." +Write-Host "🌐 DAB accessible at: https://$appUrl" +Write-Host "🩺 Health check endpoint: https://$appUrl/health" \ No newline at end of file diff --git a/dab_aci_deploy.ps1 b/dab_aci_deploy.ps1 new file mode 100644 index 0000000000..a52a062639 --- /dev/null +++ b/dab_aci_deploy.ps1 @@ -0,0 +1,64 @@ + +# === CONFIGURABLE VARIABLES === +$subscriptionId = "f33eb08a-3fe1-40e6-a9b6-2a9c376c616f" +$resourceGroup = "soghdabdevrg" +$location = "eastus" +$acrName = "dabdevacr" +$aciName = "dabdevaci" +$acrImageName = "dabdevacrimg" +$acrImageTag = "latest" +$dnsLabel = "dabaci" +$sqlServerName = "dabdevsqlserver" +$sqlAdminUser = "dabdevsqluser" +$sqlAdminPassword = "DabUserAdmin1$" +$sqlDbName = "dabdevsqldb" +$dockerfile = "Dockerfile" +$port = 5000 + +# === Set Azure Subscription === +az account set --subscription $subscriptionId + +# === Create Resource Group === +az group create --name $resourceGroup --location $location + +# === Create ACR === +az acr create --resource-group $resourceGroup --name $acrName --sku Basic --admin-enabled true + +# === Fetch ACR Credentials === +$acrPassword = az acr credential show --name $acrName --query "passwords[0].value" -o tsv +$acrLoginServer = az acr show --name $acrName --query "loginServer" -o tsv + +# === Build and Push Docker Image === +docker build -f $dockerfile -t "$acrImageName`:$acrImageTag" . +docker tag "$acrImageName`:$acrImageTag" "$acrLoginServer/$acrImageName`:$acrImageTag" +az acr login --name $acrName +docker push "$acrLoginServer/$acrImageName`:$acrImageTag" + +# === Create SQL Server and DB === +az sql server create --name $sqlServerName ` + --resource-group $resourceGroup --location $location ` + --admin-user $sqlAdminUser --admin-password $sqlAdminPassword + +az sql db create --resource-group $resourceGroup --server $sqlServerName --name $sqlDbName --service-objective S0 + +# === Allow Azure services to access SQL Server === +az sql server firewall-rule create --resource-group $resourceGroup --server $sqlServerName ` + --name "AllowAzureServices" --start-ip-address 0.0.0.0 --end-ip-address 255.255.255.255 + +# === Create ACI Container === +az container create ` + --resource-group $resourceGroup ` + --os-type "Linux" ` + --name $aciName ` + --image "$acrLoginServer/$acrImageName`:$acrImageTag" ` + --cpu 1 --memory 1.5 ` + --registry-login-server $acrLoginServer ` + --registry-username $acrName ` + --registry-password $acrPassword ` + --dns-name-label $dnsLabel ` + --environment-variables ASPNETCORE_URLS="http://+:$port" ` + --ports $port + +Write-Host "Deployment complete. App should be accessible at: http://$dnsLabel.$location.azurecontainer.io:$port" + +# az group delete --name $resourceGroup --yes --no-wait diff --git a/installcredprovider.ps1 b/installcredprovider.ps1 new file mode 100644 index 0000000000..788c6eef08 --- /dev/null +++ b/installcredprovider.ps1 @@ -0,0 +1,259 @@ +<# +.SYNOPSIS + Installs the Azure Artifacts Credential Provider for DotNet or NuGet tool usage. + +.DESCRIPTION + This script installs the latest version of the Azure Artifacts Credential Provider plugin + for DotNet and/or NuGet to the ~/.nuget/plugins directory. + +.PARAMETER AddNetfx + Installs the .NET Framework 4.6.1 Credential Provider. + +.PARAMETER AddNetfx48 + Installs the .NET Framework 4.8.1 Credential Provider. + +.PARAMETER Force + Forces overwriting of existing Credential Provider installations. + +.PARAMETER Version + Specifies the GitHub release version of the Credential Provider to install. + +.PARAMETER InstallNet6 + Installs the .NET 6 Credential Provider (default). + +.PARAMETER InstallNet8 + Installs the .NET 8 Credential Provider. + +.PARAMETER RuntimeIdentifier + Installs the self-contained Credential Provider for the specified Runtime Identifier. + +.EXAMPLE + .\installcredprovider.ps1 -InstallNet8 + .\installcredprovider.ps1 -Version "1.0.1" -Force +#> + +[CmdletBinding(HelpUri = "https://github.com/microsoft/artifacts-credprovider/blob/master/README.md#setup")] +param( + [switch]$AddNetfx, + [switch]$AddNetfx48, + [switch]$Force, + [string]$Version, + [switch]$InstallNet6 = $true, + [switch]$InstallNet8, + [string]$RuntimeIdentifier +) + +$script:ErrorActionPreference = 'Stop' + +# Without this, System.Net.WebClient.DownloadFile will fail on a client with TLS 1.0/1.1 disabled +if ([Net.ServicePointManager]::SecurityProtocol.ToString().Split(',').Trim() -notcontains 'Tls12') { + [Net.ServicePointManager]::SecurityProtocol += [Net.SecurityProtocolType]::Tls12 +} + +if ($Version.StartsWith("0.") -and $InstallNet6 -eq $True) { + Write-Error "You cannot install the .Net 6 version with versions lower than 1.0.0" + return +} +if (($Version.StartsWith("0.") -or $Version.StartsWith("1.0") -or $Version.StartsWith("1.1") -or $Version.StartsWith("1.2")) -and + ($InstallNet8 -eq $True -or $AddNetfx48 -eq $True)) { + Write-Error "You cannot install the .Net 8 or NetFX 4.8.1 version or with versions lower than 1.3.0" + return +} +if ($AddNetfx -eq $True -and $AddNetfx48 -eq $True) { + Write-Error "Please select a single .Net framework version to install" + return +} +if (![string]::IsNullOrEmpty($RuntimeIdentifier)) { + if (($Version.StartsWith("0.") -or $Version.StartsWith("1.0") -or $Version.StartsWith("1.1") -or $Version.StartsWith("1.2") -or $Version.StartsWith("1.3"))) { + Write-Error "You cannot install the .Net 8 self-contained version or with versions lower than 1.4.0" + return + } + + Write-Host "RuntimeIdentifier parameter is specified, the .Net 8 self-contained version will be installed" + $InstallNet6 = $False + $InstallNet8 = $True +} +if ($InstallNet6 -eq $True -and $InstallNet8 -eq $True) { + # InstallNet6 defaults to true, in the case of .Net 8 install, overwrite + $InstallNet6 = $False +} + +$userProfilePath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile); +if ($userProfilePath -ne '') { + $profilePath = $userProfilePath +} +else { + $profilePath = $env:UserProfile +} + +$tempPath = [System.IO.Path]::GetTempPath() + +$pluginLocation = [System.IO.Path]::Combine($profilePath, ".nuget", "plugins"); +$tempZipLocation = [System.IO.Path]::Combine($tempPath, "CredProviderZip"); + +$localNetcoreCredProviderPath = [System.IO.Path]::Combine("netcore", "CredentialProvider.Microsoft"); +$localNetfxCredProviderPath = [System.IO.Path]::Combine("netfx", "CredentialProvider.Microsoft"); + +$fullNetfxCredProviderPath = [System.IO.Path]::Combine($pluginLocation, $localNetfxCredProviderPath) +$fullNetcoreCredProviderPath = [System.IO.Path]::Combine($pluginLocation, $localNetcoreCredProviderPath) + +$netfxExists = Test-Path -Path ($fullNetfxCredProviderPath) +$netcoreExists = Test-Path -Path ($fullNetcoreCredProviderPath) + +# Check if plugin already exists if -Force swich is not set +if (!$Force) { + if ($AddNetfx -eq $True -and $netfxExists -eq $True -and $netcoreExists -eq $True) { + Write-Host "The netcore and netfx Credential Providers are already in $pluginLocation" + return + } + + if ($AddNetfx -eq $False -and $netcoreExists -eq $True) { + Write-Host "The netcore Credential Provider is already in $pluginLocation" + return + } +} + +# Get the zip file from the GitHub release +$releaseUrlBase = "https://api.github.com/repos/Microsoft/artifacts-credprovider/releases" +$versionError = "Unable to find the release version $Version from $releaseUrlBase" +$releaseId = "latest" +if (![string]::IsNullOrEmpty($Version)) { + try { + $releases = Invoke-WebRequest -UseBasicParsing $releaseUrlBase + $releaseJson = $releases | ConvertFrom-Json + $correctReleaseVersion = $releaseJson | ? { $_.name -eq $Version } + $releaseId = $correctReleaseVersion.id + } + catch { + Write-Error $versionError + return + } +} + +if (!$releaseId) { + Write-Error $versionError + return +} + +$releaseUrl = [System.IO.Path]::Combine($releaseUrlBase, $releaseId) +$releaseUrl = $releaseUrl.Replace("\", "/") + +$releaseRidPart = "" +if (![string]::IsNullOrEmpty($RuntimeIdentifier)) { + $releaseRIdPart = $RuntimeIdentifier + "." +} + +if ($Version.StartsWith("0.")) { + # versions lower than 1.0.0 installed NetCore2 zip + $zipFile = "Microsoft.NetCore2.NuGet.CredentialProvider.zip" +} +if ($InstallNet6 -eq $True) { + $zipFile = "Microsoft.Net6.NuGet.CredentialProvider.zip" +} +if ($InstallNet8 -eq $True) { + $zipFile = "Microsoft.Net8.${releaseRidPart}NuGet.CredentialProvider.zip" +} +if ($AddNetfx -eq $True) { + Write-Warning "The .Net Framework 4.6.1 version of the Credential Provider is deprecated and will be removed in the next major release. Please migrate to the .Net Framework 4.8 or .Net Core versions." + $zipFile = "Microsoft.NuGet.CredentialProvider.zip" +} +if ($AddNetfx48 -eq $True) { + $zipFile = "Microsoft.NetFx48.NuGet.CredentialProvider.zip" +} +if (-not $zipFile) { + Write-Warning "The .Net Core 3.1 version of the Credential Provider is deprecated and will be removed in the next major release. Please migrate to the .Net 8 version." + $zipFile = "Microsoft.NetCore3.NuGet.CredentialProvider.zip" +} + +function InstallZip { + Write-Verbose "Using $zipFile" + + try { + Write-Host "Fetching release $releaseUrl" + $release = Invoke-WebRequest -UseBasicParsing $releaseUrl + if (!$release) { + throw ("Unable to make Web Request to $releaseUrl") + } + $releaseJson = $release.Content | ConvertFrom-Json + if (!$releaseJson) { + throw ("Unable to get content from JSON") + } + $zipAsset = $releaseJson.assets | ? { $_.name -eq $zipFile } + if (!$zipAsset) { + throw ("Unable to find asset $zipFile from release json object") + } + $packageSourceUrl = $zipAsset.browser_download_url + if (!$packageSourceUrl) { + throw ("Unable to find download url from asset $zipAsset") + } + } + catch { + Write-Error ("Unable to resolve the browser download url from $releaseUrl `nError: " + $_.Exception.Message) + return + } + + # Create temporary location for the zip file handling + Write-Verbose "Creating temp directory for the Credential Provider zip: $tempZipLocation" + if (Test-Path -Path $tempZipLocation) { + Remove-Item $tempZipLocation -Force -Recurse + } + New-Item -ItemType Directory -Force -Path $tempZipLocation + + # Download credential provider zip to the temp location + $pluginZip = ([System.IO.Path]::Combine($tempZipLocation, $zipFile)) + Write-Host "Downloading $packageSourceUrl to $pluginZip" + try { + $client = New-Object System.Net.WebClient + $client.DownloadFile($packageSourceUrl, $pluginZip) + } + catch { + Write-Error "Unable to download $packageSourceUrl to the location $pluginZip" + } + + # Extract zip to temp directory + Write-Host "Extracting zip to the Credential Provider temp directory $tempZipLocation" + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($pluginZip, $tempZipLocation) +} + +# Call InstallZip function +InstallZip + +# Remove existing content and copy netfx directories to plugins directory +if ($AddNetfx -eq $True -or $AddNetfx48 -eq $True) { + if ($netfxExists) { + Write-Verbose "Removing existing content from $fullNetfxCredProviderPath" + Remove-Item $fullNetfxCredProviderPath -Force -Recurse + } + $tempNetfxPath = [System.IO.Path]::Combine($tempZipLocation, "plugins", $localNetfxCredProviderPath) + Write-Verbose "Copying Credential Provider from $tempNetfxPath to $fullNetfxCredProviderPath" + Copy-Item $tempNetfxPath -Destination $fullNetfxCredProviderPath -Force -Recurse +} + +# Microsoft.NuGet.CredentialProvider.zip that installs netfx provider installs .netcore3.1 version +# If InstallNet6 is also true we need to replace netcore cred provider with net6 +if ($AddNetfx -eq $True -and $InstallNet6 -eq $True) { + $zipFile = "Microsoft.Net6.NuGet.CredentialProvider.zip" + Write-Verbose "Installing Net6" + InstallZip +} +if ($AddNetfx -eq $True -and $InstallNet8 -eq $True) { + $zipFile = "Microsoft.Net8.NuGet.CredentialProvider.zip" + Write-Verbose "Installing Net8" + InstallZip +} + +# Remove existing content and copy netcore directories to plugins directory +if ($netcoreExists) { + Write-Verbose "Removing existing content from $fullNetcoreCredProviderPath" + Remove-Item $fullNetcoreCredProviderPath -Force -Recurse +} +$tempNetcorePath = [System.IO.Path]::Combine($tempZipLocation, "plugins", $localNetcoreCredProviderPath) +Write-Verbose "Copying Credential Provider from $tempNetcorePath to $fullNetcoreCredProviderPath" +Copy-Item $tempNetcorePath -Destination $fullNetcoreCredProviderPath -Force -Recurse + +# Remove $tempZipLocation directory +Write-Verbose "Removing the Credential Provider temp directory $tempZipLocation" +Remove-Item $tempZipLocation -Force -Recurse + +Write-Host "Credential Provider installed successfully" \ No newline at end of file diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 3f3004c9c6..4a60317571 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -214,7 +214,7 @@ "description": "Allow enabling/disabling GraphQL requests for all entities." }, "depth-limit": { - "type": ["integer", "null"], + "type": [ "integer", "null" ], "description": "Maximum allowed depth of a GraphQL query.", "default": null }, @@ -239,6 +239,63 @@ } } }, + "mcp": { + "type": "object", + "description": "Global MCP endpoint configuration", + "additionalProperties": false, + "properties": { + "path": { + "default": "/mcp", + "type": "string" + }, + "enabled": { + "type": "boolean", + "description": "Allow enabling/disabling MCP requests for all entities.", + "default": false + }, + "dml-tools": { + "type": "object", + "description": "Global DML tools configuration", + "properties": { + "enabled": { + "type": "boolean", + "description": "Allow enabling/disabling DML tools for all entities.", + "default": false + }, + "describe-entities": { + "type": "boolean", + "description": "Allow enabling/disabling the describe-entities tool.", + "default": false + }, + "create-record": { + "type": "boolean", + "description": "Allow enabling/disabling the create-record tool.", + "default": false + }, + "read-record": { + "type": "boolean", + "description": "Allow enabling/disabling the read-record tool.", + "default": false + }, + "update-record": { + "type": "boolean", + "description": "Allow enabling/disabling the update-record tool.", + "default": false + }, + "delete-record": { + "type": "boolean", + "description": "Allow enabling/disabling the delete-record tool.", + "default": false + }, + "execute-record": { + "type": "boolean", + "description": "Allow enabling/disabling the execute-record tool.", + "default": false + } + } + } + } + }, "host": { "type": "object", "description": "Global hosting configuration", diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index b455321c7c..d9d38a72e4 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Health; @@ -9,18 +10,29 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; namespace Azure.DataApiBuilder.Mcp { public static class Extensions { - private static McpOptions _mcpOptions = default!; + private static McpRuntimeOptions? _mcpOptions; public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) { - if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - _mcpOptions = runtimeConfig?.Ai?.Mcp ?? throw new NullReferenceException("Configuration is required."); + // If config is not available, skip MCP setup + return services; + } + + _mcpOptions = runtimeConfig?.Runtime?.Mcp; + + // Only add MCP server if it's enabled in the configuration + if (_mcpOptions == null || !_mcpOptions.Enabled) + { + return services; } // Register domain tools @@ -29,18 +41,18 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Register MCP server with dynamic tool handlers services.AddMcpServer(options => { - options.ServerInfo = new Implementation { Name = "MyServer", Version = "1.0.0" }; + options.ServerInfo = new() { Name = "Data API Builder MCP Server", Version = "1.0.0" }; - options.Capabilities = new ServerCapabilities + options.Capabilities = new() { - Tools = new ToolsCapability + Tools = new() { ListToolsHandler = (request, ct) => ValueTask.FromResult(new ListToolsResult { Tools = [ - new Tool + new() { Name = "echonew", Description = "Echoes the input back to the client.", @@ -52,15 +64,14 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service }" ) }, - new Tool + new() { - Name = "list_entities", + Name = "describe_entities", Description = "Lists all entities in the database." } ] }), - - CallToolHandler = (request, ct) => + CallToolHandler = async (request, ct) => { if (request.Params?.Name == "echonew" && request.Params.Arguments?.TryGetValue("message", out JsonElement messageEl) == true) @@ -69,21 +80,34 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service ? messageEl.GetString() : messageEl.ToString(); - return ValueTask.FromResult(new CallToolResult + return new CallToolResult { Content = [new TextContentBlock { Type = "text", Text = $"Echo: {msg}" }] - }); + }; } - else if (request.Params?.Name == "list_entities") + else if (request.Params?.Name == "describe_entities") { - // Call the ListEntities tool method from DmlTools - Task listEntitiesTask = DmlTools.ListEntities(); - listEntitiesTask.Wait(); // Wait for the async method to complete - string entitiesJson = listEntitiesTask.Result; - return ValueTask.FromResult(new CallToolResult + // Get the service provider from the MCP context + IServiceProvider? serviceProvider = request.Services; + if (serviceProvider == null) + { + throw new InvalidOperationException("Service provider is not available in the request context."); + } + + // Create a scope to resolve scoped services + using IServiceScope scope = serviceProvider.CreateScope(); + IServiceProvider scopedProvider = scope.ServiceProvider; + + // Set the service provider for DmlTools + Azure.DataApiBuilder.Mcp.Tools.Extensions.ServiceProvider = scopedProvider; + + // Call the DescribeEntities tool method + string entitiesJson = await DmlTools.DescribeEntities(); + + return new CallToolResult { Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] - }); + }; } throw new McpException($"Unknown tool: '{request.Params?.Name}'"); @@ -96,10 +120,32 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service return services; } - public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, RuntimeConfigProvider runtimeConfigProvider, [StringSyntax("Route")] string pattern = "") { - endpoints.MapMcp(); - endpoints.MapMcpHealthEndpoint(pattern); + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + // If config is not available, skip MCP mapping + return endpoints; + } + + McpRuntimeOptions? mcpOptions = runtimeConfig?.Runtime?.Mcp; + + // Only map MCP endpoints if MCP is enabled + if (mcpOptions == null || !mcpOptions.Enabled) + { + return endpoints; + } + + // Get the MCP path with proper null handling and default + string mcpPath = mcpOptions.Path ?? McpRuntimeOptions.DEFAULT_PATH; + + // Map the MCP endpoint + endpoints.MapMcp(mcpPath); + + // Map health checks relative to the MCP path + string healthPath = mcpPath.TrimEnd('/') + "/health"; + endpoints.MapDabHealthChecks(healthPath); + return endpoints; } } diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs index 3cf3e844c3..b26a4effb8 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; +using Azure.DataApiBuilder.Mcp.Tools; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; using ModelContextProtocol.Client; -using Azure.DataApiBuilder.Mcp.Tools; -using System.Text.Json; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp.Health; @@ -23,9 +23,9 @@ public static async Task CheckAllAsync(IServiceProvider servicePr { CheckResult serviceRegistrationCheck = CheckServiceRegistration(serviceProvider); CheckResult clientConnectionCheck = await CheckClientConnectionAsync(serviceProvider); - CheckResult checkListEntity = await CheckListEntityAsync(serviceProvider); + CheckResult checkDescribeEntity = await CheckDescribeEntityAsync(serviceProvider); - return new[] { serviceRegistrationCheck, clientConnectionCheck, checkListEntity }; + return new[] { serviceRegistrationCheck, clientConnectionCheck, checkDescribeEntity }; } /// @@ -198,18 +198,18 @@ public static async Task CheckClientConnectionAsync(IServiceProvide /// /// Legacy method for backward compatibility - returns service registration check /// - public static async Task CheckListEntityAsync(IServiceProvider serviceProvider) + public static async Task CheckDescribeEntityAsync(IServiceProvider serviceProvider) { SchemaLogic schemaLogic = new(serviceProvider); string json = await schemaLogic.GetEntityMetadataAsJsonAsync(false); JsonDocument doc = JsonDocument.Parse(json); return new CheckResult( - Name: "List Entities", + Name: "Describe Entities", IsHealthy: !string.IsNullOrEmpty(json), Message: !string.IsNullOrEmpty(json) ? "Successfully listed entities" : "No entities found", Tags: new Dictionary { - { "check_type", "list_entities" }, + { "check_type", "describe_entities" }, { "entities", doc } }); diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs index 70aa514961..bb328c7e29 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; using System.ComponentModel; using System.Diagnostics; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; namespace Azure.DataApiBuilder.Mcp.Tools; @@ -21,21 +21,7 @@ static DmlTools() }).CreateLogger(nameof(DmlTools)); } - [McpServerTool, Description(""" - - Use this tool any time the user asks you to ECHO anything. - When using this tool, respond with the raw result to the user. - - """)] - - public static string Echo(string message) => new(message.Reverse().ToArray()); - - [McpServerTool, Description(""" - - Use this tool to retrieve a list of database entities you can create, read, update, delete, or execute depending on type and permissions. - Never expose to the user the definition of the keys or fields of the entities. Use them, instead of your own parsing of the tools. - """)] - public static async Task ListEntities( + public static async Task DescribeEntities( [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] bool nameOnly = false, [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] @@ -43,11 +29,10 @@ public static async Task ListEntities( { _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); - + using (Activity activity = new("MCP")) { - activity.SetTag("tool", nameof(ListEntities)); - + activity.SetTag("tool", nameof(DescribeEntities)); SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); return jsonMetadata; diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs index 104da66872..a663d1e430 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs @@ -12,10 +12,45 @@ public static class Extensions { public static IServiceProvider? ServiceProvider { get; set; } - public static void AddDmlTools(this IServiceCollection services, McpOptions mcpOptions) + public static void AddDmlTools(this IServiceCollection services, McpRuntimeOptions? mcpOptions) { - HashSet DmlToolNames = mcpOptions.DmlTools - .Select(x => x.ToString()).ToHashSet(); + if (mcpOptions?.DmlTools == null || !mcpOptions.DmlTools.Enabled) + { + return; + } + + HashSet DmlToolNames = new(); + + // Check each DML tool property and add to the set if enabled + if (mcpOptions.DmlTools.DescribeEntities) + { + DmlToolNames.Add("DescribeEntities"); + } + + if (mcpOptions.DmlTools.CreateRecord) + { + DmlToolNames.Add("CreateRecord"); + } + + if (mcpOptions.DmlTools.ReadRecord) + { + DmlToolNames.Add("ReadRecord"); + } + + if (mcpOptions.DmlTools.UpdateRecord) + { + DmlToolNames.Add("UpdateRecord"); + } + + if (mcpOptions.DmlTools.DeleteRecord) + { + DmlToolNames.Add("DeleteRecord"); + } + + if (mcpOptions.DmlTools.ExecuteRecord) + { + DmlToolNames.Add("ExecuteRecord"); + } IEnumerable methods = typeof(DmlTools).GetMethods() .Where(method => DmlToolNames.Contains(method.Name)); diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs index 63b0d40bb8..3c51261604 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs @@ -37,6 +37,7 @@ public SchemaLogic(IServiceProvider services) public async Task GetRawGraphQLSchemaAsync() { IRequestExecutorResolver? requestExecutorResolver = _services.GetService(); + if (requestExecutorResolver == null) { throw new InvalidOperationException("IRequestExecutorResolver not available"); @@ -86,6 +87,7 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st try { object? entityMetadata = await BuildEntityMetadataFromSchema(entityName, objectType, nameOnly); + if (entityMetadata != null) { entityMetadataList.Add(entityMetadata); @@ -98,8 +100,8 @@ public async Task GetEntityMetadataAsJsonAsync(bool nameOnly = false, st } } - return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions - { + return JsonSerializer.Serialize(entityMetadataList, new JsonSerializerOptions + { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); @@ -114,7 +116,7 @@ private static List> GetEntityTypesFromSchema( foreach (INamedType type in schema.Types) { - if (type is IObjectType objectType && + if (type is IObjectType objectType && objectType.Directives.Any(d => d.Type.Name == "model") && !IsSystemType(objectType.Name)) { @@ -156,6 +158,7 @@ private static bool IsSystemType(string typeName) { // Try to get the literal value of the name argument HotChocolate.Language.ArgumentNode? nameArgument = modelDirective.AsSyntaxNode().Arguments.FirstOrDefault(a => a.Name.Value == "name"); + if (nameArgument?.Value is HotChocolate.Language.StringValueNode stringValue) { return stringValue.Value; @@ -214,8 +217,8 @@ private string GetEntityDescriptionFromSchema(string entityName, IObjectType obj // Check if this is a stored procedure (has execute mutations) bool isStoredProcedure = IsStoredProcedureType(entityName); - - return isStoredProcedure + + return isStoredProcedure ? $"Represents the {entityName} stored procedure" : $"Represents a {entityName} entity in the system"; } @@ -228,6 +231,7 @@ private bool IsStoredProcedureType(string entityName) try { RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + if (runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity)) { return entity.Source.Type == EntitySourceType.StoredProcedure; @@ -252,11 +256,12 @@ private static List BuildPrimaryKeysFromSchema(IObjectType objectType) foreach (IObjectField field in objectType.Fields) { Directive? primaryKeyDirective = field.Directives.FirstOrDefault(d => d.Type.Name == "primaryKey"); + if (primaryKeyDirective != null) { bool isAutoGenerated = field.Directives.Any(d => d.Type.Name == "autoGenerated"); string databaseType = GetDatabaseTypeFromDirective(primaryKeyDirective); - + keys.Add(new { Name = field.Name, @@ -280,6 +285,7 @@ private static string GetDatabaseTypeFromDirective(Directive primaryKeyDirective try { HotChocolate.Language.ArgumentNode? databaseTypeArgument = primaryKeyDirective.AsSyntaxNode().Arguments.FirstOrDefault(a => a.Name.Value == "databaseType"); + if (databaseTypeArgument?.Value is HotChocolate.Language.StringValueNode stringValue) { return stringValue.Value; @@ -301,7 +307,7 @@ private static List BuildFieldsFromSchema(IObjectType objectType) List fields = new(); bool isStoredProcedure = IsStoredProcedureFromType(objectType); - foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => ( x, i))) + foreach ((IObjectField field, int Index) value in objectType.Fields.Select((x, i) => (x, i))) { // Skip __typename field if it is the first field, it usually is if (value.Index == 0 && value.field.Name == "__typename") @@ -311,6 +317,7 @@ private static List BuildFieldsFromSchema(IObjectType objectType) // Skip primary key fields as they're already in the keys section bool isPrimaryKey = value.field.Directives.Any(d => d.Type.Name == "primaryKey"); + if (isPrimaryKey) { continue; @@ -318,6 +325,7 @@ private static List BuildFieldsFromSchema(IObjectType objectType) // Skip relationship fields (they have @relationship directive) bool isRelationship = value.field.Directives.Any(d => d.Type.Name == "relationship"); + if (isRelationship) { continue; @@ -349,9 +357,10 @@ private static bool IsStoredProcedureFromType(IObjectType objectType) // Stored procedures typically don't have Connection, Aggregations, GroupBy suffixes // and are often named with patterns that suggest they're procedures string typeName = objectType.Name; - return !IsSystemType(typeName) && - !typeName.EndsWith("Connection") && - !typeName.EndsWith("Aggregations") && + + return !IsSystemType(typeName) && + !typeName.EndsWith("Connection") && + !typeName.EndsWith("Aggregations") && !typeName.EndsWith("GroupBy") && (typeName.StartsWith("Get") || typeName.StartsWith("Execute") || typeName.Contains("Procedure")); } @@ -366,14 +375,15 @@ private async Task> BuildAllowedActionsFromSchema(string entityName // Determine entity type to know which operations to check bool isStoredProcedure = IsStoredProcedureType(entityName); - - List operationsToCheck = isStoredProcedure + + List operationsToCheck = isStoredProcedure ? new() { EntityActionOperation.Execute } : new() { EntityActionOperation.Create, EntityActionOperation.Read, EntityActionOperation.Update, EntityActionOperation.Delete }; foreach (EntityActionOperation operation in operationsToCheck) { IEnumerable allowedRoles = IAuthorizationResolver.GetRolesForOperation(entityName, operation, entityPermissionsMap); + if (allowedRoles.Any()) { object actionMetadata = await BuildActionMetadataFromSchema(operation, entityName, objectType); @@ -414,7 +424,7 @@ private static string GetActionName(EntityActionOperation operation) return operation switch { EntityActionOperation.Create => "create_entity_record", - EntityActionOperation.Read => "read_entity_records", + EntityActionOperation.Read => "read_entity_records", EntityActionOperation.Update => "update_entity_record", EntityActionOperation.Delete => "delete_entity_record", EntityActionOperation.Execute => "execute_entity", @@ -493,7 +503,7 @@ private async Task> GetCreateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string createInputTypeName = $"Create{entityName}Input"; - + return GetFieldsFromInputType(schema, createInputTypeName); } @@ -504,7 +514,7 @@ private async Task> GetUpdateFieldParametersFromSchema(string entit { ISchema schema = await GetRawGraphQLSchemaAsync(); string updateInputTypeName = $"Update{entityName}Input"; - + return GetFieldsFromInputType(schema, updateInputTypeName); } @@ -518,13 +528,13 @@ private async Task> GetStoredProcedureParametersFromSchema(string e try { ISchema schema = await GetRawGraphQLSchemaAsync(); - + // Find the Mutation type if (schema.Types.FirstOrDefault(t => t.Name == "Mutation") is IObjectType mutationType) { // Look for execute mutation for this entity string expectedMutationName = $"execute{entityName}"; - IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => + IObjectField? mutationField = mutationType.Fields.FirstOrDefault(f => f.Name.Equals(expectedMutationName, StringComparison.OrdinalIgnoreCase)); if (mutationField != null) @@ -573,7 +583,7 @@ private static string MapGraphQLTypeToString(IType type, bool forceRequired = fa { string baseType = GetBaseTypeName(type); bool isRequired = forceRequired || IsRequiredType(type); - + return isRequired ? $"{baseType}!" : baseType; } @@ -667,6 +677,7 @@ private static object GetPrimaryKeyParametersFromSchema(IObjectType objectType) if (primaryKeys.Count == 1) { IObjectField pkField = primaryKeys[0]; + return $"{pkField.Name}: {MapGraphQLTypeToString(pkField.Type, forceRequired: true)}"; } diff --git a/src/Cli.Tests/ExporterTests.cs b/src/Cli.Tests/ExporterTests.cs index aecd6455a3..3735dc43a1 100644 --- a/src/Cli.Tests/ExporterTests.cs +++ b/src/Cli.Tests/ExporterTests.cs @@ -21,7 +21,7 @@ public void ExportGraphQLFromDabService_LogsWhenHttpsWorks() RuntimeConfig runtimeConfig = new( Schema: "schema", DataSource: new DataSource(DatabaseType.MSSQL, "", new()), - Runtime: new(Rest: new(), GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(new Dictionary()) ); @@ -59,7 +59,7 @@ public void ExportGraphQLFromDabService_LogsFallbackToHttp_WhenHttpsFails() RuntimeConfig runtimeConfig = new( Schema: "schema", DataSource: new DataSource(DatabaseType.MSSQL, "", new()), - Runtime: new(Rest: new(), GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(new Dictionary()) ); @@ -105,7 +105,7 @@ public void ExportGraphQLFromDabService_ThrowsException_WhenBothHttpsAndHttpFail RuntimeConfig runtimeConfig = new( Schema: "schema", DataSource: new DataSource(DatabaseType.MSSQL, "", new()), - Runtime: new(Rest: new(), GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(new Dictionary()) ); diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 2cfba899ea..91b938bcfc 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -73,6 +73,8 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.RestPath); // Ignore the GraphQLPath as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.GraphQLPath); + // Ignore the McpPath as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.McpPath); // Ignore the AllowIntrospection as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.AllowIntrospection); // Ignore the EnableAggregation as that's unimportant from a test standpoint. diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 2719cf7df7..6d7b8d82a6 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1004,7 +1004,7 @@ public void TestVerifyCanUpdateRelationshipInvalidOptions(string db, string card RuntimeConfig runtimeConfig = new( Schema: "schema", DataSource: new DataSource(EnumExtensions.Deserialize(db), "", new()), - Runtime: new(Rest: new(), GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(new Dictionary()) ); @@ -1056,7 +1056,7 @@ public void EnsureFailure_AddRelationshipToEntityWithDisabledGraphQL() RuntimeConfig runtimeConfig = new( Schema: "schema", DataSource: new DataSource(DatabaseType.MSSQL, "", new()), - Runtime: new(Rest: new(), GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(entityMap) ); diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 4f61b2007b..5fdd25c571 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -36,6 +36,15 @@ public ConfigureOptions( bool? runtimeRestEnabled = null, string? runtimeRestPath = null, bool? runtimeRestRequestBodyStrict = null, + bool? runtimeMcpEnabled = null, + string? runtimeMcpPath = null, + bool? runtimeMcpDmlToolsEnabled = null, + bool? runtimeMcpDmlToolsDescribeEntitiesEnabled = null, + bool? runtimeMcpDmlToolsCreateRecordEnabled = null, + bool? runtimeMcpDmlToolsReadRecordEnabled = null, + bool? runtimeMcpDmlToolsUpdateRecordEnabled = null, + bool? runtimeMcpDmlToolsDeleteRecordEnabled = null, + bool? runtimeMcpDmlToolsExecuteRecordEnabled = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, HostMode? runtimeHostMode = null, @@ -81,6 +90,16 @@ public ConfigureOptions( RuntimeRestEnabled = runtimeRestEnabled; RuntimeRestPath = runtimeRestPath; RuntimeRestRequestBodyStrict = runtimeRestRequestBodyStrict; + // Mcp + RuntimeMcpEnabled = runtimeMcpEnabled; + RuntimeMcpPath = runtimeMcpPath; + RuntimeMcpDmlToolsEnabled = runtimeMcpDmlToolsEnabled; + RuntimeMcpDmlToolsDescribeEntitiesEnabled = runtimeMcpDmlToolsDescribeEntitiesEnabled; + RuntimeMcpDmlToolsCreateRecordEnabled = runtimeMcpDmlToolsCreateRecordEnabled; + RuntimeMcpDmlToolsReadRecordEnabled = runtimeMcpDmlToolsReadRecordEnabled; + RuntimeMcpDmlToolsUpdateRecordEnabled = runtimeMcpDmlToolsUpdateRecordEnabled; + RuntimeMcpDmlToolsDeleteRecordEnabled = runtimeMcpDmlToolsDeleteRecordEnabled; + RuntimeMcpDmlToolsExecuteRecordEnabled = runtimeMcpDmlToolsExecuteRecordEnabled; // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; @@ -146,6 +165,33 @@ public ConfigureOptions( [Option("runtime.graphql.multiple-mutations.create.enabled", Required = false, HelpText = "Enable/Disable multiple-mutation create operations on DAB's generated GraphQL schema. Default: true (boolean).")] public bool? RuntimeGraphQLMultipleMutationsCreateEnabled { get; } + [Option("runtime.mcp.enabled", Required = false, HelpText = "Enable DAB's MCP endpoint. Default: true (boolean).")] + public bool? RuntimeMcpEnabled { get; } + + [Option("runtime.mcp.path", Required = false, HelpText = "Customize DAB's MCP endpoint path. Default: '/mcp' Conditions: Prefix path with '/'.")] + public string? RuntimeMcpPath { get; } + + [Option("runtime.mcp.dml-tools.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsEnabled { get; } + + [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools describe entities endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsDescribeEntitiesEnabled { get; } + + [Option("runtime.mcp.dml-tools.create-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools create record endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsCreateRecordEnabled { get; } + + [Option("runtime.mcp.dml-tools.read-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools read record endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsReadRecordEnabled { get; } + + [Option("runtime.mcp.dml-tools.update-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools update record endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsUpdateRecordEnabled { get; } + + [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools delete record endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsDeleteRecordEnabled { get; } + + [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools execute record endpoint. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } + [Option("runtime.rest.enabled", Required = false, HelpText = "Enable DAB's Rest endpoint. Default: true (boolean).")] public bool? RuntimeRestEnabled { get; } diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index 5d5608a200..dc7a5350ea 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -35,8 +35,11 @@ public InitOptions( bool restDisabled = false, string graphQLPath = GraphQLRuntimeOptions.DEFAULT_PATH, bool graphqlDisabled = false, + string mcpPath = McpRuntimeOptions.DEFAULT_PATH, + bool mcpDisabled = false, CliBool restEnabled = CliBool.None, CliBool graphqlEnabled = CliBool.None, + CliBool mcpEnabled = CliBool.None, CliBool restRequestBodyStrict = CliBool.None, CliBool multipleCreateOperationEnabled = CliBool.None, string? config = null) @@ -58,8 +61,11 @@ public InitOptions( RestDisabled = restDisabled; GraphQLPath = graphQLPath; GraphQLDisabled = graphqlDisabled; + McpPath = mcpPath; + McpDisabled = mcpDisabled; RestEnabled = restEnabled; GraphQLEnabled = graphqlEnabled; + McpEnabled = mcpEnabled; RestRequestBodyStrict = restRequestBodyStrict; MultipleCreateOperationEnabled = multipleCreateOperationEnabled; } @@ -100,6 +106,9 @@ public InitOptions( [Option("rest.path", Default = RestRuntimeOptions.DEFAULT_PATH, Required = false, HelpText = "Specify the REST endpoint's default prefix.")] public string RestPath { get; } + [Option("mcp.path", Default = McpRuntimeOptions.DEFAULT_PATH, Required = false, HelpText = "Specify the MCP endpoint's default prefix.")] + public string McpPath { get; } + [Option("runtime.base-route", Default = null, Required = false, HelpText = "Specifies the base route for API requests.")] public string? RuntimeBaseRoute { get; } @@ -112,12 +121,18 @@ public InitOptions( [Option("graphql.disabled", Default = false, Required = false, HelpText = "Disables GraphQL endpoint for all entities.")] public bool GraphQLDisabled { get; } + [Option("mcp.disabled", Default = false, Required = false, HelpText = "Disables MCP endpoint for all entities.")] + public bool McpDisabled { get; } + [Option("rest.enabled", Required = false, HelpText = "(Default: true) Enables REST endpoint for all entities. Supported values: true, false.")] public CliBool RestEnabled { get; } [Option("graphql.enabled", Required = false, HelpText = "(Default: true) Enables GraphQL endpoint for all entities. Supported values: true, false.")] public CliBool GraphQLEnabled { get; } + [Option("mcp.enabled", Required = false, HelpText = "(Default: true) Enables MCP endpoint for all entities. Supported values: true, false.")] + public CliBool McpEnabled { get; } + // Since the rest.request-body-strict option does not have a default value, it is required to specify a value for this option if it is // included in the init command. [Option("rest.request-body-strict", Required = false, HelpText = "(Default: true) Allow extraneous fields in the request body for REST.")] diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9cc53493fd..772a384542 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -89,6 +89,7 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime DatabaseType dbType = options.DatabaseType; string? restPath = options.RestPath; string graphQLPath = options.GraphQLPath; + string mcpPath = options.McpPath; string? runtimeBaseRoute = options.RuntimeBaseRoute; Dictionary dbOptions = new(); @@ -108,9 +109,10 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime " We recommend that you use the --graphql.enabled option instead."); } - bool restEnabled, graphQLEnabled; + bool restEnabled, graphQLEnabled, mcpEnabled; if (!TryDetermineIfApiIsEnabled(options.RestDisabled, options.RestEnabled, ApiType.REST, out restEnabled) || - !TryDetermineIfApiIsEnabled(options.GraphQLDisabled, options.GraphQLEnabled, ApiType.GraphQL, out graphQLEnabled)) + !TryDetermineIfApiIsEnabled(options.GraphQLDisabled, options.GraphQLEnabled, ApiType.GraphQL, out graphQLEnabled) || + !TryDetermineIfApiIsEnabled(options.McpDisabled, options.McpEnabled, ApiType.MCP, out mcpEnabled)) { return false; } @@ -262,6 +264,7 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime Runtime: new( Rest: new(restEnabled, restPath ?? RestRuntimeOptions.DEFAULT_PATH, options.RestRequestBodyStrict is CliBool.False ? false : true), GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, MultipleMutationOptions: multipleMutationOptions), + Mcp: new(mcpEnabled, mcpPath ?? McpRuntimeOptions.DEFAULT_PATH), Host: new( Cors: new(options.CorsOrigin?.ToArray() ?? Array.Empty()), Authentication: new( @@ -743,6 +746,23 @@ private static bool TryUpdateConfiguredRuntimeOptions( } } + // MCP: Enabled and Path + if (options.RuntimeMcpEnabled != null || + options.RuntimeMcpPath != null) + { + McpRuntimeOptions? updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new(); + bool status = TryUpdateConfiguredMcpValues(options, ref updatedMcpOptions); + + if (status) + { + runtimeConfig = runtimeConfig! with { Runtime = runtimeConfig.Runtime! with { Mcp = updatedMcpOptions } }; + } + else + { + return false; + } + } + // Cache: Enabled and TTL if (options.RuntimeCacheEnabled != null || options.RuntimeCacheTTL != null) @@ -943,6 +963,126 @@ private static bool TryUpdateConfiguredGraphQLValues( } } + /// + /// Attempts to update the Config parameters in the Mcp runtime settings based on the provided value. + /// Validates that any user-provided values are valid and then returns true if the updated Mcp options + /// need to be overwritten on the existing config parameters + /// + /// options. + /// updatedMcpOptions + /// True if the value needs to be updated in the runtime config, else false + private static bool TryUpdateConfiguredMcpValues( + ConfigureOptions options, + ref McpRuntimeOptions? updatedMcpOptions) + { + object? updatedValue; + + try + { + // Runtime.Mcp.Enabled + updatedValue = options?.RuntimeMcpEnabled; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { Enabled = (bool)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Enabled as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Path + updatedValue = options?.RuntimeMcpPath; + if (updatedValue != null) + { + bool status = RuntimeConfigValidatorUtil.TryValidateUriComponent(uriComponent: (string)updatedValue, out string exceptionMessage); + if (status) + { + updatedMcpOptions = updatedMcpOptions! with { Path = (string)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Path as '{updatedValue}'", updatedValue); + } + else + { + _logger.LogError("Failed to update Runtime.Mcp.Path as '{updatedValue}' due to exception message: {exceptionMessage}", updatedValue, exceptionMessage); + return false; + } + } + + // Runtime.Mcp.Dml-Tools.Enabled + updatedValue = options?.RuntimeMcpDmlToolsEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { Enabled = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Enabled as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Describe-Entities + updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { DescribeEntities = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Create-Record + updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { CreateRecord = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Read-Record + updatedValue = options?.RuntimeMcpDmlToolsReadRecordEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { ReadRecord = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Read-Record as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Update-Record + updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { UpdateRecord = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Delete-Record + updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { DeleteRecord = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); + } + + // Runtime.Mcp.Dml-Tools.Execute-Record + updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; + if (updatedValue != null) + { + McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); + dmlToolsOptions = dmlToolsOptions with { ExecuteRecord = (bool)updatedValue }; + updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError("Failed to update RuntimeConfig.Mcp with exception message: {exceptionMessage}.", ex.Message); + return false; + } + } + /// /// Attempts to update the Config parameters in the Cache runtime settings based on the provided value. /// Validates user-provided parameters and then returns true if the updated Cache options diff --git a/src/Config/ObjectModel/ApiType.cs b/src/Config/ObjectModel/ApiType.cs index 5583e67098..fb57fe2859 100644 --- a/src/Config/ObjectModel/ApiType.cs +++ b/src/Config/ObjectModel/ApiType.cs @@ -10,6 +10,7 @@ public enum ApiType { REST, GraphQL, + MCP, // This is required to indicate features common between all APIs. All } diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs new file mode 100644 index 0000000000..682cc8d9cd --- /dev/null +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Global MCP endpoint runtime configuration. +/// +public record McpRuntimeOptions +{ + public McpRuntimeOptions( + bool Enabled = false, + string? Path = null, + McpDmlToolsOptions? DmlTools = null) + { + this.Enabled = Enabled; + this.Path = Path ?? DEFAULT_PATH; + this.DmlTools = DmlTools ?? new McpDmlToolsOptions(); + } + + public const string DEFAULT_PATH = "/mcp"; + + public bool Enabled { get; init; } + + public string Path { get; init; } + + [JsonPropertyName("dml-tools")] + public McpDmlToolsOptions DmlTools { get; init; } +} + +public record McpDmlToolsOptions +{ + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool Enabled { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool DescribeEntities { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool CreateRecord { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool ReadRecord { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UpdateRecord { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool DeleteRecord { get; init; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool ExecuteRecord { get; init; } = false; +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index efe6ace566..221df9a247 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -20,12 +20,12 @@ public record McpOptions { public bool Enabled { get; init; } = true; public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.ListEntities]; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; } public enum McpDmlTool { - ListEntities + DescribeEntities } public record RuntimeConfig @@ -146,6 +146,25 @@ public string GraphQLPath } } + /// + /// The path at which MCP API is available + /// + [JsonIgnore] + public string McpPath + { + get + { + if (Runtime is null || Runtime.Mcp is null || Runtime.Mcp.Path is null) + { + return McpRuntimeOptions.DEFAULT_PATH; + } + else + { + return Runtime.Mcp.Path; + } + } + } + /// /// Indicates whether introspection is allowed or not. /// diff --git a/src/Config/ObjectModel/RuntimeOptions.cs b/src/Config/ObjectModel/RuntimeOptions.cs index 8e05df4b62..b09145f964 100644 --- a/src/Config/ObjectModel/RuntimeOptions.cs +++ b/src/Config/ObjectModel/RuntimeOptions.cs @@ -10,6 +10,7 @@ public record RuntimeOptions { public RestRuntimeOptions? Rest { get; init; } public GraphQLRuntimeOptions? GraphQL { get; init; } + public McpRuntimeOptions? Mcp { get; init; } public HostOptions? Host { get; set; } public string? BaseRoute { get; init; } public TelemetryOptions? Telemetry { get; init; } @@ -21,6 +22,7 @@ public record RuntimeOptions public RuntimeOptions( RestRuntimeOptions? Rest, GraphQLRuntimeOptions? GraphQL, + McpRuntimeOptions? Mcp, HostOptions? Host, string? BaseRoute = null, TelemetryOptions? Telemetry = null, @@ -30,6 +32,7 @@ public RuntimeOptions( { this.Rest = Rest; this.GraphQL = GraphQL; + this.Mcp = Mcp; this.Host = Host; this.BaseRoute = BaseRoute; this.Telemetry = Telemetry; diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 12a8f82aa4..bfd0425975 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -794,6 +794,41 @@ public void ValidateGraphQLURI(RuntimeConfig runtimeConfig) } } + /// + /// Method to validate that the MCP URI (MCP path prefix). + /// + /// + public void ValidateMcpUri(RuntimeConfig runtimeConfig) + { + // Skip validation if MCP is not configured + if (runtimeConfig.Runtime?.Mcp is null) + { + return; + } + + // Get the MCP path from the configuration + string? mcpPath = runtimeConfig.Runtime.Mcp.Path; + + // Validate that the path is not null or empty when MCP is configured + if (string.IsNullOrWhiteSpace(mcpPath)) + { + HandleOrRecordException(new DataApiBuilderException( + message: "MCP path cannot be null or empty when MCP is configured.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + return; + } + + // Validate the MCP path using the same validation as REST and GraphQL + if (!RuntimeConfigValidatorUtil.TryValidateUriComponent(mcpPath, out string exceptionMsgSuffix)) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"MCP path {exceptionMsgSuffix}", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + } + private void ValidateAuthenticationOptions(RuntimeConfig runtimeConfig) { // Bypass validation of auth if there is no auth provided diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index dd4703d241..071c44fe05 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1151,6 +1151,19 @@ private async Task PopulateResultSetDefinitionsForStoredProcedureAsync( Type resultFieldType = SqlToCLRType(element.GetProperty(BaseSqlQueryBuilder.STOREDPROC_COLUMN_SYSTEMTYPENAME).ToString()); bool isResultFieldNullable = element.GetProperty(BaseSqlQueryBuilder.STOREDPROC_COLUMN_ISNULLABLE).GetBoolean(); + // Validate that the stored procedure returns columns with proper names + // This commonly occurs when using aggregate functions or expressions without aliases + if (string.IsNullOrWhiteSpace(resultFieldName)) + { + throw new DataApiBuilderException( + message: $"The stored procedure '{dbStoredProcedureName}' returns a column without a name. " + + "This typically happens when using aggregate functions (like MAX, MIN, COUNT) or expressions " + + "without providing an alias. Please add column aliases to your SELECT statement. " + + "For example: 'SELECT MAX(id) AS MaxId' instead of 'SELECT MAX(id)'.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + // Store the dictionary containing result set field with its type as Columns storedProcedureDefinition.Columns.TryAdd(resultFieldName, new(resultFieldType) { IsNullable = isResultFieldNullable }); } diff --git a/src/Service.Tests/Authentication/Helpers/RuntimeConfigAuthHelper.cs b/src/Service.Tests/Authentication/Helpers/RuntimeConfigAuthHelper.cs index 12c7db4fce..07a8a565ec 100644 --- a/src/Service.Tests/Authentication/Helpers/RuntimeConfigAuthHelper.cs +++ b/src/Service.Tests/Authentication/Helpers/RuntimeConfigAuthHelper.cs @@ -20,6 +20,7 @@ internal static RuntimeConfig CreateTestConfigWithAuthNProvider(AuthenticationOp Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: hostOptions ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/Authorization/AuthorizationHelpers.cs b/src/Service.Tests/Authorization/AuthorizationHelpers.cs index 7c6948b484..85f05a1c3b 100644 --- a/src/Service.Tests/Authorization/AuthorizationHelpers.cs +++ b/src/Service.Tests/Authorization/AuthorizationHelpers.cs @@ -126,6 +126,7 @@ public static RuntimeConfig InitRuntimeConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new( Cors: null, Authentication: new(authProvider, null) diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 733ec15b24..39a77bffff 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -1441,6 +1441,7 @@ private static RuntimeConfig BuildTestRuntimeConfig(EntityPermission[] permissio Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) diff --git a/src/Service.Tests/Caching/HealthEndpointCachingTests.cs b/src/Service.Tests/Caching/HealthEndpointCachingTests.cs index 2dbff7cbb2..94216a4409 100644 --- a/src/Service.Tests/Caching/HealthEndpointCachingTests.cs +++ b/src/Service.Tests/Caching/HealthEndpointCachingTests.cs @@ -156,6 +156,7 @@ private static void CreateCustomConfigFile(Dictionary entityMap, Health: new(enabled: true, cacheTtlSeconds: cacheTtlSeconds), Rest: new(Enabled: true), GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), Host: hostOptions ), Entities: new(entityMap)); diff --git a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs index 8b01d29961..963211ae40 100644 --- a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs +++ b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs @@ -194,6 +194,7 @@ private static RuntimeConfig CreateRuntimeConfigWithOptionalAuthN(Authentication Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: hostOptions ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 2522806049..380cfe2185 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1608,7 +1608,7 @@ public async Task TestSqlMetadataForInvalidConfigEntities() GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new()); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new()); // creating an entity with invalid table name Entity entityWithInvalidSourceName = new( @@ -1679,7 +1679,7 @@ public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new()); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new()); // creating an entity with invalid table name Entity entityWithInvalidSource = new( @@ -2214,7 +2214,7 @@ public async Task TestPathRewriteMiddlewareForGraphQL( GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, new()); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, new(), new()); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -2524,80 +2524,93 @@ public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() /// Expected HTTP status code code for the GraphQL request [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Both Rest and GraphQL endpoints enabled globally")] - [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled and GraphQL endpoints disabled globally")] - [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled and GraphQL endpoints enabled globally")] - [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Both Rest and GraphQL endpoints enabled globally")] - [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled and GraphQL endpoints disabled globally")] - [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled and GraphQL endpoints enabled globally")] - public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvironment( + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, MCP all enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - GraphQL enabled, Rest and MCP disabled")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - MCP enabled, Rest and GraphQL disabled")] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, MCP all enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - GraphQL enabled, Rest and MCP disabled")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - MCP enabled, Rest and GraphQL disabled")] + public async Task TestGlobalFlagToEnableRestGraphQLMcpForHostedAndNonHostedEnvironment( bool isRestEnabled, bool isGraphQLEnabled, + bool isMcpEnabled, HttpStatusCode expectedStatusCodeForREST, HttpStatusCode expectedStatusCodeForGraphQL, + HttpStatusCode expectedStatusCodeForMcp, string configurationEndpoint) { GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + DataSource dataSource = new( + DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig( + dataSource, + graphqlOptions, + restRuntimeOptions, + mcpRuntimeOptions); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" }; // Non-Hosted Scenario using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - string query = @"{ - book_by_pk(id: 1) { - id, - title, - publisher_id - } - }"; - + // GraphQL request + string query = @"{ book_by_pk(id: 1) { id, title, publisher_id } }"; object payload = new { query }; - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") { Content = JsonContent.Create(payload) }; - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); + // REST request HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); HttpResponseMessage restResponse = await client.SendAsync(restRequest); Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode); + + // MCP request + object mcpPayload = new + { + jsonrpc = "2.0", + id = 1, + method = "tools/list" + }; + HttpRequestMessage mcpRequest = new(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(mcpPayload) + }; + HttpResponseMessage mcpResponse = await client.SendAsync(mcpRequest); + Assert.AreEqual(expectedStatusCodeForMcp, mcpResponse.StatusCode); } // Hosted Scenario - // Instantiate new server with no runtime config for post-startup configuration hydration tests. using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) using (HttpClient client = server.CreateClient()) { JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - - HttpResponseMessage postResult = - await client.PostAsync(configurationEndpoint, content); + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client); - Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode); HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client); - Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode); + HttpStatusCode mcpResponseCode = await GetMcpResponsePostConfigHydration(client); + Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode); } } @@ -2618,6 +2631,7 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() { GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); @@ -2648,7 +2662,7 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() Mappings: null); string entityName = "Stock"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -2919,6 +2933,7 @@ public async Task ValidateInheritanceOfReadPermissionFromAnonymous() { GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); @@ -2949,7 +2964,7 @@ public async Task ValidateInheritanceOfReadPermissionFromAnonymous() Mappings: null); string entityName = "Stock"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -3060,6 +3075,7 @@ public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType en GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); @@ -3077,11 +3093,11 @@ public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType en ); string entityName = "GetBooks"; - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); } else { - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions); + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); } const string CUSTOM_CONFIG = "custom-config.json"; @@ -3158,6 +3174,7 @@ public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( { GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); @@ -3175,11 +3192,11 @@ public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( ); string entityName = "GetBooks"; - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); } else { - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions); + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); } const string CUSTOM_CONFIG = "custom-config.json"; @@ -3188,7 +3205,7 @@ public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( HostOptions staticWebAppsHostOptions = new(null, authenticationOptions); RuntimeOptions runtimeOptions = configuration.Runtime; - RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions?.Rest, runtimeOptions?.GraphQL, staticWebAppsHostOptions, "/data-api"); + RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions?.Rest, runtimeOptions?.GraphQL, runtimeOptions?.Mcp, staticWebAppsHostOptions, "/data-api"); RuntimeConfig baseRouteEnabledConfig = configuration with { Runtime = baseRouteEnabledRuntimeOptions }; File.WriteAllText(CUSTOM_CONFIG, baseRouteEnabledConfig.ToJson()); @@ -3347,7 +3364,7 @@ public async Task TestEngineSupportViewsWithoutKeyFieldsInConfigForMsSQL() Mappings: null ); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), viewEntity, "books_view_all"); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new(), viewEntity, "books_view_all"); const string CUSTOM_CONFIG = "custom-config.json"; @@ -3568,6 +3585,7 @@ public void TestProductionModeAppServiceEnvironmentCheck(HostMode hostMode, Easy RuntimeOptions runtimeOptions = new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, authenticationOptions, hostMode) ); RuntimeConfig configWithCustomHostMode = config with { Runtime = runtimeOptions }; @@ -3608,10 +3626,11 @@ public async Task TestSchemaIntrospectionQuery(bool enableIntrospection, bool ex { GraphQLRuntimeOptions graphqlOptions = new(AllowIntrospection: enableIntrospection); RestRuntimeOptions restRuntimeOptions = new(); + McpRuntimeOptions mcpRuntimeOptions = new(); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -3660,6 +3679,7 @@ public void TestInvalidDatabaseColumnNameHandling( { GraphQLRuntimeOptions graphqlOptions = new(Enabled: globalGraphQLEnabled); RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpOptions = new(Enabled: true); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); @@ -3683,7 +3703,7 @@ public void TestInvalidDatabaseColumnNameHandling( Mappings: mappings ); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, "graphqlNameCompat"); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpOptions, entity, "graphqlNameCompat"); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -3739,7 +3759,8 @@ public async Task OpenApi_InteractiveSwaggerUI( RuntimeConfig configuration = InitMinimalRuntimeConfig( dataSource: dataSource, graphqlOptions: new(), - restOptions: new(Path: customRestPath)); + restOptions: new(Path: customRestPath), + mcpOptions: new()); configuration = configuration with @@ -4057,6 +4078,7 @@ private static RuntimeConfig InitializeRuntimeWithLogLevel(Dictionary entityMap, ? new( Rest: new(Enabled: enableGlobalRest), GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), Host: hostOptions, Pagination: paginationOptions) : new( Rest: new(Enabled: enableGlobalRest), GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), Host: hostOptions); RuntimeConfig runtimeConfig = new( @@ -5302,6 +5327,49 @@ private static async Task GetGraphQLResponsePostConfigHydration( return responseCode; } + /// + /// Executing MCP POST requests against the engine until a non-503 error is received. + /// + /// Client used for request execution. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the MCP request + private static async Task GetMcpResponsePostConfigHydration(HttpClient httpClient) + { + // Retry request RETRY_COUNT times in 1 second increments to allow required services + // time to instantiate and hydrate permissions. + int retryCount = RETRY_COUNT; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount > 0) + { + // Minimal MCP request (list tools) – valid JSON-RPC request + object payload = new + { + jsonrpc = "2.0", + id = 1, + method = "tools/list" + }; + + HttpRequestMessage mcpRequest = new(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + responseCode = mcpResponse.StatusCode; + + if (responseCode == HttpStatusCode.ServiceUnavailable) + { + retryCount--; + Thread.Sleep(TimeSpan.FromSeconds(RETRY_WAIT_SECONDS)); + continue; + } + + break; + } + + return responseCode; + } + /// /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. /// @@ -5312,6 +5380,8 @@ public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool i RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); EntityAction createAction = new( @@ -5370,7 +5440,7 @@ public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool i RuntimeConfig runtimeConfig = new(Schema: "IntegrationTestMinimalSchema", DataSource: dataSource, - Runtime: new(restRuntimeOptions, graphqlOptions, Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: null), + Runtime: new(restRuntimeOptions, graphqlOptions, mcpRuntimeOptions, Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: null), Entities: new(entityMap)); return runtimeConfig; } @@ -5383,6 +5453,7 @@ public static RuntimeConfig InitMinimalRuntimeConfig( DataSource dataSource, GraphQLRuntimeOptions graphqlOptions, RestRuntimeOptions restOptions, + McpRuntimeOptions mcpOptions, Entity entity = null, string entityName = null, RuntimeCacheOptions cacheOptions = null @@ -5420,7 +5491,7 @@ public static RuntimeConfig InitMinimalRuntimeConfig( return new( Schema: "IntegrationTestMinimalSchema", DataSource: dataSource, - Runtime: new(restOptions, graphqlOptions, + Runtime: new(restOptions, graphqlOptions, mcpOptions, Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: cacheOptions ), @@ -5496,6 +5567,7 @@ private static RuntimeConfig CreateBasicRuntimeConfigWithNoEntity( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -5533,6 +5605,7 @@ private static RuntimeConfig CreateBasicRuntimeConfigWithSingleEntityAndAuthOpti Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: authenticationOptions) ), Entities: new(entityMap) diff --git a/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs b/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs index a492f2c167..2a83697a3a 100644 --- a/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs @@ -127,6 +127,7 @@ private static void CreateCustomConfigFile(Dictionary entityMap, Health: new(enabled: true, roles: role != null ? new HashSet { role } : null), Rest: new(Enabled: true), GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), Host: hostOptions ), Entities: new(entityMap)); diff --git a/src/Service.Tests/Configuration/HealthEndpointTests.cs b/src/Service.Tests/Configuration/HealthEndpointTests.cs index 4fd2e52bf4..29c2d1a5b2 100644 --- a/src/Service.Tests/Configuration/HealthEndpointTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointTests.cs @@ -53,25 +53,42 @@ public void CleanupAfterEachTest() /// [TestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, true, true, true, true, true, true, DisplayName = "Validate Health Report all enabled.")] - [DataRow(false, true, true, true, true, true, true, DisplayName = "Validate when Comprehensive Health Report is disabled")] - [DataRow(true, true, true, false, true, true, true, DisplayName = "Validate Health Report when data-source health is disabled")] - [DataRow(true, true, true, true, false, true, true, DisplayName = "Validate Health Report when entity health is disabled")] - [DataRow(true, false, true, true, true, true, true, DisplayName = "Validate Health Report when global rest health is disabled")] - [DataRow(true, true, true, true, true, false, true, DisplayName = "Validate Health Report when entity rest health is disabled")] - [DataRow(true, true, false, true, true, true, true, DisplayName = "Validate Health Report when global graphql health is disabled")] - [DataRow(true, true, true, true, true, true, false, DisplayName = "Validate Health Report when entity graphql health is disabled")] - public async Task ComprehensiveHealthEndpoint_ValidateContents(bool enableGlobalHealth, bool enableGlobalRest, bool enableGlobalGraphql, bool enableDatasourceHealth, bool enableEntityHealth, bool enableEntityRest, bool enableEntityGraphQL) + [DataRow(true, true, true, true, true, true, true, true, DisplayName = "Validate Health Report all enabled.")] + [DataRow(false, true, true, true, true, true, true, true, DisplayName = "Validate when Comprehensive Health Report is disabled")] + [DataRow(true, true, true, false, true, true, true, true, DisplayName = "Validate Health Report when global MCP health is disabled")] + [DataRow(true, true, true, true, false, true, true, true, DisplayName = "Validate Health Report when data-source health is disabled")] + [DataRow(true, true, true, true, true, false, true, true, DisplayName = "Validate Health Report when entity health is disabled")] + [DataRow(true, false, true, true, true, true, true, true, DisplayName = "Validate Health Report when global REST health is disabled")] + [DataRow(true, true, false, true, true, true, true, true, DisplayName = "Validate Health Report when global GraphQL health is disabled")] + [DataRow(true, true, true, true, true, true, false, true, DisplayName = "Validate Health Report when entity REST health is disabled")] + [DataRow(true, true, true, true, true, true, true, false, DisplayName = "Validate Health Report when entity GraphQL health is disabled")] + public async Task ComprehensiveHealthEndpoint_ValidateContents( + bool enableGlobalHealth, + bool enableGlobalRest, + bool enableGlobalGraphql, + bool enableGlobalMcp, + bool enableDatasourceHealth, + bool enableEntityHealth, + bool enableEntityRest, + bool enableEntityGraphQL) { - // Arrange - // Create a mock entity map with a single entity for testing - RuntimeConfig runtimeConfig = SetupCustomConfigFile(enableGlobalHealth, enableGlobalRest, enableGlobalGraphql, enableDatasourceHealth, enableEntityHealth, enableEntityRest, enableEntityGraphQL); + // The body remains exactly the same except passing enableGlobalMcp + RuntimeConfig runtimeConfig = SetupCustomConfigFile( + enableGlobalHealth, + enableGlobalRest, + enableGlobalGraphql, + enableGlobalMcp, + enableDatasourceHealth, + enableEntityHealth, + enableEntityRest, + enableEntityGraphQL); + WriteToCustomConfigFile(runtimeConfig); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" - }; + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) @@ -90,7 +107,7 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents(bool enableGlobal Assert.AreEqual(expected: HttpStatusCode.OK, actual: response.StatusCode, message: "Received unexpected HTTP code from health check endpoint."); ValidateBasicDetailsHealthCheckResponse(responseProperties); - ValidateConfigurationDetailsHealthCheckResponse(responseProperties, enableGlobalRest, enableGlobalGraphql); + ValidateConfigurationDetailsHealthCheckResponse(responseProperties, enableGlobalRest, enableGlobalGraphql, enableGlobalMcp); ValidateIfAttributePresentInResponse(responseProperties, enableDatasourceHealth, HealthCheckConstants.DATASOURCE); ValidateIfAttributePresentInResponse(responseProperties, enableEntityHealth, HealthCheckConstants.ENDPOINT); if (enableEntityHealth) @@ -110,7 +127,7 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents(bool enableGlobal public async Task TestHealthCheckRestResponseAsync() { // Arrange - RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true); + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); HttpUtilities httpUtilities = SetupRestTest(runtimeConfig); // Act @@ -139,7 +156,7 @@ public async Task TestHealthCheckRestResponseAsync() public async Task TestFailureHealthCheckRestResponseAsync() { // Arrange - RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true); + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); HttpUtilities httpUtilities = SetupGraphQLTest(runtimeConfig, HttpStatusCode.BadRequest); // Act @@ -167,7 +184,7 @@ public async Task TestFailureHealthCheckRestResponseAsync() public async Task TestHealthCheckGraphQLResponseAsync() { // Arrange - RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true); + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); HttpUtilities httpUtilities = SetupGraphQLTest(runtimeConfig); // Act @@ -191,7 +208,7 @@ public async Task TestHealthCheckGraphQLResponseAsync() public async Task TestFailureHealthCheckGraphQLResponseAsync() { // Arrange - RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true); + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); HttpUtilities httpUtilities = SetupGraphQLTest(runtimeConfig, HttpStatusCode.InternalServerError); // Act @@ -427,7 +444,7 @@ private static void ValidateConfigurationIsCorrectFlag(Dictionary responseProperties, bool enableGlobalRest, bool enableGlobalGraphQL) + private static void ValidateConfigurationDetailsHealthCheckResponse(Dictionary responseProperties, bool enableGlobalRest, bool enableGlobalGraphQL, bool enableGlobalMcp) { if (responseProperties.TryGetValue("configuration", out JsonElement configElement) && configElement.ValueKind == JsonValueKind.Object) { @@ -443,6 +460,8 @@ private static void ValidateConfigurationDetailsHealthCheckResponse(Dictionary @@ -520,7 +539,7 @@ private static RuntimeConfig SetupCustomConfigFile(bool enableGlobalHealth, bool /// /// Collection of entityName -> Entity object. /// flag to enable or disabled REST globally. - private static RuntimeConfig CreateRuntimeConfig(Dictionary entityMap, bool enableGlobalRest = true, bool enableGlobalGraphql = true, bool enableGlobalHealth = true, bool enableDatasourceHealth = true, HostMode hostMode = HostMode.Production) + private static RuntimeConfig CreateRuntimeConfig(Dictionary entityMap, bool enableGlobalRest = true, bool enableGlobalGraphql = true, bool enabledGlobalMcp = true, bool enableGlobalHealth = true, bool enableDatasourceHealth = true, HostMode hostMode = HostMode.Production) { DataSource dataSource = new( DatabaseType.MSSQL, @@ -536,6 +555,7 @@ private static RuntimeConfig CreateRuntimeConfig(Dictionary enti Health: new(enabled: enableGlobalHealth), Rest: new(Enabled: enableGlobalRest), GraphQL: new(Enabled: enableGlobalGraphql), + Mcp: new(Enabled: enabledGlobalMcp), Host: hostOptions ), Entities: new(entityMap)); diff --git a/src/Service.Tests/Configuration/HotReload/AuthorizationResolverHotReloadTests.cs b/src/Service.Tests/Configuration/HotReload/AuthorizationResolverHotReloadTests.cs index cc397e0ca0..b5fcb6162b 100644 --- a/src/Service.Tests/Configuration/HotReload/AuthorizationResolverHotReloadTests.cs +++ b/src/Service.Tests/Configuration/HotReload/AuthorizationResolverHotReloadTests.cs @@ -131,6 +131,7 @@ private static void CreateCustomConfigFile(string fileName, Dictionary flusherService.StartAsync(tokenSource.Token)); - await Task.Delay(1000); + await Task.Delay(2000); // Assert AzureLogAnalyticsLogs actualLog = customClient.LogAnalyticsLogs[0]; diff --git a/src/Service.Tests/Configuration/Telemetry/FileSinkTests.cs b/src/Service.Tests/Configuration/Telemetry/FileSinkTests.cs index 077e0098d8..7759ced58d 100644 --- a/src/Service.Tests/Configuration/Telemetry/FileSinkTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/FileSinkTests.cs @@ -39,7 +39,7 @@ private static void SetUpTelemetryInConfig(string configFileName, bool isFileSin DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new()); + _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new(), mcpOptions: new()); TelemetryOptions _testTelemetryOptions = new(File: new FileSinkOptions(isFileSinkEnabled, fileSinkPath, rollingInterval)); _configuration = _configuration with { Runtime = _configuration.Runtime with { Telemetry = _testTelemetryOptions } }; diff --git a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs index 3b8e374fe2..df213c6f48 100644 --- a/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/OpenTelemetryTests.cs @@ -37,7 +37,7 @@ public static void SetUpTelemetryInConfig(string configFileName, bool isOtelEnab DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new()); + _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new(), mcpOptions: new()); TelemetryOptions _testTelemetryOptions = new(OpenTelemetry: new OpenTelemetryOptions(isOtelEnabled, otelEndpoint, otelHeaders, otlpExportProtocol, "TestServiceName")); _configuration = _configuration with { Runtime = _configuration.Runtime with { Telemetry = _testTelemetryOptions } }; diff --git a/src/Service.Tests/Configuration/Telemetry/TelemetryTests.cs b/src/Service.Tests/Configuration/Telemetry/TelemetryTests.cs index 016cac8d1c..996e79b805 100644 --- a/src/Service.Tests/Configuration/Telemetry/TelemetryTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/TelemetryTests.cs @@ -43,7 +43,7 @@ public static void SetUpTelemetryInConfig(string configFileName, bool isTelemetr DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new()); + _configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions: new(), restOptions: new(), mcpOptions: new()); TelemetryOptions _testTelemetryOptions = new(new ApplicationInsightsOptions(isTelemetryEnabled, telemetryConnectionString)); _configuration = _configuration with { Runtime = _configuration.Runtime with { Telemetry = _testTelemetryOptions } }; diff --git a/src/Service.Tests/CosmosTests/MutationTests.cs b/src/Service.Tests/CosmosTests/MutationTests.cs index 23e23a9ec7..2f82541806 100644 --- a/src/Service.Tests/CosmosTests/MutationTests.cs +++ b/src/Service.Tests/CosmosTests/MutationTests.cs @@ -513,6 +513,7 @@ type Planet @model(name:""Planet"") { }"; GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); Dictionary dbOptions = new(); HyphenatedNamingPolicy namingPolicy = new(); @@ -548,7 +549,7 @@ type Planet @model(name:""Planet"") { Mappings: null); string entityName = "Planet"; - RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); const string CUSTOM_CONFIG = "custom-config.json"; const string CUSTOM_SCHEMA = "custom-schema.gql"; @@ -642,6 +643,7 @@ type Planet @model(name:""Planet"") { }"; GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); Dictionary dbOptions = new(); HyphenatedNamingPolicy namingPolicy = new(); @@ -677,7 +679,7 @@ type Planet @model(name:""Planet"") { Mappings: null); string entityName = "Planet"; - RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName); + RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); const string CUSTOM_CONFIG = "custom-config.json"; const string CUSTOM_SCHEMA = "custom-schema.gql"; diff --git a/src/Service.Tests/CosmosTests/QueryTests.cs b/src/Service.Tests/CosmosTests/QueryTests.cs index 97cffa3c98..c40c95c75b 100644 --- a/src/Service.Tests/CosmosTests/QueryTests.cs +++ b/src/Service.Tests/CosmosTests/QueryTests.cs @@ -682,6 +682,7 @@ type Planet @model(name:""Planet"") { GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); Dictionary dbOptions = new(); HyphenatedNamingPolicy namingPolicy = new(); @@ -724,7 +725,7 @@ type Planet @model(name:""Planet"") { string entityName = "Planet"; // cache configuration - RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName, new RuntimeCacheOptions() { Enabled = true, TtlSeconds = 5 }); + RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName, new RuntimeCacheOptions() { Enabled = true, TtlSeconds = 5 }); const string CUSTOM_CONFIG = "custom-config.json"; const string CUSTOM_SCHEMA = "custom-schema.gql"; diff --git a/src/Service.Tests/CosmosTests/SchemaGeneratorFactoryTests.cs b/src/Service.Tests/CosmosTests/SchemaGeneratorFactoryTests.cs index 20f415e3dc..562a5174d2 100644 --- a/src/Service.Tests/CosmosTests/SchemaGeneratorFactoryTests.cs +++ b/src/Service.Tests/CosmosTests/SchemaGeneratorFactoryTests.cs @@ -78,7 +78,7 @@ public async Task ExportGraphQLFromCosmosDB_GeneratesSchemaSuccessfully(string g {"database", globalDatabase}, {"container", globalContainer} }), - Runtime: new(Rest: null, GraphQL: new(), Host: new(null, null)), + Runtime: new(Rest: null, GraphQL: new(), Mcp: new(), Host: new(null, null)), Entities: new(new Dictionary() { {"Container1", new Entity( diff --git a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs index 0ed64ca6ee..94665d7c18 100644 --- a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs @@ -360,6 +360,7 @@ private static RuntimeConfigProvider GetRuntimeConfigProvider() { Runtime = new RuntimeOptions(Rest: runtimeConfig.Runtime.Rest, GraphQL: new GraphQLRuntimeOptions(MultipleMutationOptions: new MultipleMutationOptions(new MultipleCreateOptions(enabled: true))), + Mcp: runtimeConfig.Runtime.Mcp, Host: runtimeConfig.Runtime.Host, BaseRoute: runtimeConfig.Runtime.BaseRoute, Telemetry: runtimeConfig.Runtime.Telemetry, diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/DwSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/DwSqlGraphQLQueryTests.cs index fa977e48d5..8cf55c247d 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/DwSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/DwSqlGraphQLQueryTests.cs @@ -1239,6 +1239,7 @@ public void TestEnableDwNto1JoinQueryFeatureFlagLoadedFromRuntime() { EnableDwNto1JoinQueryOptimization = true }), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -1261,6 +1262,7 @@ public void TestEnableDwNto1JoinQueryFeatureFlagDefaultValueLoaded() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index 6193d843a0..e739f6cc8c 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -389,6 +389,7 @@ public static RuntimeConfig InitBasicRuntimeConfigWithNoEntity( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: authenticationOptions) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs index 5a9d783376..e70d18b1d6 100644 --- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs @@ -257,6 +257,7 @@ public void TestAddingRelationshipWithInvalidTargetEntity() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -317,6 +318,7 @@ public void TestAddingRelationshipWithDisabledGraphQL() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -373,6 +375,7 @@ string relationshipEntity Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -461,6 +464,7 @@ public void TestRelationshipWithNoLinkingObjectAndEitherSourceOrTargetFieldIsNul Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap)); @@ -553,6 +557,7 @@ public void TestRelationshipWithoutSourceAndTargetFieldsMatching( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap)); @@ -626,6 +631,7 @@ public void TestRelationshipWithoutSourceAndTargetFieldsAsValidBackingColumns( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap)); @@ -755,6 +761,7 @@ public void TestRelationshipWithoutLinkingSourceAndTargetFieldsMatching( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap)); @@ -1011,6 +1018,7 @@ public void TestOperationValidityAndCasing(string operationName, bool exceptionE Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap)); @@ -1083,6 +1091,7 @@ public void ValidateGraphQLTypeNamesFromConfig(string entityNameFromConfig, bool Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -1440,20 +1449,27 @@ public void ValidateValidEntityDefinitionsDoesNotGenerateDuplicateQueries(Databa /// /// GraphQL global path /// REST global path + /// MCP global path /// Exception expected [DataTestMethod] - [DataRow("/graphql", "/graphql", true)] - [DataRow("/api", "/api", true)] - [DataRow("/graphql", "/api", false)] - public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restConfiguredPath, bool expectError) + [DataRow("/graphql", "/graphql", "/mcp", true, DisplayName = "GraphQL and REST conflict (same path), MCP different.")] + [DataRow("/api", "/api", "/mcp", true, DisplayName = "REST and GraphQL conflict (same path), MCP different.")] + [DataRow("/graphql", "/api", "/mcp", false, DisplayName = "GraphQL and REST distinct, MCP different.")] + // Extra case: conflict with MCP + [DataRow("/mcp", "/api", "/mcp", true, DisplayName = "MCP and GraphQL conflict (same path).")] + [DataRow("/graphql", "/mcp", "/mcp", true, DisplayName = "MCP and REST conflict (same path).")] + + public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restConfiguredPath, string mcpConfiguredPath, bool expectError) { GraphQLRuntimeOptions graphQL = new(Path: graphQLConfiguredPath); RestRuntimeOptions rest = new(Path: restConfiguredPath); + McpRuntimeOptions mcp = new(Path: mcpConfiguredPath); RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig( new(DatabaseType.MSSQL, "", Options: null), graphQL, - rest); + rest, + mcp); string expectedErrorMessage = "Conflicting GraphQL and REST path configuration."; try @@ -1671,11 +1687,16 @@ public void ValidateApiURIsAreWellFormed( { string graphQLPathPrefix = GraphQLRuntimeOptions.DEFAULT_PATH; string restPathPrefix = RestRuntimeOptions.DEFAULT_PATH; + string mcpPathPrefix = McpRuntimeOptions.DEFAULT_PATH; if (apiType is ApiType.REST) { restPathPrefix = apiPathPrefix; } + else if (apiType is ApiType.MCP) + { + mcpPathPrefix = apiPathPrefix; + } else { graphQLPathPrefix = apiPathPrefix; @@ -1683,11 +1704,13 @@ public void ValidateApiURIsAreWellFormed( GraphQLRuntimeOptions graphQL = new(Path: graphQLPathPrefix); RestRuntimeOptions rest = new(Path: restPathPrefix); + McpRuntimeOptions mcp = new(Enabled: false); RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig( new(DatabaseType.MSSQL, "", Options: null), graphQL, - rest); + rest, + mcp); RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); @@ -1715,19 +1738,26 @@ public void ValidateApiURIsAreWellFormed( [DataRow(true, false, false, DisplayName = "REST enabled, and GraphQL disabled.")] [DataRow(false, true, false, DisplayName = "REST disabled, and GraphQL enabled.")] [DataRow(false, false, true, DisplayName = "Both REST and GraphQL are disabled.")] + [DataRow(true, true, true, false, DisplayName = "Both REST and GraphQL enabled, MCP enabled.")] + [DataRow(true, false, true, false, DisplayName = "REST enabled, and GraphQL disabled, MCP enabled.")] + [DataRow(false, true, true, false, DisplayName = "REST disabled, and GraphQL enabled, MCP enabled.")] + [DataRow(false, false, true, false, DisplayName = "Both REST and GraphQL are disabled, but MCP enabled.")] [DataTestMethod] public void EnsureFailureWhenBothRestAndGraphQLAreDisabled( bool restEnabled, bool graphqlEnabled, + bool mcpEnabled, bool expectError) { GraphQLRuntimeOptions graphQL = new(Enabled: graphqlEnabled); RestRuntimeOptions rest = new(Enabled: restEnabled); + McpRuntimeOptions mcp = new(Enabled: mcpEnabled); RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig( new(DatabaseType.MSSQL, "", Options: null), graphQL, - rest); + rest, + mcp); string expectedErrorMessage = "Both GraphQL and REST endpoints are disabled."; try @@ -1995,6 +2025,7 @@ public void ValidateRestMethodsForEntityInConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null)), Entities: new(entityMap)); @@ -2068,6 +2099,7 @@ public void ValidateRestPathForEntityInConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -2138,6 +2170,7 @@ public void ValidateUniqueRestPathsForEntitiesInConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(entityMap) @@ -2198,6 +2231,7 @@ public void ValidateRuntimeBaseRouteSettings( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: new(Provider: authenticationProvider, Jwt: null)), BaseRoute: runtimeBaseRoute ), @@ -2334,6 +2368,7 @@ public void TestRuntimeConfigSetupWithNonJsonConstructor() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new RuntimeEntities(entityMap), @@ -2405,6 +2440,7 @@ public void ValidatePaginationOptionsInConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null), Pagination: new PaginationOptions(defaultPageSize, maxPageSize, nextLinkRelative) ), @@ -2456,6 +2492,7 @@ public void ValidateMaxResponseSizeInConfig( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null, MaxResponseSizeMB: providedMaxResponseSizeMB) ), Entities: new(new Dictionary())); diff --git a/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs b/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs index ba7f05251a..02801de3e2 100644 --- a/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs @@ -38,6 +38,7 @@ public void VerifyCorrectErrorMessage(bool isDeveloperMode, string expected) Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null, isDeveloperMode ? HostMode.Development : HostMode.Production) ), Entities: new(new Dictionary()) @@ -80,6 +81,7 @@ public void TestIsTransientExceptionMethod(bool expected, int number) Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null, HostMode.Development) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs index 986419f228..376a4cfa2a 100644 --- a/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs +++ b/src/Service.Tests/UnitTests/MultiSourceQueryExecutionUnitTests.cs @@ -251,6 +251,7 @@ public async Task TestMultiSourceTokenSet() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null) ), DefaultDataSourceName: DATA_SOURCE_NAME_1, @@ -312,6 +313,7 @@ private static RuntimeConfig GenerateMockRuntimeConfigForMultiDbScenario() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), // use prod mode to avoid having to mock config file watcher Host: new(Cors: null, Authentication: null, HostMode.Production) ), diff --git a/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs index 423234aa73..cbfef36664 100644 --- a/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs @@ -46,6 +46,7 @@ public async Task TestHandleManagedIdentityAccess( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs index f0db8b4742..ccaa90b353 100644 --- a/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs @@ -57,6 +57,7 @@ public async Task TestHandleManagedIdentityAccess( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs b/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs index a19823df18..186f254c51 100644 --- a/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs @@ -356,6 +356,7 @@ public static void PerformTest( Runtime: new( Rest: new(Path: "/api"), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null) ), Entities: new(new Dictionary() diff --git a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs index 9d483bf1d2..1fa1a276ad 100644 --- a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs +++ b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs @@ -115,6 +115,7 @@ public static void InitializeTest(string restRoutePrefix, string entityName) Runtime: new( Rest: new(Path: restRoutePrefix), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs index 3c7427971d..82ae37be81 100644 --- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs @@ -3,19 +3,23 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.IO; using System.Net; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Tests.Configuration; using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.AspNetCore.Http; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -399,6 +403,102 @@ public async Task ValidateInferredRelationshipInfoForPgSql() ValidateInferredRelationshipInfoForTables(); } + /// + /// Data-driven test to validate that DataApiBuilderException is thrown for various invalid resultFieldName values + /// during stored procedure result set definition population. + /// + [DataTestMethod, TestCategory(TestCategory.MSSQL)] + [DataRow(null, DisplayName = "Null result field name")] + [DataRow("", DisplayName = "Empty result field name")] + [DataRow(" ", DisplayName = "Multiple spaces result field name")] + public async Task ValidateExceptionForInvalidResultFieldNames(string invalidFieldName) + { + DatabaseEngine = TestCategory.MSSQL; + TestHelper.SetupDatabaseEnvironment(DatabaseEngine); + RuntimeConfig baseConfigFromDisk = SqlTestHelper.SetupRuntimeConfig(); + + // Create a RuntimeEntities with ONLY our test stored procedure entity + Dictionary entitiesDictionary = new() + { + { + "get_book_by_id", new Entity( + Source: new("dbo.get_book_by_id", EntitySourceType.StoredProcedure, null, null), + Rest: new(Enabled: true), + GraphQL: new("get_book_by_id", "get_book_by_ids", Enabled: true), + Permissions: new EntityPermission[] { + new( + Role: "anonymous", + Actions: new EntityAction[] { + new(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) + }, + Relationships: null, + Mappings: null + ) + } + }; + + RuntimeEntities entities = new(entitiesDictionary); + RuntimeConfig runtimeConfig = baseConfigFromDisk with { Entities = entities }; + RuntimeConfigProvider runtimeConfigProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(runtimeConfig); + ILogger sqlMetadataLogger = new Mock>().Object; + + // Setup query builder + _queryBuilder = new MsSqlQueryBuilder(); + + try + { + string dataSourceName = runtimeConfigProvider.GetConfig().DefaultDataSourceName; + + // Create mock query executor that always returns JsonArray with invalid field name + Mock mockQueryExecutor = new(); + + // Create a JsonArray that simulates the stored procedure result with invalid field name + JsonArray invalidFieldJsonArray = new(); + JsonObject jsonObject = new() + { + [BaseSqlQueryBuilder.STOREDPROC_COLUMN_NAME] = invalidFieldName, // This will be null, empty, or whitespace + [BaseSqlQueryBuilder.STOREDPROC_COLUMN_SYSTEMTYPENAME] = "varchar", + [BaseSqlQueryBuilder.STOREDPROC_COLUMN_ISNULLABLE] = false + }; + invalidFieldJsonArray.Add(jsonObject); + + // Setup the mock to return our malformed JsonArray for all ExecuteQueryAsync calls + mockQueryExecutor.Setup(x => x.ExecuteQueryAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny, Task>>(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .ReturnsAsync(invalidFieldJsonArray); + + // Setup Mock query manager Factory + Mock queryManagerFactory = new(); + queryManagerFactory.Setup(x => x.GetQueryBuilder(It.IsAny())).Returns(_queryBuilder); + queryManagerFactory.Setup(x => x.GetQueryExecutor(It.IsAny())).Returns(mockQueryExecutor.Object); + + ISqlMetadataProvider sqlMetadataProvider = new MsSqlMetadataProvider( + runtimeConfigProvider, + queryManagerFactory.Object, + sqlMetadataLogger, + dataSourceName); + + await sqlMetadataProvider.InitializeAsync(); + Assert.Fail($"Expected DataApiBuilderException was not thrown for invalid resultFieldName: '{invalidFieldName}'."); + } + catch (DataApiBuilderException ex) + { + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, ex.SubStatusCode); + Assert.IsTrue(ex.Message.Contains("returns a column without a name")); + } + finally + { + TestHelper.UnsetAllDABEnvironmentVariables(); + } + } + /// /// Helper method for test methods ValidateInferredRelationshipInfoFor{MsSql, MySql, and PgSql}. /// This helper validates that an entity's relationship data is correctly inferred based on config and database supplied relationship metadata. diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index 92b076107a..908b7019c4 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -80,6 +80,7 @@ public async Task TestHandleManagedIdentityAccess( Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -154,6 +155,7 @@ public async Task TestRetryPolicyExhaustingMaxAttempts() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -229,6 +231,7 @@ public void Test_DbCommandParameter_PopulatedWithCorrectDbTypes() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -344,6 +347,7 @@ public async Task TestHttpContextIsPopulatedWithDbExecutionTime() Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -446,6 +450,7 @@ public void TestToValidateLockingOfHttpContextObjectDuringCalcuationOfDbExecutio Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(null, null) ), Entities: new(new Dictionary()) @@ -512,6 +517,7 @@ public void ValidateStreamingLogicAsync(int readDataLoops, bool exceptionExpecte Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null, MaxResponseSizeMB: 5) ), Entities: new(new Dictionary())); @@ -573,6 +579,7 @@ public void ValidateStreamingLogicForStoredProcedures(int readDataLoops, bool ex Runtime: new( Rest: new(), GraphQL: new(), + Mcp: new(), Host: new(Cors: null, Authentication: null, MaxResponseSizeMB: 4) ), Entities: new(new Dictionary())); diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 0f3b1e5f83..ba4fa1d438 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", + "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", "options": { "set-session-context": true } @@ -23,6 +23,31 @@ } } }, + "mcp": { + "enabled": true, + "path": "/mcp", + "dml-tools": { + "enabled": true, + "describe-entities": { + "enabled": true + }, + "create-record": { + "enabled": true + }, + "read-record": { + "enabled": true + }, + "update-record": { + "enabled": true + }, + "delete-record": { + "enabled": true + }, + "execute-record": { + "enabled": true + } + } + }, "host": { "cors": { "origins": [ @@ -2314,7 +2339,18 @@ "Notebook": { "source": { "object": "notebooks", - "type": "table" + "type": "table", + "object-description": "Table containing notebook information", + "parameters" : { + "id": { + "type": "int", + "description": "An integer parameter for testing" + }, + "name": { + "type": "string", + "description": "A string parameter for testing" + } + } }, "graphql": { "enabled": true, @@ -3800,4 +3836,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index d147bdc80a..aa580d0ce4 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -681,7 +681,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC endpoints.MapControllers(); // Special for MCP - endpoints.MapDabMcp(); + endpoints.MapDabMcp(runtimeConfigProvider); endpoints .MapGraphQL() diff --git a/src/Service/Telemetry/AzureLogAnalyticsCustomLogCollector.cs b/src/Service/Telemetry/AzureLogAnalyticsCustomLogCollector.cs index 6e150f64af..b756d8e16f 100644 --- a/src/Service/Telemetry/AzureLogAnalyticsCustomLogCollector.cs +++ b/src/Service/Telemetry/AzureLogAnalyticsCustomLogCollector.cs @@ -53,14 +53,22 @@ await _logs.Writer.WriteAsync( public async Task> DequeueAllAsync(string dabIdentifier, int flushIntervalSeconds) { List list = new(); - Stopwatch time = Stopwatch.StartNew(); if (await _logs.Reader.WaitToReadAsync()) { - while (_logs.Reader.TryRead(out AzureLogAnalyticsLogs? item)) + Stopwatch time = Stopwatch.StartNew(); + + while (true) { - item.Identifier = dabIdentifier; - list.Add(item); + if (_logs.Reader.TryRead(out AzureLogAnalyticsLogs? item)) + { + item.Identifier = dabIdentifier; + list.Add(item); + } + else + { + break; + } if (time.Elapsed >= TimeSpan.FromSeconds(flushIntervalSeconds)) { From 5e06a6ec2da268ab5c486d805ebc36f3b24a53ce Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 9 Sep 2025 23:05:51 +0530 Subject: [PATCH 17/63] Delete dab_aci_deploy.ps1 --- dab_aci_deploy.ps1 | 64 ---------------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 dab_aci_deploy.ps1 diff --git a/dab_aci_deploy.ps1 b/dab_aci_deploy.ps1 deleted file mode 100644 index a52a062639..0000000000 --- a/dab_aci_deploy.ps1 +++ /dev/null @@ -1,64 +0,0 @@ - -# === CONFIGURABLE VARIABLES === -$subscriptionId = "f33eb08a-3fe1-40e6-a9b6-2a9c376c616f" -$resourceGroup = "soghdabdevrg" -$location = "eastus" -$acrName = "dabdevacr" -$aciName = "dabdevaci" -$acrImageName = "dabdevacrimg" -$acrImageTag = "latest" -$dnsLabel = "dabaci" -$sqlServerName = "dabdevsqlserver" -$sqlAdminUser = "dabdevsqluser" -$sqlAdminPassword = "DabUserAdmin1$" -$sqlDbName = "dabdevsqldb" -$dockerfile = "Dockerfile" -$port = 5000 - -# === Set Azure Subscription === -az account set --subscription $subscriptionId - -# === Create Resource Group === -az group create --name $resourceGroup --location $location - -# === Create ACR === -az acr create --resource-group $resourceGroup --name $acrName --sku Basic --admin-enabled true - -# === Fetch ACR Credentials === -$acrPassword = az acr credential show --name $acrName --query "passwords[0].value" -o tsv -$acrLoginServer = az acr show --name $acrName --query "loginServer" -o tsv - -# === Build and Push Docker Image === -docker build -f $dockerfile -t "$acrImageName`:$acrImageTag" . -docker tag "$acrImageName`:$acrImageTag" "$acrLoginServer/$acrImageName`:$acrImageTag" -az acr login --name $acrName -docker push "$acrLoginServer/$acrImageName`:$acrImageTag" - -# === Create SQL Server and DB === -az sql server create --name $sqlServerName ` - --resource-group $resourceGroup --location $location ` - --admin-user $sqlAdminUser --admin-password $sqlAdminPassword - -az sql db create --resource-group $resourceGroup --server $sqlServerName --name $sqlDbName --service-objective S0 - -# === Allow Azure services to access SQL Server === -az sql server firewall-rule create --resource-group $resourceGroup --server $sqlServerName ` - --name "AllowAzureServices" --start-ip-address 0.0.0.0 --end-ip-address 255.255.255.255 - -# === Create ACI Container === -az container create ` - --resource-group $resourceGroup ` - --os-type "Linux" ` - --name $aciName ` - --image "$acrLoginServer/$acrImageName`:$acrImageTag" ` - --cpu 1 --memory 1.5 ` - --registry-login-server $acrLoginServer ` - --registry-username $acrName ` - --registry-password $acrPassword ` - --dns-name-label $dnsLabel ` - --environment-variables ASPNETCORE_URLS="http://+:$port" ` - --ports $port - -Write-Host "Deployment complete. App should be accessible at: http://$dnsLabel.$location.azurecontainer.io:$port" - -# az group delete --name $resourceGroup --yes --no-wait From 6ef8a3af5cc01a071f1670bf7acb7557b9370d43 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 9 Sep 2025 23:06:13 +0530 Subject: [PATCH 18/63] Delete dab_aca_deploy.ps1 --- dab_aca_deploy.ps1 | 91 ---------------------------------------------- 1 file changed, 91 deletions(-) delete mode 100644 dab_aca_deploy.ps1 diff --git a/dab_aca_deploy.ps1 b/dab_aca_deploy.ps1 deleted file mode 100644 index 93c19650a5..0000000000 --- a/dab_aca_deploy.ps1 +++ /dev/null @@ -1,91 +0,0 @@ -# === CONFIGURABLE VARIABLES === -$subscriptionId = "f33eb08a-3fe1-40e6-a9b6-2a9c376c616f" -$resourceGroup = "soghdabdevrg" -$location = "eastus" -$acrName = "dabdevacr" -$acaName = "dabdevaca" -$acrImageName = "dabdevacrimg" -$acrImageTag = "latest" -$dockerfile = "Dockerfile" -$containerPort = 1234 -$sqlServerName = "dabdevsqlserver" -$sqlAdminUser = "dabdevsqluser" -$sqlAdminPassword = "DabUserAdmin1$" -$sqlDbName = "dabdevsqldb" - -# === Authenticate and Setup === -az account set --subscription $subscriptionId -az group create --name $resourceGroup --location $location - -# === Create ACR === -az acr create ` - --resource-group $resourceGroup ` - --name $acrName ` - --sku Basic ` - --admin-enabled true - -$acrLoginServer = az acr show --name $acrName --query "loginServer" -o tsv -$acrPassword = az acr credential show --name $acrName --query "passwords[0].value" -o tsv - -# === Build and Push Docker Image === -docker build -f $dockerfile -t "${acrImageName}:${acrImageTag}" . -docker tag "${acrImageName}:${acrImageTag}" "${acrLoginServer}/${acrImageName}:${acrImageTag}" -az acr login --name $acrName -docker push "${acrLoginServer}/${acrImageName}:${acrImageTag}" - -# === Create SQL Server and Database === -az sql server create ` - --name $sqlServerName ` - --resource-group $resourceGroup ` - --location $location ` - --admin-user $sqlAdminUser ` - --admin-password $sqlAdminPassword - -az sql db create ` - --resource-group $resourceGroup ` - --server $sqlServerName ` - --name $sqlDbName ` - --service-objective S0 - -az sql server firewall-rule create ` - --resource-group $resourceGroup ` - --server $sqlServerName ` - --name "AllowAzureServices" ` - --start-ip-address 0.0.0.0 ` - --end-ip-address 255.255.255.255 - -# === Create ACA Environment === -$envName = "${acaName}Env" -az containerapp env create ` - --name $envName ` - --resource-group $resourceGroup ` - --location $location - -# === Deploy Container App with Fixed Port === -az containerapp create ` - --name $acaName ` - --resource-group $resourceGroup ` - --environment $envName ` - --image "${acrLoginServer}/${acrImageName}:${acrImageTag}" ` - --target-port $containerPort ` - --ingress external ` - --transport auto ` - --registry-server $acrLoginServer ` - --registry-username $acrName ` - --registry-password $acrPassword ` - --env-vars ` - ASPNETCORE_URLS="http://+:$containerPort" ` - SQL_SERVER="$sqlServerName.database.windows.net" ` - SQL_DATABASE="$sqlDbName" ` - SQL_USER="$sqlAdminUser" ` - SQL_PASSWORD="$sqlAdminPassword" - -# === Output Public App URL === -$appUrl = az containerapp show ` - --name $acaName ` - --resource-group $resourceGroup ` - --query "properties.configuration.ingress.fqdn" -o tsv - -Write-Host "`n✅ Deployment complete." -Write-Host "🌐 DAB accessible at: https://$appUrl" -Write-Host "🩺 Health check endpoint: https://$appUrl/health" \ No newline at end of file From d63d999ab45a261d44a63a06caa00b5d619db8ac Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 10 Sep 2025 16:41:52 +0530 Subject: [PATCH 19/63] Added mechanism to implement tools and configure them in generic way --- src/Azure.DataApiBuilder.Mcp/Extensions.cs | 108 +++++++------- src/Azure.DataApiBuilder.Mcp/IMcpTool.cs | 31 ++++ .../McpToolRegistry.cs | 40 ++++++ .../McpToolRegistryInitializer.cs | 40 ++++++ .../Tools/ComplexTool.cs | 132 ++++++++++++++++++ .../Tools/DescribeEntitiesTool.cs | 42 ++++++ .../Tools/EchoTool.cs | 47 +++++++ 7 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/IMcpTool.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Extensions.cs index d9d38a72e4..99a8cda269 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Extensions.cs @@ -35,6 +35,14 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service return services; } + // Register the tool registry + services.AddSingleton(); + + // Register individual tools + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Register domain tools services.AddDmlTools(_mcpOptions); @@ -48,75 +56,75 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service Tools = new() { ListToolsHandler = (request, ct) => - ValueTask.FromResult(new ListToolsResult + { + McpToolRegistry? toolRegistry = request.Services?.GetRequiredService(); + if (toolRegistry == null) + { + throw new InvalidOperationException("Tool registry is not available."); + } + + List tools = toolRegistry.GetAllTools().ToList(); + + return ValueTask.FromResult(new ListToolsResult { - Tools = - [ - new() - { - Name = "echonew", - Description = "Echoes the input back to the client.", - InputSchema = JsonSerializer.Deserialize( - @"{ - ""type"": ""object"", - ""properties"": { ""message"": { ""type"": ""string"" } }, - ""required"": [""message""] - }" - ) - }, - new() - { - Name = "describe_entities", - Description = "Lists all entities in the database." - } - ] - }), + Tools = tools + }); + }, CallToolHandler = async (request, ct) => { - if (request.Params?.Name == "echonew" && - request.Params.Arguments?.TryGetValue("message", out JsonElement messageEl) == true) + 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."); + } + + if (!toolRegistry.TryGetTool(toolName, out IMcpTool? tool) || tool == null) { - string? msg = messageEl.ValueKind == JsonValueKind.String - ? messageEl.GetString() - : messageEl.ToString(); + throw new McpException($"Unknown tool: '{toolName}'"); + } - return new CallToolResult + JsonDocument? arguments = null; + if (request.Params?.Arguments != null) + { + // Convert IReadOnlyDictionary to JsonDocument + Dictionary jsonObject = new(); + foreach (KeyValuePair kvp in request.Params.Arguments) { - Content = [new TextContentBlock { Type = "text", Text = $"Echo: {msg}" }] - }; + jsonObject[kvp.Key] = kvp.Value; + } + + string json = JsonSerializer.Serialize(jsonObject); + arguments = JsonDocument.Parse(json); } - else if (request.Params?.Name == "describe_entities") + + try { - // Get the service provider from the MCP context - IServiceProvider? serviceProvider = request.Services; - if (serviceProvider == null) + if (request.Services == null) { throw new InvalidOperationException("Service provider is not available in the request context."); } - // Create a scope to resolve scoped services - using IServiceScope scope = serviceProvider.CreateScope(); - IServiceProvider scopedProvider = scope.ServiceProvider; - - // Set the service provider for DmlTools - Azure.DataApiBuilder.Mcp.Tools.Extensions.ServiceProvider = scopedProvider; - - // Call the DescribeEntities tool method - string entitiesJson = await DmlTools.DescribeEntities(); - - return new CallToolResult - { - Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] - }; + return await tool.ExecuteAsync(arguments, request.Services, ct); + } + finally + { + arguments?.Dispose(); } - - throw new McpException($"Unknown tool: '{request.Params?.Name}'"); } } }; }) .WithHttpTransport(); + // Build the tool registry + services.AddHostedService(); + return services; } diff --git a/src/Azure.DataApiBuilder.Mcp/IMcpTool.cs b/src/Azure.DataApiBuilder.Mcp/IMcpTool.cs new file mode 100644 index 0000000000..1b39796e62 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/IMcpTool.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp +{ + /// + /// Interface for MCP tool implementations + /// + public interface IMcpTool + { + /// + /// Gets the tool metadata + /// + Tool GetToolMetadata(); + + /// + /// Executes the tool with the provided arguments + /// + /// The JSON arguments passed to the tool + /// The service provider for resolving dependencies + /// Cancellation token + /// The tool execution result + Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs b/src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs new file mode 100644 index 0000000000..76fe1f4e41 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp +{ + /// + /// Registry for managing MCP tools + /// + public class McpToolRegistry + { + private readonly Dictionary _tools = new(); + + /// + /// Registers a tool in the registry + /// + public void RegisterTool(IMcpTool tool) + { + Tool metadata = tool.GetToolMetadata(); + _tools[metadata.Name] = tool; + } + + /// + /// Gets all registered tools + /// + public IEnumerable GetAllTools() + { + return _tools.Values.Select(t => t.GetToolMetadata()); + } + + /// + /// Tries to get a tool by name + /// + public bool TryGetTool(string toolName, out IMcpTool? tool) + { + return _tools.TryGetValue(toolName, out tool); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs b/src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs new file mode 100644 index 0000000000..763e9c3f81 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Azure.DataApiBuilder.Mcp +{ + /// + /// Hosted service to initialize the MCP tool registry + /// + public class McpToolRegistryInitializer : IHostedService + { + private readonly IServiceProvider _serviceProvider; + private readonly McpToolRegistry _toolRegistry; + + public McpToolRegistryInitializer(IServiceProvider serviceProvider, McpToolRegistry toolRegistry) + { + _serviceProvider = serviceProvider; + _toolRegistry = toolRegistry; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + // Register all IMcpTool implementations + IEnumerable tools = _serviceProvider.GetServices(); + foreach (IMcpTool tool in tools) + { + _toolRegistry.RegisterTool(tool); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs new file mode 100644 index 0000000000..41820d40f6 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Tools +{ + /* This is a sample for reference and will be deleted + // to call the tool, use the following JSON payload body and make POST request to: http://localhost:5000/mcp + + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "process_data", + "arguments": { + "name": "DataOperation", + "count": 42, + "enabled": true, + "threshold": 3.14, + "tags": ["tag1", "tag2", "tag3"], + "options": { + "verbose": true, + "timeout": 60 + } + } + } + } + + */ + public class ComplexTool : IMcpTool + { + public Tool GetToolMetadata() + { + return new Tool + { + Name = "process_data", + Description = "Processes data with multiple parameters of different types.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { + ""name"": { ""type"": ""string"", ""description"": ""Name of the operation"" }, + ""count"": { ""type"": ""integer"", ""description"": ""Number of items to process"" }, + ""enabled"": { ""type"": ""boolean"", ""description"": ""Whether processing is enabled"" }, + ""threshold"": { ""type"": ""number"", ""description"": ""Processing threshold value"" }, + ""tags"": { + ""type"": ""array"", + ""items"": { ""type"": ""string"" }, + ""description"": ""List of tags"" + }, + ""options"": { + ""type"": ""object"", + ""properties"": { + ""verbose"": { ""type"": ""boolean"" }, + ""timeout"": { ""type"": ""integer"" } + } + } + }, + ""required"": [""name"", ""count""] + }" + ) + }; + } + + public Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + if (arguments == null) + { + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = "Error: No arguments provided" }] + }); + } + + JsonElement root = arguments.RootElement; + + // Extract different types of parameters + string name = root.TryGetProperty("name", out JsonElement nameEl) && nameEl.ValueKind == JsonValueKind.String + ? nameEl.GetString() ?? "Unknown" + : "Unknown"; + + int count = root.TryGetProperty("count", out JsonElement countEl) && countEl.ValueKind == JsonValueKind.Number + ? countEl.GetInt32() + : 0; + + bool enabled = root.TryGetProperty("enabled", out JsonElement enabledEl) && enabledEl.ValueKind == JsonValueKind.True; + + double threshold = root.TryGetProperty("threshold", out JsonElement thresholdEl) && thresholdEl.ValueKind == JsonValueKind.Number + ? thresholdEl.GetDouble() + : 0.0; + + List tags = new(); + if (root.TryGetProperty("tags", out JsonElement tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement tag in tagsEl.EnumerateArray()) + { + if (tag.ValueKind == JsonValueKind.String) + { + tags.Add(tag.GetString() ?? string.Empty); + } + } + } + + bool verbose = false; + int timeout = 30; + if (root.TryGetProperty("options", out JsonElement optionsEl) && optionsEl.ValueKind == JsonValueKind.Object) + { + verbose = optionsEl.TryGetProperty("verbose", out JsonElement verboseEl) && verboseEl.ValueKind == JsonValueKind.True; + + if (optionsEl.TryGetProperty("timeout", out JsonElement timeoutEl) && timeoutEl.ValueKind == JsonValueKind.Number) + { + timeout = timeoutEl.GetInt32(); + } + } + + // Process the data (example logic) + string result = $"Processed: name={name}, count={count}, enabled={enabled}, threshold={threshold}, " + + $"tags=[{string.Join(", ", tags)}], verbose={verbose}, timeout={timeout}"; + + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = result }] + }); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs new file mode 100644 index 0000000000..0867253971 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Tools +{ + public class DescribeEntitiesTool : IMcpTool + { + public Tool GetToolMetadata() + { + return new Tool + { + Name = "describe_entities", + Description = "Lists all entities in the database." + }; + } + + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + // Create a scope to resolve scoped services + using IServiceScope scope = serviceProvider.CreateScope(); + IServiceProvider scopedProvider = scope.ServiceProvider; + + // Set the service provider for DmlTools + Extensions.ServiceProvider = scopedProvider; + + // Call the DescribeEntities tool method + string entitiesJson = await DmlTools.DescribeEntities(); + + return new CallToolResult + { + Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] + }; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs new file mode 100644 index 0000000000..ad3735abe6 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Tools +{ + public class EchoTool : IMcpTool + { + public Tool GetToolMetadata() + { + return new Tool + { + Name = "echonew", + Description = "Echoes the input back to the client.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { ""message"": { ""type"": ""string"" } }, + ""required"": [""message""] + }" + ) + }; + } + + public Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + string? message = null; + + if (arguments?.RootElement.TryGetProperty("message", out JsonElement messageEl) == true) + { + message = messageEl.ValueKind == JsonValueKind.String + ? messageEl.GetString() + : messageEl.ToString(); + } + + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Echo: {message}" }] + }); + } + } +} From 046d04a60f21aba92a0905e1d055fc2c6340c847 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 11 Sep 2025 17:48:56 +0530 Subject: [PATCH 20/63] Auto registration of tools and refactoring --- .../McpEndpointRouteBuilderExtensions.cs | 57 ++++++++++++++ ...xtensions.cs => McpServerConfiguration.cs} | 78 ++----------------- .../McpServiceCollectionExtensions.cs | 71 +++++++++++++++++ 3 files changed, 136 insertions(+), 70 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs rename src/Azure.DataApiBuilder.Mcp/{Extensions.cs => McpServerConfiguration.cs} (58%) create mode 100644 src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..3f1dab23d6 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Health; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using ModelContextProtocol; + +namespace Azure.DataApiBuilder.Mcp +{ + /// + /// Extension methods for mapping MCP endpoints + /// + public static class McpEndpointRouteBuilderExtensions + { + /// + /// Maps MCP endpoints and health checks if MCP is enabled + /// + public static IEndpointRouteBuilder MapDabMcp( + this IEndpointRouteBuilder endpoints, + RuntimeConfigProvider runtimeConfigProvider, + [StringSyntax("Route")] string pattern = "") + { + if (!TryGetMcpOptions(runtimeConfigProvider, out McpRuntimeOptions? mcpOptions) || mcpOptions == null || !mcpOptions.Enabled) + { + return endpoints; + } + + string mcpPath = mcpOptions.Path ?? McpRuntimeOptions.DEFAULT_PATH; + + // Map the MCP endpoint + endpoints.MapMcp(mcpPath); + + // Map health checks relative to the MCP path + string healthPath = $"{mcpPath.TrimEnd('/')}/health"; + endpoints.MapDabHealthChecks(healthPath); + + return endpoints; + } + + private static bool TryGetMcpOptions(RuntimeConfigProvider runtimeConfigProvider, out McpRuntimeOptions? mcpOptions) + { + mcpOptions = null; + + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + return false; + } + + mcpOptions = runtimeConfig?.Runtime?.Mcp; + return mcpOptions != null; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs similarity index 58% rename from src/Azure.DataApiBuilder.Mcp/Extensions.cs rename to src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs index 99a8cda269..d526617e2f 100644 --- a/src/Azure.DataApiBuilder.Mcp/Extensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs @@ -1,56 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Mcp.Health; -using Azure.DataApiBuilder.Mcp.Tools; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Azure.DataApiBuilder.Mcp { - public static class Extensions + /// + /// Configuration for MCP server capabilities and handlers + /// + internal static class McpServerConfiguration { - private static McpRuntimeOptions? _mcpOptions; - - public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) + /// + /// Configures the MCP server with tool capabilities + /// + internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services) { - if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) - { - // If config is not available, skip MCP setup - return services; - } - - _mcpOptions = runtimeConfig?.Runtime?.Mcp; - - // Only add MCP server if it's enabled in the configuration - if (_mcpOptions == null || !_mcpOptions.Enabled) - { - return services; - } - - // Register the tool registry - services.AddSingleton(); - - // Register individual tools - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Register domain tools - services.AddDmlTools(_mcpOptions); - - // Register MCP server with dynamic tool handlers services.AddMcpServer(options => { options.ServerInfo = new() { Name = "Data API Builder MCP Server", Version = "1.0.0" }; - options.Capabilities = new() { Tools = new() @@ -122,39 +92,7 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service }) .WithHttpTransport(); - // Build the tool registry - services.AddHostedService(); - return services; } - - public static IEndpointRouteBuilder MapDabMcp(this IEndpointRouteBuilder endpoints, RuntimeConfigProvider runtimeConfigProvider, [StringSyntax("Route")] string pattern = "") - { - if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) - { - // If config is not available, skip MCP mapping - return endpoints; - } - - McpRuntimeOptions? mcpOptions = runtimeConfig?.Runtime?.Mcp; - - // Only map MCP endpoints if MCP is enabled - if (mcpOptions == null || !mcpOptions.Enabled) - { - return endpoints; - } - - // Get the MCP path with proper null handling and default - string mcpPath = mcpOptions.Path ?? McpRuntimeOptions.DEFAULT_PATH; - - // Map the MCP endpoint - endpoints.MapMcp(mcpPath); - - // Map health checks relative to the MCP path - string healthPath = mcpPath.TrimEnd('/') + "/health"; - endpoints.MapDabHealthChecks(healthPath); - - return endpoints; - } } } diff --git a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs new file mode 100644 index 0000000000..c4d51b57de --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.DataApiBuilder.Mcp +{ + /// + /// Extension methods for configuring MCP services in the DI container + /// + public static class McpServiceCollectionExtensions + { + /// + /// Adds MCP server and related services to the service collection + /// + public static IServiceCollection AddDabMcpServer(this IServiceCollection services, RuntimeConfigProvider runtimeConfigProvider) + { + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + // If config is not available, skip MCP setup + return services; + } + + McpRuntimeOptions? mcpOptions = runtimeConfig?.Runtime?.Mcp; + + // Only add MCP server if it's enabled in the configuration + if (mcpOptions == null || !mcpOptions.Enabled) + { + return services; + } + + // Register core MCP services + services.AddSingleton(); + services.AddHostedService(); + + // Auto-discover and register all MCP tools + RegisterAllMcpTools(services); + + // Register domain-specific tools + services.AddDmlTools(mcpOptions); + + // Configure MCP server + services.ConfigureMcpServer(); + + return services; + } + + /// + /// Automatically discovers and registers all classes implementing IMcpTool + /// + private static void RegisterAllMcpTools(IServiceCollection services) + { + Assembly mcpAssembly = typeof(IMcpTool).Assembly; + + IEnumerable toolTypes = mcpAssembly.GetTypes() + .Where(t => t.IsClass && + !t.IsAbstract && + typeof(IMcpTool).IsAssignableFrom(t)); + + foreach (Type toolType in toolTypes) + { + // Additional logic can be added to skip regstration, for e.g., if a tool is disabled in config + services.AddSingleton(typeof(IMcpTool), toolType); + } + } + } +} From 0796e32ceb80e4f436ab97437153e4a62133cd85 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 11 Sep 2025 18:30:16 +0530 Subject: [PATCH 21/63] Refactored unwanted files and logic --- .../McpServiceCollectionExtensions.cs | 5 -- .../Tools/CreateRecordTool.cs | 83 +++++++++++++++++++ .../Tools/DescribeEntitiesTool.cs | 59 ++++++++++--- .../Tools/DmlTools.cs | 41 --------- .../Tools/Extensions.cs | 82 ------------------ 5 files changed, 131 insertions(+), 139 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs delete mode 100644 src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs index c4d51b57de..e13c919598 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using System.Reflection; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Mcp.Tools; using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp @@ -40,9 +39,6 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Auto-discover and register all MCP tools RegisterAllMcpTools(services); - // Register domain-specific tools - services.AddDmlTools(mcpOptions); - // Configure MCP server services.ConfigureMcpServer(); @@ -63,7 +59,6 @@ private static void RegisterAllMcpTools(IServiceCollection services) foreach (Type toolType in toolTypes) { - // Additional logic can be added to skip regstration, for e.g., if a tool is disabled in config services.AddSingleton(typeof(IMcpTool), toolType); } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs new file mode 100644 index 0000000000..abbfe76b78 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Tools +{ + public class CreateRecordTool : IMcpTool + { + public Tool GetToolMetadata() + { + return new Tool + { + Name = "create_record", + Description = "Creates a new record in the specified entity.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { + ""entity"": { + ""type"": ""string"", + ""description"": ""The name of the entity"" + }, + ""data"": { + ""type"": ""object"", + ""description"": ""The data for the new record"" + } + }, + ""required"": [""entity"", ""data""] + }" + ) + }; + } + + public Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + if (arguments == null) + { + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = "Error: No arguments provided" }] + }); + } + + try + { + // Extract arguments + JsonElement root = arguments.RootElement; + + if (!root.TryGetProperty("entity", out JsonElement entityElement) || + !root.TryGetProperty("data", out JsonElement dataElement)) + { + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = "Error: Missing required arguments 'entity' or 'data'" }] + }); + } + + string entityName = entityElement.GetString() ?? string.Empty; + + // TODO: Implement actual create logic using DAB's internal services + // For now, return a placeholder response + string result = $"Would create record in entity '{entityName}' with data: {dataElement.GetRawText()}"; + + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = result }] + }); + } + catch (Exception ex) + { + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Error: {ex.Message}" }] + }); + } + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs index 0867253971..d449bc8486 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; @@ -18,25 +20,60 @@ public Tool GetToolMetadata() }; } - public async Task ExecuteAsync( + public Task ExecuteAsync( JsonDocument? arguments, IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { - // Create a scope to resolve scoped services - using IServiceScope scope = serviceProvider.CreateScope(); - IServiceProvider scopedProvider = scope.ServiceProvider; + try + { + // Get the runtime config provider + RuntimeConfigProvider? runtimeConfigProvider = serviceProvider.GetService(); + if (runtimeConfigProvider == null || !runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = "Error: Runtime configuration not available." }] + }); + } - // Set the service provider for DmlTools - Extensions.ServiceProvider = scopedProvider; + // Extract entity information from the runtime config + Dictionary entities = new(); + + if (runtimeConfig.Entities != null) + { + foreach (KeyValuePair entity in runtimeConfig.Entities) + { + entities[entity.Key] = new + { + source = entity.Value.Source, + permissions = entity.Value.Permissions?.Select(p => new + { + role = p.Role, + actions = p.Actions + }) + }; + } + } - // Call the DescribeEntities tool method - string entitiesJson = await DmlTools.DescribeEntities(); + string entitiesJson = JsonSerializer.Serialize(entities, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); - return new CallToolResult + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] + }); + } + catch (Exception ex) { - Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] - }; + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Type = "text", Text = $"Error: {ex.Message}" }] + }); + } } } } diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs b/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs deleted file mode 100644 index bb328c7e29..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DmlTools.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.ComponentModel; -using System.Diagnostics; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -[McpServerToolType] -public static class DmlTools -{ - private static readonly ILogger _logger; - - static DmlTools() - { - _logger = LoggerFactory.Create(builder => - { - builder.AddConsole(); - }).CreateLogger(nameof(DmlTools)); - } - - public static async Task DescribeEntities( - [Description("This optional boolean parameter allows you (when true) to ask for entities without any additional metadata other than description.")] - bool nameOnly = false, - [Description("This optional string array parameter allows you to filter the response to only a select list of entities. You must first return the full list of entities to get the names to filter.")] - string[]? entityNames = null) - { - _logger.LogInformation("GetEntityMetadataAsJson tool called with nameOnly: {nameOnly}, entityNames: {entityNames}", - nameOnly, entityNames != null ? string.Join(", ", entityNames) : "null"); - - using (Activity activity = new("MCP")) - { - activity.SetTag("tool", nameof(DescribeEntities)); - SchemaLogic schemaLogic = new(Extensions.ServiceProvider!); - string jsonMetadata = await schemaLogic.GetEntityMetadataAsJsonAsync(nameOnly, entityNames); - return jsonMetadata; - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs b/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs deleted file mode 100644 index a663d1e430..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/Tools/Extensions.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Reflection; -using Azure.DataApiBuilder.Config.ObjectModel; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Server; - -namespace Azure.DataApiBuilder.Mcp.Tools; - -public static class Extensions -{ - public static IServiceProvider? ServiceProvider { get; set; } - - public static void AddDmlTools(this IServiceCollection services, McpRuntimeOptions? mcpOptions) - { - if (mcpOptions?.DmlTools == null || !mcpOptions.DmlTools.Enabled) - { - return; - } - - HashSet DmlToolNames = new(); - - // Check each DML tool property and add to the set if enabled - if (mcpOptions.DmlTools.DescribeEntities) - { - DmlToolNames.Add("DescribeEntities"); - } - - if (mcpOptions.DmlTools.CreateRecord) - { - DmlToolNames.Add("CreateRecord"); - } - - if (mcpOptions.DmlTools.ReadRecord) - { - DmlToolNames.Add("ReadRecord"); - } - - if (mcpOptions.DmlTools.UpdateRecord) - { - DmlToolNames.Add("UpdateRecord"); - } - - if (mcpOptions.DmlTools.DeleteRecord) - { - DmlToolNames.Add("DeleteRecord"); - } - - if (mcpOptions.DmlTools.ExecuteRecord) - { - DmlToolNames.Add("ExecuteRecord"); - } - - IEnumerable methods = typeof(DmlTools).GetMethods() - .Where(method => DmlToolNames.Contains(method.Name)); - - foreach (MethodInfo method in methods) - { - AddTool(services, method); - } - - AddTool(services, typeof(DmlTools).GetMethod("Echo")!); - } - - private static void AddTool(IServiceCollection services, MethodInfo method) - { - Func factory = (services) => - { - ServiceProvider ??= services; - - McpServerTool tool = McpServerTool - .Create(method, options: new() - { - Services = services, - SerializerOptions = default - }); - return tool; - }; - _ = services.AddSingleton(factory); - } -} From 78baf4ae117ca36d6f2799e0dba7e0a3df6b267a Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 15 Sep 2025 11:20:32 +0530 Subject: [PATCH 22/63] Refactoring and removed gitignore, installcredprovider.ps1 --- .gitignore | 3 +- installcredprovider.ps1 | 259 ------------------ .../McpEndpointRouteBuilderExtensions.cs | 6 + .../McpServiceCollectionExtensions.cs | 6 +- src/Service/Properties/launchSettings.json | 4 +- 5 files changed, 12 insertions(+), 266 deletions(-) delete mode 100644 installcredprovider.ps1 diff --git a/.gitignore b/.gitignore index a922bf369d..56bd0e435d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,4 @@ dab-config*.json .env # Verify test files -*.received.* -/src/git +*.received.* \ No newline at end of file diff --git a/installcredprovider.ps1 b/installcredprovider.ps1 deleted file mode 100644 index 788c6eef08..0000000000 --- a/installcredprovider.ps1 +++ /dev/null @@ -1,259 +0,0 @@ -<# -.SYNOPSIS - Installs the Azure Artifacts Credential Provider for DotNet or NuGet tool usage. - -.DESCRIPTION - This script installs the latest version of the Azure Artifacts Credential Provider plugin - for DotNet and/or NuGet to the ~/.nuget/plugins directory. - -.PARAMETER AddNetfx - Installs the .NET Framework 4.6.1 Credential Provider. - -.PARAMETER AddNetfx48 - Installs the .NET Framework 4.8.1 Credential Provider. - -.PARAMETER Force - Forces overwriting of existing Credential Provider installations. - -.PARAMETER Version - Specifies the GitHub release version of the Credential Provider to install. - -.PARAMETER InstallNet6 - Installs the .NET 6 Credential Provider (default). - -.PARAMETER InstallNet8 - Installs the .NET 8 Credential Provider. - -.PARAMETER RuntimeIdentifier - Installs the self-contained Credential Provider for the specified Runtime Identifier. - -.EXAMPLE - .\installcredprovider.ps1 -InstallNet8 - .\installcredprovider.ps1 -Version "1.0.1" -Force -#> - -[CmdletBinding(HelpUri = "https://github.com/microsoft/artifacts-credprovider/blob/master/README.md#setup")] -param( - [switch]$AddNetfx, - [switch]$AddNetfx48, - [switch]$Force, - [string]$Version, - [switch]$InstallNet6 = $true, - [switch]$InstallNet8, - [string]$RuntimeIdentifier -) - -$script:ErrorActionPreference = 'Stop' - -# Without this, System.Net.WebClient.DownloadFile will fail on a client with TLS 1.0/1.1 disabled -if ([Net.ServicePointManager]::SecurityProtocol.ToString().Split(',').Trim() -notcontains 'Tls12') { - [Net.ServicePointManager]::SecurityProtocol += [Net.SecurityProtocolType]::Tls12 -} - -if ($Version.StartsWith("0.") -and $InstallNet6 -eq $True) { - Write-Error "You cannot install the .Net 6 version with versions lower than 1.0.0" - return -} -if (($Version.StartsWith("0.") -or $Version.StartsWith("1.0") -or $Version.StartsWith("1.1") -or $Version.StartsWith("1.2")) -and - ($InstallNet8 -eq $True -or $AddNetfx48 -eq $True)) { - Write-Error "You cannot install the .Net 8 or NetFX 4.8.1 version or with versions lower than 1.3.0" - return -} -if ($AddNetfx -eq $True -and $AddNetfx48 -eq $True) { - Write-Error "Please select a single .Net framework version to install" - return -} -if (![string]::IsNullOrEmpty($RuntimeIdentifier)) { - if (($Version.StartsWith("0.") -or $Version.StartsWith("1.0") -or $Version.StartsWith("1.1") -or $Version.StartsWith("1.2") -or $Version.StartsWith("1.3"))) { - Write-Error "You cannot install the .Net 8 self-contained version or with versions lower than 1.4.0" - return - } - - Write-Host "RuntimeIdentifier parameter is specified, the .Net 8 self-contained version will be installed" - $InstallNet6 = $False - $InstallNet8 = $True -} -if ($InstallNet6 -eq $True -and $InstallNet8 -eq $True) { - # InstallNet6 defaults to true, in the case of .Net 8 install, overwrite - $InstallNet6 = $False -} - -$userProfilePath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile); -if ($userProfilePath -ne '') { - $profilePath = $userProfilePath -} -else { - $profilePath = $env:UserProfile -} - -$tempPath = [System.IO.Path]::GetTempPath() - -$pluginLocation = [System.IO.Path]::Combine($profilePath, ".nuget", "plugins"); -$tempZipLocation = [System.IO.Path]::Combine($tempPath, "CredProviderZip"); - -$localNetcoreCredProviderPath = [System.IO.Path]::Combine("netcore", "CredentialProvider.Microsoft"); -$localNetfxCredProviderPath = [System.IO.Path]::Combine("netfx", "CredentialProvider.Microsoft"); - -$fullNetfxCredProviderPath = [System.IO.Path]::Combine($pluginLocation, $localNetfxCredProviderPath) -$fullNetcoreCredProviderPath = [System.IO.Path]::Combine($pluginLocation, $localNetcoreCredProviderPath) - -$netfxExists = Test-Path -Path ($fullNetfxCredProviderPath) -$netcoreExists = Test-Path -Path ($fullNetcoreCredProviderPath) - -# Check if plugin already exists if -Force swich is not set -if (!$Force) { - if ($AddNetfx -eq $True -and $netfxExists -eq $True -and $netcoreExists -eq $True) { - Write-Host "The netcore and netfx Credential Providers are already in $pluginLocation" - return - } - - if ($AddNetfx -eq $False -and $netcoreExists -eq $True) { - Write-Host "The netcore Credential Provider is already in $pluginLocation" - return - } -} - -# Get the zip file from the GitHub release -$releaseUrlBase = "https://api.github.com/repos/Microsoft/artifacts-credprovider/releases" -$versionError = "Unable to find the release version $Version from $releaseUrlBase" -$releaseId = "latest" -if (![string]::IsNullOrEmpty($Version)) { - try { - $releases = Invoke-WebRequest -UseBasicParsing $releaseUrlBase - $releaseJson = $releases | ConvertFrom-Json - $correctReleaseVersion = $releaseJson | ? { $_.name -eq $Version } - $releaseId = $correctReleaseVersion.id - } - catch { - Write-Error $versionError - return - } -} - -if (!$releaseId) { - Write-Error $versionError - return -} - -$releaseUrl = [System.IO.Path]::Combine($releaseUrlBase, $releaseId) -$releaseUrl = $releaseUrl.Replace("\", "/") - -$releaseRidPart = "" -if (![string]::IsNullOrEmpty($RuntimeIdentifier)) { - $releaseRIdPart = $RuntimeIdentifier + "." -} - -if ($Version.StartsWith("0.")) { - # versions lower than 1.0.0 installed NetCore2 zip - $zipFile = "Microsoft.NetCore2.NuGet.CredentialProvider.zip" -} -if ($InstallNet6 -eq $True) { - $zipFile = "Microsoft.Net6.NuGet.CredentialProvider.zip" -} -if ($InstallNet8 -eq $True) { - $zipFile = "Microsoft.Net8.${releaseRidPart}NuGet.CredentialProvider.zip" -} -if ($AddNetfx -eq $True) { - Write-Warning "The .Net Framework 4.6.1 version of the Credential Provider is deprecated and will be removed in the next major release. Please migrate to the .Net Framework 4.8 or .Net Core versions." - $zipFile = "Microsoft.NuGet.CredentialProvider.zip" -} -if ($AddNetfx48 -eq $True) { - $zipFile = "Microsoft.NetFx48.NuGet.CredentialProvider.zip" -} -if (-not $zipFile) { - Write-Warning "The .Net Core 3.1 version of the Credential Provider is deprecated and will be removed in the next major release. Please migrate to the .Net 8 version." - $zipFile = "Microsoft.NetCore3.NuGet.CredentialProvider.zip" -} - -function InstallZip { - Write-Verbose "Using $zipFile" - - try { - Write-Host "Fetching release $releaseUrl" - $release = Invoke-WebRequest -UseBasicParsing $releaseUrl - if (!$release) { - throw ("Unable to make Web Request to $releaseUrl") - } - $releaseJson = $release.Content | ConvertFrom-Json - if (!$releaseJson) { - throw ("Unable to get content from JSON") - } - $zipAsset = $releaseJson.assets | ? { $_.name -eq $zipFile } - if (!$zipAsset) { - throw ("Unable to find asset $zipFile from release json object") - } - $packageSourceUrl = $zipAsset.browser_download_url - if (!$packageSourceUrl) { - throw ("Unable to find download url from asset $zipAsset") - } - } - catch { - Write-Error ("Unable to resolve the browser download url from $releaseUrl `nError: " + $_.Exception.Message) - return - } - - # Create temporary location for the zip file handling - Write-Verbose "Creating temp directory for the Credential Provider zip: $tempZipLocation" - if (Test-Path -Path $tempZipLocation) { - Remove-Item $tempZipLocation -Force -Recurse - } - New-Item -ItemType Directory -Force -Path $tempZipLocation - - # Download credential provider zip to the temp location - $pluginZip = ([System.IO.Path]::Combine($tempZipLocation, $zipFile)) - Write-Host "Downloading $packageSourceUrl to $pluginZip" - try { - $client = New-Object System.Net.WebClient - $client.DownloadFile($packageSourceUrl, $pluginZip) - } - catch { - Write-Error "Unable to download $packageSourceUrl to the location $pluginZip" - } - - # Extract zip to temp directory - Write-Host "Extracting zip to the Credential Provider temp directory $tempZipLocation" - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::ExtractToDirectory($pluginZip, $tempZipLocation) -} - -# Call InstallZip function -InstallZip - -# Remove existing content and copy netfx directories to plugins directory -if ($AddNetfx -eq $True -or $AddNetfx48 -eq $True) { - if ($netfxExists) { - Write-Verbose "Removing existing content from $fullNetfxCredProviderPath" - Remove-Item $fullNetfxCredProviderPath -Force -Recurse - } - $tempNetfxPath = [System.IO.Path]::Combine($tempZipLocation, "plugins", $localNetfxCredProviderPath) - Write-Verbose "Copying Credential Provider from $tempNetfxPath to $fullNetfxCredProviderPath" - Copy-Item $tempNetfxPath -Destination $fullNetfxCredProviderPath -Force -Recurse -} - -# Microsoft.NuGet.CredentialProvider.zip that installs netfx provider installs .netcore3.1 version -# If InstallNet6 is also true we need to replace netcore cred provider with net6 -if ($AddNetfx -eq $True -and $InstallNet6 -eq $True) { - $zipFile = "Microsoft.Net6.NuGet.CredentialProvider.zip" - Write-Verbose "Installing Net6" - InstallZip -} -if ($AddNetfx -eq $True -and $InstallNet8 -eq $True) { - $zipFile = "Microsoft.Net8.NuGet.CredentialProvider.zip" - Write-Verbose "Installing Net8" - InstallZip -} - -# Remove existing content and copy netcore directories to plugins directory -if ($netcoreExists) { - Write-Verbose "Removing existing content from $fullNetcoreCredProviderPath" - Remove-Item $fullNetcoreCredProviderPath -Force -Recurse -} -$tempNetcorePath = [System.IO.Path]::Combine($tempZipLocation, "plugins", $localNetcoreCredProviderPath) -Write-Verbose "Copying Credential Provider from $tempNetcorePath to $fullNetcoreCredProviderPath" -Copy-Item $tempNetcorePath -Destination $fullNetcoreCredProviderPath -Force -Recurse - -# Remove $tempZipLocation directory -Write-Verbose "Removing the Credential Provider temp directory $tempZipLocation" -Remove-Item $tempZipLocation -Force -Recurse - -Write-Host "Credential Provider installed successfully" \ No newline at end of file diff --git a/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs index 3f1dab23d6..3ddf9a842e 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs @@ -41,6 +41,12 @@ public static IEndpointRouteBuilder MapDabMcp( return endpoints; } + /// + /// Gets MCP options from the runtime configuration + /// + /// Runtime config provider + /// MCP options + /// True if MCP options were found, false otherwise private static bool TryGetMcpOptions(RuntimeConfigProvider runtimeConfigProvider, out McpRuntimeOptions? mcpOptions) { mcpOptions = null; diff --git a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs index e13c919598..d33065c5d5 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs @@ -51,10 +51,10 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service private static void RegisterAllMcpTools(IServiceCollection services) { Assembly mcpAssembly = typeof(IMcpTool).Assembly; - + IEnumerable toolTypes = mcpAssembly.GetTypes() - .Where(t => t.IsClass && - !t.IsAbstract && + .Where(t => t.IsClass && + !t.IsAbstract && typeof(IMcpTool).IsAssignableFrom(t)); foreach (Type toolType in toolTypes) diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index cafc7ac070..c05b7959bd 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -41,7 +41,7 @@ "MsSql": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "jerry", + "launchUrl": "graphql", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, @@ -77,4 +77,4 @@ "sslPort": 44353 } } -} \ No newline at end of file +} From 2362084c762d133175a899938d0bd26d89778fc3 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 15 Sep 2025 15:22:51 +0530 Subject: [PATCH 23/63] Refactoring file structures --- .../Azure.DataApiBuilder.Mcp.csproj | 4 ++++ .../{Tools => BuiltInTools}/ComplexTool.cs | 3 ++- .../{Tools => BuiltInTools}/CreateRecordTool.cs | 3 ++- .../{Tools => BuiltInTools}/DescribeEntitiesTool.cs | 3 ++- .../{Tools => BuiltInTools}/EchoTool.cs | 3 ++- .../{ => Core}/McpEndpointRouteBuilderExtensions.cs | 2 +- .../{ => Core}/McpServerConfiguration.cs | 3 ++- .../{ => Core}/McpServiceCollectionExtensions.cs | 3 ++- src/Azure.DataApiBuilder.Mcp/{ => Core}/McpToolRegistry.cs | 3 ++- .../{ => Core}/McpToolRegistryInitializer.cs | 3 ++- src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs | 2 +- src/Azure.DataApiBuilder.Mcp/{ => Model}/IMcpTool.cs | 2 +- .../{Tools => Utilities}/SchemaLogic.cs | 6 +++--- src/Service/Startup.cs | 2 +- 14 files changed, 27 insertions(+), 15 deletions(-) rename src/Azure.DataApiBuilder.Mcp/{Tools => BuiltInTools}/ComplexTool.cs (98%) rename src/Azure.DataApiBuilder.Mcp/{Tools => BuiltInTools}/CreateRecordTool.cs (97%) rename src/Azure.DataApiBuilder.Mcp/{Tools => BuiltInTools}/DescribeEntitiesTool.cs (97%) rename src/Azure.DataApiBuilder.Mcp/{Tools => BuiltInTools}/EchoTool.cs (94%) rename src/Azure.DataApiBuilder.Mcp/{ => Core}/McpEndpointRouteBuilderExtensions.cs (98%) rename src/Azure.DataApiBuilder.Mcp/{ => Core}/McpServerConfiguration.cs (98%) rename src/Azure.DataApiBuilder.Mcp/{ => Core}/McpServiceCollectionExtensions.cs (96%) rename src/Azure.DataApiBuilder.Mcp/{ => Core}/McpToolRegistry.cs (92%) rename src/Azure.DataApiBuilder.Mcp/{ => Core}/McpToolRegistryInitializer.cs (93%) rename src/Azure.DataApiBuilder.Mcp/{ => Model}/IMcpTool.cs (95%) rename src/Azure.DataApiBuilder.Mcp/{Tools => Utilities}/SchemaLogic.cs (99%) diff --git a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj index 4aecc24710..f675f8d8d1 100644 --- a/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj +++ b/src/Azure.DataApiBuilder.Mcp/Azure.DataApiBuilder.Mcp.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs similarity index 98% rename from src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs index 41820d40f6..abb33d88e0 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/ComplexTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp.Tools +namespace Azure.DataApiBuilder.Mcp.BuiltInTools { /* This is a sample for reference and will be deleted // to call the tool, use the following JSON payload body and make POST request to: http://localhost:5000/mcp diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs similarity index 97% rename from src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index abbfe76b78..77e1187a20 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp.Tools +namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class CreateRecordTool : IMcpTool { diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs similarity index 97% rename from src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index d449bc8486..2baeb1ebe8 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -4,10 +4,11 @@ using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp.Tools +namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class DescribeEntitiesTool : IMcpTool { diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs similarity index 94% rename from src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs index ad3735abe6..60143a1c4a 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/EchoTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp.Tools +namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class EchoTool : IMcpTool { diff --git a/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs similarity index 98% rename from src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs rename to src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs index 3ddf9a842e..08ca7fc61e 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Routing; using ModelContextProtocol; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Extension methods for mapping MCP endpoints diff --git a/src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs similarity index 98% rename from src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs rename to src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index d526617e2f..ebee7407ab 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -2,11 +2,12 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Configuration for MCP server capabilities and handlers diff --git a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs similarity index 96% rename from src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs rename to src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index d33065c5d5..dd9575e341 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -4,9 +4,10 @@ using System.Reflection; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Extension methods for configuring MCP services in the DI container diff --git a/src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs similarity index 92% rename from src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs rename to src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs index 76fe1f4e41..9c9b96d72b 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpToolRegistry.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Registry for managing MCP tools diff --git a/src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistryInitializer.cs similarity index 93% rename from src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs rename to src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistryInitializer.cs index 763e9c3f81..97d0dac7f3 100644 --- a/src/Azure.DataApiBuilder.Mcp/McpToolRegistryInitializer.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistryInitializer.cs @@ -1,10 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Core { /// /// Hosted service to initialize the MCP tool registry diff --git a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs index b26a4effb8..5407fa82d1 100644 --- a/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs +++ b/src/Azure.DataApiBuilder.Mcp/Health/McpCheck.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; -using Azure.DataApiBuilder.Mcp.Tools; +using Azure.DataApiBuilder.Mcp.Utilities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/Azure.DataApiBuilder.Mcp/IMcpTool.cs b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs similarity index 95% rename from src/Azure.DataApiBuilder.Mcp/IMcpTool.cs rename to src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs index 1b39796e62..69136b3276 100644 --- a/src/Azure.DataApiBuilder.Mcp/IMcpTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs @@ -4,7 +4,7 @@ using System.Text.Json; using ModelContextProtocol.Protocol; -namespace Azure.DataApiBuilder.Mcp +namespace Azure.DataApiBuilder.Mcp.Model { /// /// Interface for MCP tool implementations diff --git a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs b/src/Azure.DataApiBuilder.Mcp/Utilities/SchemaLogic.cs similarity index 99% rename from src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs rename to src/Azure.DataApiBuilder.Mcp/Utilities/SchemaLogic.cs index 3c51261604..8f7f5b02f6 100644 --- a/src/Azure.DataApiBuilder.Mcp/Tools/SchemaLogic.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utilities/SchemaLogic.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Azure.DataApiBuilder.Mcp.Tools; +namespace Azure.DataApiBuilder.Mcp.Utilities; /// /// Provides GraphQL schema analysis and entity metadata extraction functionality @@ -264,7 +264,7 @@ private static List BuildPrimaryKeysFromSchema(IObjectType objectType) keys.Add(new { - Name = field.Name, + field.Name, Type = MapGraphQLTypeToString(field.Type), Autogen = isAutoGenerated, Description = $"Primary key field for {objectType.Name}", @@ -337,7 +337,7 @@ private static List BuildFieldsFromSchema(IObjectType objectType) fields.Add(new { - Name = value.field.Name, + value.field.Name, Type = MapGraphQLTypeToString(value.field.Type), Nullable = isNullable, HasDefault = hasDefaultValue, diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index aa580d0ce4..416d55be9f 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -24,7 +24,7 @@ using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Core.Telemetry; -using Azure.DataApiBuilder.Mcp; +using Azure.DataApiBuilder.Mcp.Core; using Azure.DataApiBuilder.Service.Controllers; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.HealthCheck; From c212bdd31326e1868f92102f5db1376c3ee9ed16 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 15 Sep 2025 15:54:02 +0530 Subject: [PATCH 24/63] Refactoring- files structures, unwanted changes --- src/AppHost/AppHost.cs | 6 ---- src/AppHost/AppHost.csproj | 21 -------------- src/AppHost/Properties/launchSettings.json | 29 ------------------- src/AppHost/appsettings.json | 9 ------ .../BuiltInTools/ComplexTool.cs | 3 ++ .../BuiltInTools/CreateRecordTool.cs | 3 ++ .../BuiltInTools/DescribeEntitiesTool.cs | 3 ++ .../BuiltInTools/EchoTool.cs | 3 ++ src/Azure.DataApiBuilder.Mcp/Model/Enums.cs | 19 ++++++++++++ .../Model/IMcpTool.cs | 6 ++++ src/Service/Properties/launchSettings.json | 18 ++++++------ 11 files changed, 46 insertions(+), 74 deletions(-) delete mode 100644 src/AppHost/AppHost.cs delete mode 100644 src/AppHost/AppHost.csproj delete mode 100644 src/AppHost/Properties/launchSettings.json delete mode 100644 src/AppHost/appsettings.json create mode 100644 src/Azure.DataApiBuilder.Mcp/Model/Enums.cs diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs deleted file mode 100644 index 9c1c3b1b07..0000000000 --- a/src/AppHost/AppHost.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -var builder = DistributedApplication.CreateBuilder(args); - -builder.Build().Run(); diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj deleted file mode 100644 index 675857d766..0000000000 --- a/src/AppHost/AppHost.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Exe - net8.0 - enable - enable - c5cad791-d88c-4afa-aa4e-751eb542808e - - - - - - - - - - - diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json deleted file mode 100644 index 57a9c83412..0000000000 --- a/src/AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17204;http://localhost:15267", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21261", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22259" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15267", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19162", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20222" - } - } - } -} diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json deleted file mode 100644 index 31c092aa45..0000000000 --- a/src/AppHost/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs index abb33d88e0..35762de78f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.Enums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { @@ -33,6 +34,8 @@ namespace Azure.DataApiBuilder.Mcp.BuiltInTools */ public class ComplexTool : IMcpTool { + public ToolType ToolType { get; } = ToolType.BuiltIn; + public Tool GetToolMetadata() { return new Tool diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 77e1187a20..68128acedd 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -4,11 +4,14 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.Enums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class CreateRecordTool : IMcpTool { + public ToolType ToolType { get; } = ToolType.BuiltIn; + public Tool GetToolMetadata() { return new Tool diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 2baeb1ebe8..188bc65d7f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -7,11 +7,14 @@ using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.Enums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class DescribeEntitiesTool : IMcpTool { + public ToolType ToolType { get; } = ToolType.BuiltIn; + public Tool GetToolMetadata() { return new Tool diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs index 60143a1c4a..cec1b663a7 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs @@ -4,11 +4,14 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.Enums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { public class EchoTool : IMcpTool { + public ToolType ToolType { get; } = ToolType.BuiltIn; + public Tool GetToolMetadata() { return new Tool diff --git a/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs b/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs new file mode 100644 index 0000000000..f0e15f2ab3 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Model +{ + public class Enums + { + /// + /// Specifies the type of tool. + /// + /// This enumeration defines whether a tool is a built-in tool provided by the system or + /// a custom tool defined by the user. + public enum ToolType + { + BuiltIn, + Custom + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs index 69136b3276..dfd587f7da 100644 --- a/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs @@ -3,6 +3,7 @@ using System.Text.Json; using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.Enums; namespace Azure.DataApiBuilder.Mcp.Model { @@ -11,6 +12,11 @@ namespace Azure.DataApiBuilder.Mcp.Model /// public interface IMcpTool { + /// + /// Gets the type of the tool. + /// + ToolType ToolType { get; } + /// /// Gets the tool metadata /// diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index c05b7959bd..acd7ab6646 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -1,4 +1,13 @@ { + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:35704", + "sslPort": 44353 + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "IIS Express": { "commandName": "IISExpress", @@ -67,14 +76,5 @@ }, "applicationUrl": "https://localhost:5001;http://localhost:5000" } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:35704", - "sslPort": 44353 - } } } From 540a0251ac8e8e1827fd54a31b7cd6261ed07fe9 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 11 Sep 2025 14:15:42 -0700 Subject: [PATCH 25/63] Fix test formatting errors --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs | 2 +- .../BuiltInTools/CreateRecordTool.cs | 6 +++--- .../BuiltInTools/DescribeEntitiesTool.cs | 6 +++--- src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs | 2 +- .../Core/McpEndpointRouteBuilderExtensions.cs | 6 +++--- src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs index 35762de78f..ab7635b330 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs @@ -116,7 +116,7 @@ public Task ExecuteAsync( if (root.TryGetProperty("options", out JsonElement optionsEl) && optionsEl.ValueKind == JsonValueKind.Object) { verbose = optionsEl.TryGetProperty("verbose", out JsonElement verboseEl) && verboseEl.ValueKind == JsonValueKind.True; - + if (optionsEl.TryGetProperty("timeout", out JsonElement timeoutEl) && timeoutEl.ValueKind == JsonValueKind.Number) { timeout = timeoutEl.GetInt32(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 68128acedd..da54607b1a 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -54,8 +54,8 @@ public Task ExecuteAsync( { // Extract arguments JsonElement root = arguments.RootElement; - - if (!root.TryGetProperty("entity", out JsonElement entityElement) || + + if (!root.TryGetProperty("entity", out JsonElement entityElement) || !root.TryGetProperty("data", out JsonElement dataElement)) { return Task.FromResult(new CallToolResult @@ -65,7 +65,7 @@ public Task ExecuteAsync( } string entityName = entityElement.GetString() ?? string.Empty; - + // TODO: Implement actual create logic using DAB's internal services // For now, return a placeholder response string result = $"Would create record in entity '{entityName}' with data: {dataElement.GetRawText()}"; diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 188bc65d7f..f0d561f141 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -43,7 +43,7 @@ public Task ExecuteAsync( // Extract entity information from the runtime config Dictionary entities = new(); - + if (runtimeConfig.Entities != null) { foreach (KeyValuePair entity in runtimeConfig.Entities) @@ -60,8 +60,8 @@ public Task ExecuteAsync( } } - string entitiesJson = JsonSerializer.Serialize(entities, new JsonSerializerOptions - { + string entitiesJson = JsonSerializer.Serialize(entities, new JsonSerializerOptions + { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs index cec1b663a7..d3a1b59c6f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs @@ -34,7 +34,7 @@ public Task ExecuteAsync( CancellationToken cancellationToken = default) { string? message = null; - + if (arguments?.RootElement.TryGetProperty("message", out JsonElement messageEl) == true) { message = messageEl.ValueKind == JsonValueKind.String diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs index 08ca7fc61e..5d674d0913 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs @@ -20,8 +20,8 @@ public static class McpEndpointRouteBuilderExtensions /// Maps MCP endpoints and health checks if MCP is enabled /// public static IEndpointRouteBuilder MapDabMcp( - this IEndpointRouteBuilder endpoints, - RuntimeConfigProvider runtimeConfigProvider, + this IEndpointRouteBuilder endpoints, + RuntimeConfigProvider runtimeConfigProvider, [StringSyntax("Route")] string pattern = "") { if (!TryGetMcpOptions(runtimeConfigProvider, out McpRuntimeOptions? mcpOptions) || mcpOptions == null || !mcpOptions.Enabled) @@ -50,7 +50,7 @@ public static IEndpointRouteBuilder MapDabMcp( private static bool TryGetMcpOptions(RuntimeConfigProvider runtimeConfigProvider, out McpRuntimeOptions? mcpOptions) { mcpOptions = null; - + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { return false; diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index ebee7407ab..549d50bb64 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -35,7 +35,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se } List tools = toolRegistry.GetAllTools().ToList(); - + return ValueTask.FromResult(new ListToolsResult { Tools = tools @@ -69,7 +69,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se { jsonObject[kvp.Key] = kvp.Value; } - + string json = JsonSerializer.Serialize(jsonObject); arguments = JsonDocument.Parse(json); } From 07e59452b0080072b86955b0f6b4d7c16e4c196a Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 11 Sep 2025 15:29:34 -0700 Subject: [PATCH 26/63] Fix formatting for tests part 2 --- .../Core/McpEndpointRouteBuilderExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs index 5d674d0913..ac49902ad4 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs @@ -7,7 +7,6 @@ using Azure.DataApiBuilder.Mcp.Health; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; -using ModelContextProtocol; namespace Azure.DataApiBuilder.Mcp.Core { From 00916ed3b585c80f7de0fed44bb3bed1aba863b2 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 11 Sep 2025 18:00:22 -0700 Subject: [PATCH 27/63] Fix ConfigValidationUnitTests --- src/Config/ObjectModel/RuntimeConfig.cs | 9 ++++++++ .../Configurations/RuntimeConfigValidator.cs | 20 +++++++++++----- .../UnitTests/ConfigValidationUnitTests.cs | 23 ++++++++++--------- src/Service.Tests/dab-config.MsSql.json | 2 +- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 221df9a247..3449de9df1 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -91,6 +91,15 @@ Runtime.Rest is null || Runtime.Rest.Enabled) && DataSource.DatabaseType != DatabaseType.CosmosDB_NoSQL; + /// + /// Retrieves the value of runtime.mcp.enabled property if present, default is true. + /// + [JsonIgnore] + public bool IsMcpEnabled => + Runtime is null || + Runtime.Mcp is null || + Runtime.Mcp.Enabled; + [JsonIgnore] public bool IsHealthEnabled => Runtime is null || diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index bfd0425975..2e4994debe 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -703,10 +703,10 @@ private void ValidateNameRequirements(string entityName) public void ValidateGlobalEndpointRouteConfig(RuntimeConfig runtimeConfig) { // Both REST and GraphQL endpoints cannot be disabled at the same time. - if (!runtimeConfig.IsRestEnabled && !runtimeConfig.IsGraphQLEnabled) + if (!runtimeConfig.IsRestEnabled && !runtimeConfig.IsGraphQLEnabled && !runtimeConfig.IsMcpEnabled) { HandleOrRecordException(new DataApiBuilderException( - message: $"Both GraphQL and REST endpoints are disabled.", + message: $"GraphQL, REST, and MCP endpoints are disabled.", statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); } @@ -742,12 +742,20 @@ public void ValidateGlobalEndpointRouteConfig(RuntimeConfig runtimeConfig) } if (string.Equals( - a: runtimeConfig.RestPath, - b: runtimeConfig.GraphQLPath, - comparisonType: StringComparison.OrdinalIgnoreCase)) + a: runtimeConfig.RestPath, + b: runtimeConfig.GraphQLPath, + comparisonType: StringComparison.OrdinalIgnoreCase) || + string.Equals( + a: runtimeConfig.RestPath, + b: runtimeConfig.McpPath, + comparisonType: StringComparison.OrdinalIgnoreCase) || + string.Equals( + a: runtimeConfig.McpPath, + b: runtimeConfig.GraphQLPath, + comparisonType: StringComparison.OrdinalIgnoreCase)) { HandleOrRecordException(new DataApiBuilderException( - message: $"Conflicting GraphQL and REST path configuration.", + message: $"Conflicting path configuration between GraphQL, REST, and MCP.", statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); } diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs index e70d18b1d6..d51bd209a1 100644 --- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs @@ -1470,7 +1470,7 @@ public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restC graphQL, rest, mcp); - string expectedErrorMessage = "Conflicting GraphQL and REST path configuration."; + string expectedErrorMessage = "Conflicting path configuration between GraphQL, REST, and MCP."; try { @@ -1733,17 +1733,18 @@ public void ValidateApiURIsAreWellFormed( /// /// Boolean flag to indicate if REST endpoints are enabled globally. /// Boolean flag to indicate if GraphQL endpoints are enabled globally. + /// Boolean flag to indicate if MCP endpoints are enabled globally. /// Boolean flag to indicate if exception is expected. - [DataRow(true, true, false, DisplayName = "Both REST and GraphQL enabled.")] - [DataRow(true, false, false, DisplayName = "REST enabled, and GraphQL disabled.")] - [DataRow(false, true, false, DisplayName = "REST disabled, and GraphQL enabled.")] - [DataRow(false, false, true, DisplayName = "Both REST and GraphQL are disabled.")] - [DataRow(true, true, true, false, DisplayName = "Both REST and GraphQL enabled, MCP enabled.")] - [DataRow(true, false, true, false, DisplayName = "REST enabled, and GraphQL disabled, MCP enabled.")] - [DataRow(false, true, true, false, DisplayName = "REST disabled, and GraphQL enabled, MCP enabled.")] - [DataRow(false, false, true, false, DisplayName = "Both REST and GraphQL are disabled, but MCP enabled.")] + [DataRow(true, true, true, false, DisplayName = "REST, GraphQL and MCP are enabled.")] + [DataRow(true, false, false, false, DisplayName = "REST enabled, GraphQL and MCP disabled.")] + [DataRow(true, true, false, false, DisplayName = "REST and GraphQL enabled, MCP disabled.")] + [DataRow(false, true, false, false, DisplayName = "REST disabled, GraphQL and MCP enabled.")] + [DataRow(false, false, true, false, DisplayName = "REST and GraphQL disabled, MCP enabled.")] + [DataRow(true, false, true, false, DisplayName = "REST enabled, GraphQL disabled, MCP enabled.")] + [DataRow(false, true, true, false, DisplayName = "REST disabled, GraphQL and MCP enabled.")] + [DataRow(false, false, false, true, DisplayName = "REST, GraphQL, and MCP are disabled.")] [DataTestMethod] - public void EnsureFailureWhenBothRestAndGraphQLAreDisabled( + public void EnsureFailureWhenRestAndGraphQLAndMcpAreDisabled( bool restEnabled, bool graphqlEnabled, bool mcpEnabled, @@ -1758,7 +1759,7 @@ public void EnsureFailureWhenBothRestAndGraphQLAreDisabled( graphQL, rest, mcp); - string expectedErrorMessage = "Both GraphQL and REST endpoints are disabled."; + string expectedErrorMessage = "GraphQL, REST, and MCP endpoints are disabled."; try { diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 347461062b..9648d2da6f 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", + "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", "options": { "set-session-context": true } From ae99b9a4a1b268797f3cd251e117eafed1ae2d41 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Tue, 16 Sep 2025 17:49:05 -0700 Subject: [PATCH 28/63] Fix RuntimeConfigValidator tests --- src/Config/ObjectModel/RuntimeConfig.cs | 4 ++-- .../Configurations/RuntimeConfigValidator.cs | 8 +++++--- .../UnitTests/ConfigValidationUnitTests.cs | 18 +++++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 3449de9df1..6cc6f156ed 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -97,8 +97,8 @@ Runtime.Rest is null || [JsonIgnore] public bool IsMcpEnabled => Runtime is null || - Runtime.Mcp is null || - Runtime.Mcp.Enabled; + Runtime.Mcp is null || + Runtime.Mcp.Enabled; [JsonIgnore] public bool IsHealthEnabled => diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 2e4994debe..82fa7fa0ff 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -702,7 +702,7 @@ private void ValidateNameRequirements(string entityName) /// The config that will be validated. public void ValidateGlobalEndpointRouteConfig(RuntimeConfig runtimeConfig) { - // Both REST and GraphQL endpoints cannot be disabled at the same time. + // REST, GraphQL and MCP endpoints cannot be disabled at the same time. if (!runtimeConfig.IsRestEnabled && !runtimeConfig.IsGraphQLEnabled && !runtimeConfig.IsMcpEnabled) { HandleOrRecordException(new DataApiBuilderException( @@ -735,8 +735,10 @@ public void ValidateGlobalEndpointRouteConfig(RuntimeConfig runtimeConfig) ValidateRestURI(runtimeConfig); ValidateGraphQLURI(runtimeConfig); - // Do not check for conflicts if GraphQL or REST endpoints are disabled. - if (!runtimeConfig.IsRestEnabled || !runtimeConfig.IsGraphQLEnabled) + // Do not check for conflicts if two of the endpoints are disabled between GraphQL, REST, and MCP. + if ((!runtimeConfig.IsRestEnabled && !runtimeConfig.IsGraphQLEnabled) || + (!runtimeConfig.IsRestEnabled && !runtimeConfig.IsMcpEnabled) || + (!runtimeConfig.IsGraphQLEnabled && !runtimeConfig.IsMcpEnabled)) { return; } diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs index d51bd209a1..6cea36f2ff 100644 --- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs @@ -1452,9 +1452,9 @@ public void ValidateValidEntityDefinitionsDoesNotGenerateDuplicateQueries(Databa /// MCP global path /// Exception expected [DataTestMethod] - [DataRow("/graphql", "/graphql", "/mcp", true, DisplayName = "GraphQL and REST conflict (same path), MCP different.")] - [DataRow("/api", "/api", "/mcp", true, DisplayName = "REST and GraphQL conflict (same path), MCP different.")] - [DataRow("/graphql", "/api", "/mcp", false, DisplayName = "GraphQL and REST distinct, MCP different.")] + [DataRow("/graphql", "/graphql", "/mcp", true, DisplayName = "GraphQL and REST conflict (same path).")] + [DataRow("/api", "/api", "/mcp", true, DisplayName = "REST and GraphQL conflict (same path).")] + [DataRow("/graphql", "/api", "/mcp", false, DisplayName = "GraphQL, REST, and MCP distinct.")] // Extra case: conflict with MCP [DataRow("/mcp", "/api", "/mcp", true, DisplayName = "MCP and GraphQL conflict (same path).")] [DataRow("/graphql", "/mcp", "/mcp", true, DisplayName = "MCP and REST conflict (same path).")] @@ -1735,14 +1735,14 @@ public void ValidateApiURIsAreWellFormed( /// Boolean flag to indicate if GraphQL endpoints are enabled globally. /// Boolean flag to indicate if MCP endpoints are enabled globally. /// Boolean flag to indicate if exception is expected. - [DataRow(true, true, true, false, DisplayName = "REST, GraphQL and MCP are enabled.")] - [DataRow(true, false, false, false, DisplayName = "REST enabled, GraphQL and MCP disabled.")] + [DataRow(true, true, true, false, DisplayName = "REST, GraphQL, and MCP enabled.")] [DataRow(true, true, false, false, DisplayName = "REST and GraphQL enabled, MCP disabled.")] - [DataRow(false, true, false, false, DisplayName = "REST disabled, GraphQL and MCP enabled.")] - [DataRow(false, false, true, false, DisplayName = "REST and GraphQL disabled, MCP enabled.")] - [DataRow(true, false, true, false, DisplayName = "REST enabled, GraphQL disabled, MCP enabled.")] + [DataRow(true, false, true, false, DisplayName = "REST enabled, GraphQL disabled, and MCP enabled.")] + [DataRow(true, false, false, false, DisplayName = "REST enabled, GraphQL and MCP disabled.")] [DataRow(false, true, true, false, DisplayName = "REST disabled, GraphQL and MCP enabled.")] - [DataRow(false, false, false, true, DisplayName = "REST, GraphQL, and MCP are disabled.")] + [DataRow(false, true, false, false, DisplayName = "REST disabled, GraphQL enabled, and MCP disabled.")] + [DataRow(false, false, true, false, DisplayName = "REST and GraphQL disabled, MCP enabled.")] + [DataRow(false, false, false, true, DisplayName = "REST, GraphQL, and MCP disabled.")] [DataTestMethod] public void EnsureFailureWhenRestAndGraphQLAndMcpAreDisabled( bool restEnabled, From be772305a07018415ea88ac35beca291d193c7e9 Mon Sep 17 00:00:00 2001 From: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Date: Fri, 19 Sep 2025 06:29:01 +0000 Subject: [PATCH 29/63] Add MCP Runtime serialization/deserialization and fix tests related to it (#2881) ## Still missing This PR is still missing some test changes as well as some changes to the `McpRuntimeOptions` file to properly serialize/deserialize the properties found inside `mcp`. ## Why make this change? - Tests related to the serialization and deserialization of the MCP properties were failing or missing. ## What is this change? - DAB is now able to serialize and deserialize the MCP properties, this is done through the addition of the following files: - `McpRuntimeOptionsConverterFactory` reads and writes the properties that are found inside of the `mcp` property. - `McpDmlToolsOptionsConverter` reads and writes the properties that are found inside the `dml-tools` property. - Fixed the `McpRuntimeOptions`, `RuntimeConfig`, and `RuntimeOptions` files to allow for proper serialization and deserialization and reflect the changes related to the new `mcp` property. - Separated `McpDmlToolsOptions` class from the `McpRuntimeOptions` file where it originally resided. - Fixed tests that were failing or missing changes for the new `mcp` property: - `ModuleInitializer` for both CLI and Service tests now ignore `IsMcpEnabled` property which is only used inside of DAB and shouldn't be written inside the config file. - `RuntimeConfigLoaderJsonDeserializerTests` changed tests to include `mcp` property. ## How was this tested? - [ ] Integration Tests - [X] Unit Tests --- src/Cli.Tests/ModuleInitializer.cs | 6 + .../Converters/McpDmlToolsOptionsConverter.cs | 153 +++++++++++++++++ .../McpRuntimeOptionsConverterFactory.cs | 139 ++++++++++++++++ src/Config/ObjectModel/McpDmlToolsOptions.cs | 154 ++++++++++++++++++ src/Config/ObjectModel/McpRuntimeOptions.cs | 73 ++++++--- src/Config/ObjectModel/RuntimeConfig.cs | 19 --- src/Config/ObjectModel/RuntimeOptions.cs | 6 + src/Config/RuntimeConfigLoader.cs | 1 + src/Service.Tests/ModuleInitializer.cs | 8 + ...untimeConfigLoaderJsonDeserializerTests.cs | 12 +- 10 files changed, 524 insertions(+), 47 deletions(-) create mode 100644 src/Config/Converters/McpDmlToolsOptionsConverter.cs create mode 100644 src/Config/Converters/McpRuntimeOptionsConverterFactory.cs create mode 100644 src/Config/ObjectModel/McpDmlToolsOptions.cs diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 91b938bcfc..295757dc36 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -47,6 +47,10 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsGraphQLEnabled); // Ignore the entity IsGraphQLEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsGraphQLEnabled); + // Ignore the global IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); + // Ignore the global RuntimeOptions.IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(options => options.IsMcpEnabled); // Ignore the global IsHealthEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsHealthEnabled); // Ignore the global RuntimeOptions.IsHealthCheckEnabled as that's unimportant from a test standpoint. @@ -67,6 +71,8 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.IsGraphQLEnabled); // Ignore the IsRestEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRestEnabled); + // Ignore the IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. diff --git a/src/Config/Converters/McpDmlToolsOptionsConverter.cs b/src/Config/Converters/McpDmlToolsOptionsConverter.cs new file mode 100644 index 0000000000..efe9dc13f0 --- /dev/null +++ b/src/Config/Converters/McpDmlToolsOptionsConverter.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +internal class McpDmlToolsOptionsConverter : JsonConverter +{ + /// + /// Defines how DAB reads MCP DML Tools options and defines which values are + /// used to instantiate McpDmlToolsOptions. + /// + /// Thrown when improperly formatted MCP DML Tools options are provided. + public override McpDmlToolsOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.True) + { + return new McpDmlToolsOptions(true, true, true, true, true, true); + } + + if (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.Null) + { + return new McpDmlToolsOptions(); + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + bool? describeEntities = null; + bool? createRecord = null; + bool? readRecord = null; + bool? updateRecord = null; + bool? deleteRecord = null; + bool? executeRecord = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new McpDmlToolsOptions(describeEntities, createRecord, readRecord, updateRecord, deleteRecord, executeRecord); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "describe-entities": + if (reader.TokenType is not JsonTokenType.Null) + { + describeEntities = reader.GetBoolean(); + } + + break; + + case "create-record": + if (reader.TokenType is not JsonTokenType.Null) + { + createRecord = reader.GetBoolean(); + } + + break; + + case "read-record": + if (reader.TokenType is not JsonTokenType.Null) + { + readRecord = reader.GetBoolean(); + } + + break; + + case "update-record": + if (reader.TokenType is not JsonTokenType.Null) + { + updateRecord = reader.GetBoolean(); + } + + break; + + case "delete-record": + if (reader.TokenType is not JsonTokenType.Null) + { + deleteRecord = reader.GetBoolean(); + } + + break; + + case "execute-record": + if (reader.TokenType is not JsonTokenType.Null) + { + executeRecord = reader.GetBoolean(); + } + + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the MCP DML Tools Options"); + } + + /// + /// When writing the McpDmlToolsOptions back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, McpDmlToolsOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedDescribeEntities is true) + { + writer.WritePropertyName("describe-entities"); + JsonSerializer.Serialize(writer, value.DescribeEntities, options); + } + + if (value?.UserProvidedCreateRecord is true) + { + writer.WritePropertyName("create-record"); + JsonSerializer.Serialize(writer, value.CreateRecord, options); + } + + if (value?.UserProvidedReadRecord is true) + { + writer.WritePropertyName("read-record"); + JsonSerializer.Serialize(writer, value.ReadRecord, options); + } + + if (value?.UserProvidedUpdateRecord is true) + { + writer.WritePropertyName("update-record"); + JsonSerializer.Serialize(writer, value.UpdateRecord, options); + } + + if (value?.UserProvidedDeleteRecord is true) + { + writer.WritePropertyName("delete-record"); + JsonSerializer.Serialize(writer, value.DeleteRecord, options); + } + + if (value?.UserProvidedExecuteRecord is true) + { + writer.WritePropertyName("execute-record"); + JsonSerializer.Serialize(writer, value.ExecuteRecord, options); + } + } +} diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs new file mode 100644 index 0000000000..6001be920d --- /dev/null +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +internal class McpRuntimeOptionsConverterFactory : JsonConverterFactory +{ + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(McpRuntimeOptions)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new McpRuntimeOptionsConverter(_replaceEnvVar); + } + + internal McpRuntimeOptionsConverterFactory(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + + private class McpRuntimeOptionsConverter : JsonConverter + { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// Whether to replace environment variable with its + /// value or not while deserializing. + internal McpRuntimeOptionsConverter(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + + /// + /// Defines how DAB reads MCP options and defines which values are + /// used to instantiate McpRuntimeOptions. + /// + /// Thrown when improperly formatted MCP options are provided. + public override McpRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.Null) + { + return new McpRuntimeOptions(); + } + + if (reader.TokenType == JsonTokenType.False) + { + return new McpRuntimeOptions(Enabled: false); + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + McpDmlToolsOptionsConverter dmlToolsOptionsConverter = new(); + + bool enabled = true; + string? path = null; + McpDmlToolsOptions? dmlTools = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new McpRuntimeOptions(enabled, path, dmlTools); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "enabled": + if (reader.TokenType is not JsonTokenType.Null) + { + enabled = reader.GetBoolean(); + } + + break; + + case "path": + if (reader.TokenType is not JsonTokenType.Null) + { + path = reader.DeserializeString(_replaceEnvVar); + } + + break; + + case "dml-tools": + dmlTools = dmlToolsOptionsConverter.Read(ref reader, typeToConvert, options); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the MCP Options"); + } + + /// + /// When writing the McpRuntimeOptions back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, McpRuntimeOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteBoolean("enabled", value.Enabled); + + if (value?.UserProvidedPath is true) + { + writer.WritePropertyName("path"); + JsonSerializer.Serialize(writer, value.Path, options); + } + + if (value?.DmlTools is not null) + { + McpDmlToolsOptionsConverter dmlToolsOptionsConverter = options.GetConverter(typeof(McpDmlToolsOptions)) as McpDmlToolsOptionsConverter ?? + throw new JsonException("Failed to get mcp.dml-tools options converter"); + + writer.WritePropertyName("dml-tools"); + dmlToolsOptionsConverter.Write(writer, value.DmlTools, options); + } + } + } +} diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs new file mode 100644 index 0000000000..57bb02f16c --- /dev/null +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// DML Tools found in global MCP configuration. +/// +public record McpDmlToolsOptions +{ + public bool DescribeEntities { get; init; } + + public bool CreateRecord { get; init; } + + public bool ReadRecord { get; init; } + + public bool UpdateRecord { get; init; } + + public bool DeleteRecord { get; init; } + + public bool ExecuteRecord { get; init; } + + public McpDmlToolsOptions( + bool? DescribeEntities = null, + bool? CreateRecord = null, + bool? ReadRecord = null, + bool? UpdateRecord = null, + bool? DeleteRecord = null, + bool? ExecuteRecord = null) + { + if (DescribeEntities is not null) + { + this.DescribeEntities = (bool)DescribeEntities; + UserProvidedDescribeEntities = true; + } + else + { + this.DescribeEntities = false; + } + + if (CreateRecord is not null) + { + this.CreateRecord = (bool)CreateRecord; + UserProvidedCreateRecord = true; + } + else + { + this.CreateRecord = false; + } + + if (ReadRecord is not null) + { + this.ReadRecord = (bool)ReadRecord; + UserProvidedReadRecord = true; + } + else + { + this.ReadRecord = false; + } + + if (UpdateRecord is not null) + { + this.UpdateRecord = (bool)UpdateRecord; + UserProvidedUpdateRecord = true; + } + else + { + this.UpdateRecord = false; + } + + if (DeleteRecord is not null) + { + this.DeleteRecord = (bool)DeleteRecord; + UserProvidedDeleteRecord = true; + } + else + { + this.DeleteRecord = false; + } + + if (ExecuteRecord is not null) + { + this.ExecuteRecord = (bool)ExecuteRecord; + UserProvidedExecuteRecord = true; + } + else + { + this.ExecuteRecord = false; + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write describe-entities + /// property and value to the runtime config file. + /// When user doesn't provide the describe-entities property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(DescribeEntities))] + public bool UserProvidedDescribeEntities { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write create-record + /// property and value to the runtime config file. + /// When user doesn't provide the create-record property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(CreateRecord))] + public bool UserProvidedCreateRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write read-record + /// property and value to the runtime config file. + /// When user doesn't provide the read-record property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(ReadRecord))] + public bool UserProvidedReadRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write update-record + /// property and value to the runtime config file. + /// When user doesn't provide the update-record property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(UpdateRecord))] + public bool UserProvidedUpdateRecord { get; init; } = false; + /// + /// Flag which informs CLI and JSON serializer whether to write delete-record + /// property and value to the runtime config file. + /// When user doesn't provide the delete-record property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(DeleteRecord))] + public bool UserProvidedDeleteRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write execute-record + /// property and value to the runtime config file. + /// When user doesn't provide the execute-record property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(ExecuteRecord))] + public bool UserProvidedExecuteRecord { get; init; } = false; +} + diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 682cc8d9cd..ca9b7a3cf0 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -10,46 +11,64 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// public record McpRuntimeOptions { - public McpRuntimeOptions( - bool Enabled = false, - string? Path = null, - McpDmlToolsOptions? DmlTools = null) - { - this.Enabled = Enabled; - this.Path = Path ?? DEFAULT_PATH; - this.DmlTools = DmlTools ?? new McpDmlToolsOptions(); - } - + /// + /// Default path for MCP endpoint. + /// public const string DEFAULT_PATH = "/mcp"; + /// + /// Whether MCP endpoints is enabled. + /// public bool Enabled { get; init; } + /// + /// Path used to access MCP endpoint. + /// public string Path { get; init; } + /// + /// DML Tools that are enabled for MCP to access. + /// [JsonPropertyName("dml-tools")] public McpDmlToolsOptions DmlTools { get; init; } -} - -public record McpDmlToolsOptions -{ - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool Enabled { get; init; } = false; - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool DescribeEntities { get; init; } = false; - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool CreateRecord { get; init; } = false; + public McpRuntimeOptions( + bool Enabled = true, + string? Path = null, + McpDmlToolsOptions? DmlTools = null) + { + this.Enabled = Enabled; - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool ReadRecord { get; init; } = false; + if (Path is not null) + { + this.Path = Path; + UserProvidedPath = true; + } + else + { + this.Path = DEFAULT_PATH; + } - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UpdateRecord { get; init; } = false; + this.DmlTools = DmlTools ?? new McpDmlToolsOptions(); + } + /// + /// Flag which informs CLI and JSON serializer whether to write enabled + /// property and value to the runtime config file. + /// When user doesn't provide the enabled property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool DeleteRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(Enabled))] + public bool UserProvidedEnabled { get; init; } = false; + /// + /// Flag which informs CLI and JSON serializer whether to write path + /// property and value to the runtime config file. + /// When user doesn't provide the path property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool ExecuteRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(Enabled))] + public bool UserProvidedPath { get; init; } = false; } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 6cc6f156ed..57ee8aa628 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,27 +11,8 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record AiOptions -{ - public McpOptions? Mcp { get; init; } = new(); -} - -public record McpOptions -{ - public bool Enabled { get; init; } = true; - public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; -} - -public enum McpDmlTool -{ - DescribeEntities -} - public record RuntimeConfig { - public AiOptions? Ai { get; init; } = new(); - [JsonPropertyName("$schema")] public string Schema { get; init; } diff --git a/src/Config/ObjectModel/RuntimeOptions.cs b/src/Config/ObjectModel/RuntimeOptions.cs index b09145f964..6f6c046651 100644 --- a/src/Config/ObjectModel/RuntimeOptions.cs +++ b/src/Config/ObjectModel/RuntimeOptions.cs @@ -63,6 +63,12 @@ GraphQL is null || GraphQL?.Enabled is null || GraphQL?.Enabled is true; + [JsonIgnore] + public bool IsMcpEnabled => + Mcp is null || + Mcp?.Enabled is null || + Mcp?.Enabled is true; + [JsonIgnore] public bool IsHealthCheckEnabled => Health is null || diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 4a220af0ea..14ab2b25f2 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -246,6 +246,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityHealthOptionsConvertorFactory()); options.Converters.Add(new RestRuntimeOptionsConverterFactory()); options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new McpRuntimeOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntitySourceConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index b099508604..c387fe37eb 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -51,6 +51,10 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsGraphQLEnabled); // Ignore the entity IsGraphQLEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsGraphQLEnabled); + // Ignore the global IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); + // Ignore the global RuntimeOptions.IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(options => options.IsMcpEnabled); // Ignore the global IsHealthEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsHealthEnabled); // Ignore the global RuntimeOptions.IsHealthCheckEnabled as that's unimportant from a test standpoint. @@ -73,12 +77,16 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.IsGraphQLEnabled); // Ignore the IsRestEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRestEnabled); + // Ignore the IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.RestPath); // Ignore the GraphQLPath as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.GraphQLPath); + // Ignore the McpPath as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.McpPath); // Ignore the AllowIntrospection as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.AllowIntrospection); // Ignore the EnableAggregation as that's unimportant from a test standpoint. diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index 8d7dae0541..b98de993e2 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -259,7 +259,7 @@ public void TestNullableOptionalProps() TryParseAndAssertOnDefaults("{" + emptyRuntime, out _); // Test with empty sub properties of runtime - minJson.Append(@"{ ""rest"": { }, ""graphql"": { }, + minJson.Append(@"{ ""rest"": { }, ""graphql"": { }, ""mcp"": { }, ""base-route"" : """","); StringBuilder minJsonWithHostSubProps = new(minJson + @"""telemetry"" : { }, ""host"" : "); StringBuilder minJsonWithTelemetrySubProps = new(minJson + @"""host"" : { }, ""telemetry"" : "); @@ -423,6 +423,10 @@ public static string GetModifiedJsonString(string[] reps, string enumString) } } }, + ""mcp"": { + ""enabled"": true, + ""path"": """ + reps[++index % reps.Length] + @""" + }, ""host"": { ""mode"": ""development"", ""cors"": { @@ -506,6 +510,10 @@ public static string GetModifiedJsonString(string[] reps, string enumString) ""enabled"": true, ""path"": ""/graphql"" }, + ""mcp"": { + ""enabled"": true, + ""path"": ""/mcp"" + }, ""host"": { ""mode"": ""development"", ""cors"": { @@ -641,6 +649,8 @@ private static bool TryParseAndAssertOnDefaults(string json, out RuntimeConfig p Assert.AreEqual(RestRuntimeOptions.DEFAULT_PATH, parsedConfig.RestPath); Assert.IsTrue(parsedConfig.IsGraphQLEnabled); Assert.AreEqual(GraphQLRuntimeOptions.DEFAULT_PATH, parsedConfig.GraphQLPath); + Assert.IsTrue(parsedConfig.IsMcpEnabled); + Assert.AreEqual(McpRuntimeOptions.DEFAULT_PATH, parsedConfig.McpPath); Assert.IsTrue(parsedConfig.AllowIntrospection); Assert.IsFalse(parsedConfig.IsDevelopmentMode()); Assert.IsTrue(parsedConfig.IsStaticWebAppsIdentityProvider); From 9a70ac3fefda12cf613575252b82aede11bd39a9 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 19 Sep 2025 12:05:45 +0530 Subject: [PATCH 30/63] Fix MCP runtime config --- schemas/dab.draft.schema.json | 102 ++++----- src/Cli/ConfigGenerator.cs | 205 +++++++++--------- .../Converters/McpOptionsConverterFactory.cs | 169 +++++++++++++++ src/Config/ObjectModel/McpRuntimeOptions.cs | 89 +++++--- src/Config/ObjectModel/RuntimeConfig.cs | 38 ++-- src/McpRuntimeConfig.cs | 0 src/Service.Tests/dab-config.MsSql.json | 27 +-- src/dab.draft.schema.json | 0 8 files changed, 411 insertions(+), 219 deletions(-) create mode 100644 src/Config/Converters/McpOptionsConverterFactory.cs create mode 100644 src/McpRuntimeConfig.cs create mode 100644 src/dab.draft.schema.json diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index ada4372d81..938b5c2c6e 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -158,13 +158,13 @@ "type": "object", "properties": { "max-page-size": { - "type": ["integer", "null"], + "type": [ "integer", "null" ], "description": "Defines the maximum number of records that can be returned in a single page of results. If set to null, the default value is 100,000.", "default": 100000, "minimum": 1 }, "default-page-size": { - "type": ["integer", "null"], + "type": [ "integer", "null" ], "description": "Sets the default number of records returned in a single response. When this limit is reached, a continuation token is provided to retrieve the next page. If set to null, the default value is 100.", "default": 100, "minimum": 1 @@ -254,45 +254,49 @@ "default": false }, "dml-tools": { - "type": "object", - "description": "Global DML tools configuration", - "properties": { - "enabled": { - "type": "boolean", - "description": "Allow enabling/disabling DML tools for all entities.", - "default": false - }, - "describe-entities": { - "type": "boolean", - "description": "Allow enabling/disabling the describe-entities tool.", - "default": false - }, - "create-record": { - "type": "boolean", - "description": "Allow enabling/disabling the create-record tool.", - "default": false - }, - "read-record": { - "type": "boolean", - "description": "Allow enabling/disabling the read-record tool.", - "default": false - }, - "update-record": { - "type": "boolean", - "description": "Allow enabling/disabling the update-record tool.", - "default": false - }, - "delete-record": { + "oneOf": [ + { "type": "boolean", - "description": "Allow enabling/disabling the delete-record tool.", - "default": false + "description": "Enable/disable all DML tools with default settings." }, - "execute-record": { - "type": "boolean", - "description": "Allow enabling/disabling the execute-record tool.", - "default": false + { + "type": "object", + "description": "Individual DML tools configuration", + "additionalProperties": false, + "properties": { + "describe-entities": { + "type": "boolean", + "description": "Enable/disable the describe-entities tool.", + "default": false + }, + "create-record": { + "type": "boolean", + "description": "Enable/disable the create-record tool.", + "default": false + }, + "read-record": { + "type": "boolean", + "description": "Enable/disable the read-record tool.", + "default": false + }, + "update-record": { + "type": "boolean", + "description": "Enable/disable the update-record tool.", + "default": false + }, + "delete-record": { + "type": "boolean", + "description": "Enable/disable the delete-record tool.", + "default": false + }, + "execute-record": { + "type": "boolean", + "description": "Enable/disable the execute-record tool.", + "default": false + } + } } - } + ] } } }, @@ -302,7 +306,7 @@ "additionalProperties": false, "properties": { "max-response-size-mb": { - "type": ["integer", "null"], + "type": [ "integer", "null" ], "description": "Specifies the maximum size, in megabytes, of the database response allowed in a single result. If set to null, the default value is 158 MB.", "default": 158, "minimum": 1, @@ -310,12 +314,12 @@ }, "mode": { "description": "Set if running in Development or Production mode", - "type": ["string", "null"], + "type": [ "string", "null" ], "default": "production", - "enum": ["production", "development"] + "enum": [ "production", "development" ] }, "cors": { - "type": ["object", "null"], + "type": [ "object", "null" ], "description": "Configure CORS", "additionalProperties": false, "properties": { @@ -335,7 +339,7 @@ } }, "authentication": { - "type": ["object", "null"], + "type": [ "object", "null" ], "additionalProperties": false, "properties": { "provider": { @@ -379,7 +383,7 @@ "type": "string" } }, - "required": ["audience", "issuer"] + "required": [ "audience", "issuer" ] } }, "allOf": [ @@ -395,9 +399,9 @@ ] } }, - "required": ["provider"] + "required": [ "provider" ] }, - "then": { "required": ["jwt"] }, + "then": { "required": [ "jwt" ] }, "else": { "properties": { "jwt": false } } } ] @@ -439,7 +443,7 @@ "default": true } }, - "required": ["connection-string"] + "required": [ "connection-string" ] }, "open-telemetry": { "type": "object", @@ -462,7 +466,7 @@ "type": "string", "description": "Open Telemetry protocol", "default": "grpc", - "enum": ["grpc", "httpprotobuf"] + "enum": [ "grpc", "httpprotobuf" ] }, "enabled": { "type": "boolean", @@ -470,7 +474,7 @@ "default": true } }, - "required": ["endpoint"] + "required": [ "endpoint" ] }, "azure-log-analytics": { "type": "object", diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 772a384542..9f9b79bf86 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -972,117 +972,120 @@ private static bool TryUpdateConfiguredGraphQLValues( /// updatedMcpOptions /// True if the value needs to be updated in the runtime config, else false private static bool TryUpdateConfiguredMcpValues( - ConfigureOptions options, - ref McpRuntimeOptions? updatedMcpOptions) + ConfigureOptions options, + ref McpRuntimeOptions? updatedMcpOptions) +{ + object? updatedValue; + + try + { + // Runtime.Mcp.Enabled + updatedValue = options?.RuntimeMcpEnabled; + if (updatedValue != null) { - object? updatedValue; + updatedMcpOptions = updatedMcpOptions! with { Enabled = (bool)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Enabled as '{updatedValue}'", updatedValue); + } - try + // Runtime.Mcp.Path + updatedValue = options?.RuntimeMcpPath; + if (updatedValue != null) + { + bool status = RuntimeConfigValidatorUtil.TryValidateUriComponent(uriComponent: (string)updatedValue, out string exceptionMessage); + if (status) { - // Runtime.Mcp.Enabled - updatedValue = options?.RuntimeMcpEnabled; - if (updatedValue != null) - { - updatedMcpOptions = updatedMcpOptions! with { Enabled = (bool)updatedValue }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Enabled as '{updatedValue}'", updatedValue); - } + updatedMcpOptions = updatedMcpOptions! with { Path = (string)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Path as '{updatedValue}'", updatedValue); + } + else + { + _logger.LogError("Failed to update Runtime.Mcp.Path as '{updatedValue}' due to exception message: {exceptionMessage}", updatedValue, exceptionMessage); + return false; + } + } - // Runtime.Mcp.Path - updatedValue = options?.RuntimeMcpPath; - if (updatedValue != null) - { - bool status = RuntimeConfigValidatorUtil.TryValidateUriComponent(uriComponent: (string)updatedValue, out string exceptionMessage); - if (status) - { - updatedMcpOptions = updatedMcpOptions! with { Path = (string)updatedValue }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Path as '{updatedValue}'", updatedValue); - } - else - { - _logger.LogError("Failed to update Runtime.Mcp.Path as '{updatedValue}' due to exception message: {exceptionMessage}", updatedValue, exceptionMessage); - return false; - } - } + // Runtime.Mcp.Dml-Tools (as boolean) + updatedValue = options?.RuntimeMcpDmlToolsEnabled; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { DmlTools = DmlToolsConfig.FromBoolean((bool)updatedValue) }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools as '{updatedValue}'", updatedValue); + } - // Runtime.Mcp.Dml-Tools.Enabled - updatedValue = options?.RuntimeMcpDmlToolsEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { Enabled = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Enabled as '{updatedValue}'", updatedValue); - } + // Individual tool configurations + DmlToolsConfig? currentConfig = updatedMcpOptions?.DmlTools; + bool hasToolUpdates = false; - // Runtime.Mcp.Dml-Tools.Describe-Entities - updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { DescribeEntities = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); - } + // Build new config with individual tool settings + bool? describeEntities = currentConfig?.DescribeEntities; + bool? createRecord = currentConfig?.CreateRecord; + bool? readRecord = currentConfig?.ReadRecord; + bool? updateRecord = currentConfig?.UpdateRecord; + bool? deleteRecord = currentConfig?.DeleteRecord; + bool? executeRecord = currentConfig?.ExecuteRecord; - // Runtime.Mcp.Dml-Tools.Create-Record - updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { CreateRecord = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); - } + if (options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled != null) + { + describeEntities = (bool)options.RuntimeMcpDmlToolsDescribeEntitiesEnabled; + hasToolUpdates = true; + } - // Runtime.Mcp.Dml-Tools.Read-Record - updatedValue = options?.RuntimeMcpDmlToolsReadRecordEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { ReadRecord = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Read-Record as '{updatedValue}'", updatedValue); - } + if (options?.RuntimeMcpDmlToolsCreateRecordEnabled != null) + { + createRecord = (bool)options.RuntimeMcpDmlToolsCreateRecordEnabled; + hasToolUpdates = true; + } - // Runtime.Mcp.Dml-Tools.Update-Record - updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { UpdateRecord = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); - } + if (options?.RuntimeMcpDmlToolsReadRecordEnabled != null) + { + readRecord = (bool)options.RuntimeMcpDmlToolsReadRecordEnabled; + hasToolUpdates = true; + } - // Runtime.Mcp.Dml-Tools.Delete-Record - updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { DeleteRecord = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); - } + if (options?.RuntimeMcpDmlToolsUpdateRecordEnabled != null) + { + updateRecord = (bool)options.RuntimeMcpDmlToolsUpdateRecordEnabled; + hasToolUpdates = true; + } - // Runtime.Mcp.Dml-Tools.Execute-Record - updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; - if (updatedValue != null) - { - McpDmlToolsOptions dmlToolsOptions = updatedMcpOptions?.DmlTools ?? new(); - dmlToolsOptions = dmlToolsOptions with { ExecuteRecord = (bool)updatedValue }; - updatedMcpOptions = updatedMcpOptions! with { DmlTools = dmlToolsOptions }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); - } + if (options?.RuntimeMcpDmlToolsDeleteRecordEnabled != null) + { + deleteRecord = (bool)options.RuntimeMcpDmlToolsDeleteRecordEnabled; + hasToolUpdates = true; + } - return true; - } - catch (Exception ex) - { - _logger.LogError("Failed to update RuntimeConfig.Mcp with exception message: {exceptionMessage}.", ex.Message); - return false; - } + if (options?.RuntimeMcpDmlToolsExecuteRecordEnabled != null) + { + executeRecord = (bool)options.RuntimeMcpDmlToolsExecuteRecordEnabled; + hasToolUpdates = true; + } + + if (hasToolUpdates) + { + updatedMcpOptions = updatedMcpOptions! with + { + DmlTools = new DmlToolsConfig + { + AllToolsEnabled = false, + DescribeEntities = describeEntities, + CreateRecord = createRecord, + ReadRecord = readRecord, + UpdateRecord = updateRecord, + DeleteRecord = deleteRecord, + ExecuteRecord = executeRecord + } + }; } + return true; + } + catch (Exception ex) + { + _logger.LogError("Failed to update RuntimeConfig.Mcp with exception message: {exceptionMessage}.", ex.Message); + return false; + } +} + /// /// Attempts to update the Config parameters in the Cache runtime settings based on the provided value. /// Validates user-provided parameters and then returns true if the updated Cache options @@ -2367,7 +2370,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyMaxCount.Value < 1) { - _logger.LogError("Failed to update azure-key-vault.retry-policy.max-count. Value must be at least 1."); + _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); return false; } @@ -2382,7 +2385,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyDelaySeconds.Value < 1) { - _logger.LogError("Failed to update azure-key-vault.retry-policy.delay-seconds. Value must be at least 1."); + _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); return false; } @@ -2397,7 +2400,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value < 1) { - _logger.LogError("Failed to update azure-key-vault.retry-policy.max-delay-seconds. Value must be at least 1."); + _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); return false; } @@ -2412,7 +2415,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value < 1) { - _logger.LogError("Failed to update azure-key-vault.retry-policy.network-timeout-seconds. Value must be at least 1."); + _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); return false; } diff --git a/src/Config/Converters/McpOptionsConverterFactory.cs b/src/Config/Converters/McpOptionsConverterFactory.cs new file mode 100644 index 0000000000..baba7fb18a --- /dev/null +++ b/src/Config/Converters/McpOptionsConverterFactory.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// JSON converter factory for DmlToolsConfig that handles both boolean and object formats. +/// +internal class McpOptionsConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(DmlToolsConfig)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new DmlToolsConfigConverter(); + } + + private class DmlToolsConfigConverter : JsonConverter + { + /// + /// Reads DmlToolsConfig from JSON which can be either: + /// - A boolean: all tools are enabled/disabled + /// - An object: individual tool settings + /// + public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return null; + } + + // Handle boolean format: "dml-tools": true/false + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + bool enabled = reader.GetBoolean(); + return DmlToolsConfig.FromBoolean(enabled); + } + + // Handle object format + if (reader.TokenType is JsonTokenType.StartObject) + { + bool? describeEntities = null; + bool? createRecord = null; + bool? readRecord = null; + bool? updateRecord = null; + bool? deleteRecord = null; + bool? executeRecord = null; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject) + { + return new DmlToolsConfig + { + AllToolsEnabled = false, // Default when using object format + DescribeEntities = describeEntities, + CreateRecord = createRecord, + ReadRecord = readRecord, + UpdateRecord = updateRecord, + DeleteRecord = deleteRecord, + ExecuteRecord = executeRecord + }; + } + + string? property = reader.GetString(); + reader.Read(); + + switch (property) + { + case "describe-entities": + describeEntities = reader.GetBoolean(); + break; + case "create-record": + createRecord = reader.GetBoolean(); + break; + case "read-record": + readRecord = reader.GetBoolean(); + break; + case "update-record": + updateRecord = reader.GetBoolean(); + break; + case "delete-record": + deleteRecord = reader.GetBoolean(); + break; + case "execute-record": + executeRecord = reader.GetBoolean(); + break; + default: + throw new JsonException($"Unexpected property '{property}' in dml-tools configuration."); + } + } + } + + throw new JsonException("DML Tools configuration is missing closing brace."); + } + + /// + /// Writes DmlToolsConfig to JSON. + /// If all tools have the same state, writes as boolean. + /// Otherwise, writes as an object with individual tool settings. + /// + public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + // Check if this can be simplified to a boolean + bool hasIndividualSettings = value.DescribeEntities.HasValue || + value.CreateRecord.HasValue || + value.ReadRecord.HasValue || + value.UpdateRecord.HasValue || + value.DeleteRecord.HasValue || + value.ExecuteRecord.HasValue; + + if (!hasIndividualSettings) + { + writer.WriteBooleanValue(value.AllToolsEnabled); + } + else + { + writer.WriteStartObject(); + + if (value.DescribeEntities.HasValue) + { + writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); + } + + if (value.CreateRecord.HasValue) + { + writer.WriteBoolean("create-record", value.CreateRecord.Value); + } + + if (value.ReadRecord.HasValue) + { + writer.WriteBoolean("read-record", value.ReadRecord.Value); + } + + if (value.UpdateRecord.HasValue) + { + writer.WriteBoolean("update-record", value.UpdateRecord.Value); + } + + if (value.DeleteRecord.HasValue) + { + writer.WriteBoolean("delete-record", value.DeleteRecord.Value); + } + + if (value.ExecuteRecord.HasValue) + { + writer.WriteBoolean("execute-record", value.ExecuteRecord.Value); + } + + writer.WriteEndObject(); + } + } + } +} diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 682cc8d9cd..4482870380 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -10,46 +11,74 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// public record McpRuntimeOptions { + public const string DEFAULT_PATH = "/mcp"; + + [JsonPropertyName("enabled")] + public bool Enabled { get; init; } + + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("dml-tools")] + public DmlToolsConfig? DmlTools { get; init; } + + [JsonConstructor] public McpRuntimeOptions( bool Enabled = false, string? Path = null, - McpDmlToolsOptions? DmlTools = null) + DmlToolsConfig? DmlTools = null) { this.Enabled = Enabled; this.Path = Path ?? DEFAULT_PATH; - this.DmlTools = DmlTools ?? new McpDmlToolsOptions(); + this.DmlTools = DmlTools; } - - public const string DEFAULT_PATH = "/mcp"; - - public bool Enabled { get; init; } - - public string Path { get; init; } - - [JsonPropertyName("dml-tools")] - public McpDmlToolsOptions DmlTools { get; init; } } -public record McpDmlToolsOptions +/// +/// DML Tools configuration that can be either a boolean or object with individual tool settings +/// +[JsonConverter(typeof(McpOptionsConverterFactory))] +public record DmlToolsConfig { - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool Enabled { get; init; } = false; - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool DescribeEntities { get; init; } = false; - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool CreateRecord { get; init; } = false; + public bool AllToolsEnabled { get; init; } + public bool? DescribeEntities { get; init; } + public bool? CreateRecord { get; init; } + public bool? ReadRecord { get; init; } + public bool? UpdateRecord { get; init; } + public bool? DeleteRecord { get; init; } + public bool? ExecuteRecord { get; init; } - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool ReadRecord { get; init; } = false; - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UpdateRecord { get; init; } = false; - - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool DeleteRecord { get; init; } = false; + /// + /// Creates a DmlToolsConfig with all tools enabled/disabled + /// + public static DmlToolsConfig FromBoolean(bool enabled) + { + return new DmlToolsConfig + { + AllToolsEnabled = enabled, + DescribeEntities = null, + CreateRecord = null, + ReadRecord = null, + UpdateRecord = null, + DeleteRecord = null, + ExecuteRecord = null + }; + } - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool ExecuteRecord { get; init; } = false; + /// + /// Checks if a specific tool is enabled + /// + public bool IsToolEnabled(string toolName) + { + return toolName switch + { + "describe-entities" => DescribeEntities ?? AllToolsEnabled, + "create-record" => CreateRecord ?? AllToolsEnabled, + "read-record" => ReadRecord ?? AllToolsEnabled, + "update-record" => UpdateRecord ?? AllToolsEnabled, + "delete-record" => DeleteRecord ?? AllToolsEnabled, + "execute-record" => ExecuteRecord ?? AllToolsEnabled, + _ => false + }; + } } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 6cc6f156ed..5e0ff34c13 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,27 +11,8 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record AiOptions -{ - public McpOptions? Mcp { get; init; } = new(); -} - -public record McpOptions -{ - public bool Enabled { get; init; } = true; - public string Path { get; init; } = "/mcp"; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; -} - -public enum McpDmlTool -{ - DescribeEntities -} - public record RuntimeConfig { - public AiOptions? Ai { get; init; } = new(); - [JsonPropertyName("$schema")] public string Schema { get; init; } @@ -754,4 +735,23 @@ public LogLevel GetConfiguredLogLevel(string loggerFilter = "") return LogLevel.Error; } + + /// + /// Checks if the specified DML tool is enabled in MCP runtime options. + /// + public bool IsMcpDmlToolEnabled(string toolName) + { + if (Runtime?.Mcp?.Enabled != true || Runtime.Mcp.DmlTools == null) + { + return false; + } + + return Runtime.Mcp.DmlTools.IsToolEnabled(toolName); + } + + /// + /// Gets the MCP DML tools configuration + /// + [JsonIgnore] + public DmlToolsConfig? McpDmlTools => Runtime?.Mcp?.DmlTools; } diff --git a/src/McpRuntimeConfig.cs b/src/McpRuntimeConfig.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 9648d2da6f..4c672842ab 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", + "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", "options": { "set-session-context": true } @@ -27,25 +27,12 @@ "enabled": true, "path": "/mcp", "dml-tools": { - "enabled": true, - "describe-entities": { - "enabled": true - }, - "create-record": { - "enabled": true - }, - "read-record": { - "enabled": true - }, - "update-record": { - "enabled": true - }, - "delete-record": { - "enabled": true - }, - "execute-record": { - "enabled": true - } + "describe-entities": true, + "create-record": true, + "read-record": true, + "update-record": false, + "delete-record": false, + "execute-record": true } }, "host": { diff --git a/src/dab.draft.schema.json b/src/dab.draft.schema.json new file mode 100644 index 0000000000..e69de29bb2 From 68a7119192a460def6f770656d6f6a7cb68322b0 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 19 Sep 2025 14:00:04 +0530 Subject: [PATCH 31/63] Revert "Add MCP Runtime serialization/deserialization and fix tests related to it (#2881)" This reverts commit be772305a07018415ea88ac35beca291d193c7e9. --- src/Config/ObjectModel/RuntimeConfig.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 5e0ff34c13..7eaa672912 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,8 +11,27 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; +public record AiOptions +{ + public McpOptions? Mcp { get; init; } = new(); +} + +public record McpOptions +{ + public bool Enabled { get; init; } = true; + public string Path { get; init; } = "/mcp"; + public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; +} + +public enum McpDmlTool +{ + DescribeEntities +} + public record RuntimeConfig { + public AiOptions? Ai { get; init; } = new(); + [JsonPropertyName("$schema")] public string Schema { get; init; } From 89777bf4ba0eec2ffacbf77dc80fd296b0203d72 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 19 Sep 2025 14:15:13 +0530 Subject: [PATCH 32/63] Fixed MCP runtime configs --- src/Cli/ConfigGenerator.cs | 205 +++++++++++++----------- src/Config/ObjectModel/RuntimeConfig.cs | 7 - 2 files changed, 109 insertions(+), 103 deletions(-) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9f9b79bf86..3b63ed3273 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -974,117 +974,130 @@ private static bool TryUpdateConfiguredGraphQLValues( private static bool TryUpdateConfiguredMcpValues( ConfigureOptions options, ref McpRuntimeOptions? updatedMcpOptions) -{ - object? updatedValue; - - try - { - // Runtime.Mcp.Enabled - updatedValue = options?.RuntimeMcpEnabled; - if (updatedValue != null) { - updatedMcpOptions = updatedMcpOptions! with { Enabled = (bool)updatedValue }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Enabled as '{updatedValue}'", updatedValue); - } + object? updatedValue; - // Runtime.Mcp.Path - updatedValue = options?.RuntimeMcpPath; - if (updatedValue != null) - { - bool status = RuntimeConfigValidatorUtil.TryValidateUriComponent(uriComponent: (string)updatedValue, out string exceptionMessage); - if (status) - { - updatedMcpOptions = updatedMcpOptions! with { Path = (string)updatedValue }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Path as '{updatedValue}'", updatedValue); - } - else + try { - _logger.LogError("Failed to update Runtime.Mcp.Path as '{updatedValue}' due to exception message: {exceptionMessage}", updatedValue, exceptionMessage); - return false; - } - } + // Runtime.Mcp.Enabled + updatedValue = options?.RuntimeMcpEnabled; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { Enabled = (bool)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Enabled as '{updatedValue}'", updatedValue); + } - // Runtime.Mcp.Dml-Tools (as boolean) - updatedValue = options?.RuntimeMcpDmlToolsEnabled; - if (updatedValue != null) - { - updatedMcpOptions = updatedMcpOptions! with { DmlTools = DmlToolsConfig.FromBoolean((bool)updatedValue) }; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools as '{updatedValue}'", updatedValue); - } + // Runtime.Mcp.Path + updatedValue = options?.RuntimeMcpPath; + if (updatedValue != null) + { + bool status = RuntimeConfigValidatorUtil.TryValidateUriComponent(uriComponent: (string)updatedValue, out string exceptionMessage); + if (status) + { + updatedMcpOptions = updatedMcpOptions! with { Path = (string)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Path as '{updatedValue}'", updatedValue); + } + else + { + _logger.LogError("Failed to update Runtime.Mcp.Path as '{updatedValue}' due to exception message: {exceptionMessage}", updatedValue, exceptionMessage); + return false; + } + } - // Individual tool configurations - DmlToolsConfig? currentConfig = updatedMcpOptions?.DmlTools; - bool hasToolUpdates = false; + // Handle DML tools configuration + bool hasToolUpdates = false; + DmlToolsConfig? currentDmlTools = updatedMcpOptions?.DmlTools; - // Build new config with individual tool settings - bool? describeEntities = currentConfig?.DescribeEntities; - bool? createRecord = currentConfig?.CreateRecord; - bool? readRecord = currentConfig?.ReadRecord; - bool? updateRecord = currentConfig?.UpdateRecord; - bool? deleteRecord = currentConfig?.DeleteRecord; - bool? executeRecord = currentConfig?.ExecuteRecord; + // If setting all tools at once + updatedValue = options?.RuntimeMcpDmlToolsEnabled; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { DmlTools = DmlToolsConfig.FromBoolean((bool)updatedValue) }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools as '{updatedValue}'", updatedValue); + return true; // Return early since we're setting all tools at once + } - if (options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled != null) - { - describeEntities = (bool)options.RuntimeMcpDmlToolsDescribeEntitiesEnabled; - hasToolUpdates = true; - } + // Handle individual tool updates + bool? describeEntities = currentDmlTools?.DescribeEntities; + bool? createRecord = currentDmlTools?.CreateRecord; + bool? readRecord = currentDmlTools?.ReadRecord; + bool? updateRecord = currentDmlTools?.UpdateRecord; + bool? deleteRecord = currentDmlTools?.DeleteRecord; + bool? executeRecord = currentDmlTools?.ExecuteRecord; - if (options?.RuntimeMcpDmlToolsCreateRecordEnabled != null) - { - createRecord = (bool)options.RuntimeMcpDmlToolsCreateRecordEnabled; - hasToolUpdates = true; - } + updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; + if (updatedValue != null) + { + describeEntities = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); + } - if (options?.RuntimeMcpDmlToolsReadRecordEnabled != null) - { - readRecord = (bool)options.RuntimeMcpDmlToolsReadRecordEnabled; - hasToolUpdates = true; - } + updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; + if (updatedValue != null) + { + createRecord = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); + } - if (options?.RuntimeMcpDmlToolsUpdateRecordEnabled != null) - { - updateRecord = (bool)options.RuntimeMcpDmlToolsUpdateRecordEnabled; - hasToolUpdates = true; - } + updatedValue = options?.RuntimeMcpDmlToolsReadRecordEnabled; + if (updatedValue != null) + { + readRecord = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Read-Record as '{updatedValue}'", updatedValue); + } - if (options?.RuntimeMcpDmlToolsDeleteRecordEnabled != null) - { - deleteRecord = (bool)options.RuntimeMcpDmlToolsDeleteRecordEnabled; - hasToolUpdates = true; - } + updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; + if (updatedValue != null) + { + updateRecord = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); + } - if (options?.RuntimeMcpDmlToolsExecuteRecordEnabled != null) - { - executeRecord = (bool)options.RuntimeMcpDmlToolsExecuteRecordEnabled; - hasToolUpdates = true; - } + updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; + if (updatedValue != null) + { + deleteRecord = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); + } - if (hasToolUpdates) - { - updatedMcpOptions = updatedMcpOptions! with - { - DmlTools = new DmlToolsConfig - { - AllToolsEnabled = false, - DescribeEntities = describeEntities, - CreateRecord = createRecord, - ReadRecord = readRecord, - UpdateRecord = updateRecord, - DeleteRecord = deleteRecord, - ExecuteRecord = executeRecord + updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; + if (updatedValue != null) + { + executeRecord = (bool)updatedValue; + hasToolUpdates = true; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); } - }; - } - return true; - } - catch (Exception ex) - { - _logger.LogError("Failed to update RuntimeConfig.Mcp with exception message: {exceptionMessage}.", ex.Message); - return false; - } -} + if (hasToolUpdates) + { + updatedMcpOptions = updatedMcpOptions! with + { + DmlTools = new DmlToolsConfig + { + AllToolsEnabled = false, + DescribeEntities = describeEntities, + CreateRecord = createRecord, + ReadRecord = readRecord, + UpdateRecord = updateRecord, + DeleteRecord = deleteRecord, + ExecuteRecord = executeRecord + } + }; + } + + return true; + } + catch (Exception ex) + { + _logger.LogError("Failed to update RuntimeConfig.Mcp with exception message: {exceptionMessage}.", ex.Message); + return false; + } + } /// /// Attempts to update the Config parameters in the Cache runtime settings based on the provided value. diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 7eaa672912..666becccbf 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,11 +11,6 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record AiOptions -{ - public McpOptions? Mcp { get; init; } = new(); -} - public record McpOptions { public bool Enabled { get; init; } = true; @@ -30,8 +25,6 @@ public enum McpDmlTool public record RuntimeConfig { - public AiOptions? Ai { get; init; } = new(); - [JsonPropertyName("$schema")] public string Schema { get; init; } From c757d8ef90464369171ec636def067344b994fb5 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 19 Sep 2025 16:29:04 +0530 Subject: [PATCH 33/63] PR reviews and fixes --- .../BuiltInTools/ComplexTool.cs | 2 +- .../BuiltInTools/CreateRecordTool.cs | 2 +- .../BuiltInTools/DescribeEntitiesTool.cs | 2 +- .../BuiltInTools/EchoTool.cs | 2 +- src/Azure.DataApiBuilder.Mcp/Model/Enums.cs | 2 +- src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs | 2 +- src/Cli/Commands/ConfigureOptions.cs | 12 ++++++------ src/Cli/Commands/InitOptions.cs | 5 ----- src/Cli/ConfigGenerator.cs | 17 ++++++++++++++--- 9 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs index ab7635b330..d1e081868c 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs @@ -4,7 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.Enums; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index da54607b1a..1a23a37216 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -4,7 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.Enums; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index f0d561f141..70d9680f0e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -7,7 +7,7 @@ using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.Enums; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs index d3a1b59c6f..ec86d278c3 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs @@ -4,7 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Mcp.Model; using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.Enums; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { diff --git a/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs b/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs index f0e15f2ab3..84ca49e1b0 100644 --- a/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs +++ b/src/Azure.DataApiBuilder.Mcp/Model/Enums.cs @@ -3,7 +3,7 @@ namespace Azure.DataApiBuilder.Mcp.Model { - public class Enums + public class McpEnums { /// /// Specifies the type of tool. diff --git a/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs index dfd587f7da..bbee6a9304 100644 --- a/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Model/IMcpTool.cs @@ -3,7 +3,7 @@ using System.Text.Json; using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.Enums; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.Model { diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 5fdd25c571..5081951b26 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -174,22 +174,22 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools endpoint. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsEnabled { get; } - [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools describe entities endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP describe entities tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDescribeEntitiesEnabled { get; } - [Option("runtime.mcp.dml-tools.create-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools create record endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.create-record.enabled", Required = false, HelpText = "Enable DAB's MCP create record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsCreateRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.read-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools read record endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.read-record.enabled", Required = false, HelpText = "Enable DAB's MCP read record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsReadRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.update-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools update record endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.update-record.enabled", Required = false, HelpText = "Enable DAB's MCP update record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsUpdateRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools delete record endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDeleteRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools execute record endpoint. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } [Option("runtime.rest.enabled", Required = false, HelpText = "Enable DAB's Rest endpoint. Default: true (boolean).")] diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index dc7a5350ea..c04ac90f94 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -36,7 +36,6 @@ public InitOptions( string graphQLPath = GraphQLRuntimeOptions.DEFAULT_PATH, bool graphqlDisabled = false, string mcpPath = McpRuntimeOptions.DEFAULT_PATH, - bool mcpDisabled = false, CliBool restEnabled = CliBool.None, CliBool graphqlEnabled = CliBool.None, CliBool mcpEnabled = CliBool.None, @@ -62,7 +61,6 @@ public InitOptions( GraphQLPath = graphQLPath; GraphQLDisabled = graphqlDisabled; McpPath = mcpPath; - McpDisabled = mcpDisabled; RestEnabled = restEnabled; GraphQLEnabled = graphqlEnabled; McpEnabled = mcpEnabled; @@ -121,9 +119,6 @@ public InitOptions( [Option("graphql.disabled", Default = false, Required = false, HelpText = "Disables GraphQL endpoint for all entities.")] public bool GraphQLDisabled { get; } - [Option("mcp.disabled", Default = false, Required = false, HelpText = "Disables MCP endpoint for all entities.")] - public bool McpDisabled { get; } - [Option("rest.enabled", Required = false, HelpText = "(Default: true) Enables REST endpoint for all entities. Supported values: true, false.")] public CliBool RestEnabled { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 3b63ed3273..c1c4cd051d 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -112,7 +112,7 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime bool restEnabled, graphQLEnabled, mcpEnabled; if (!TryDetermineIfApiIsEnabled(options.RestDisabled, options.RestEnabled, ApiType.REST, out restEnabled) || !TryDetermineIfApiIsEnabled(options.GraphQLDisabled, options.GraphQLEnabled, ApiType.GraphQL, out graphQLEnabled) || - !TryDetermineIfApiIsEnabled(options.McpDisabled, options.McpEnabled, ApiType.MCP, out mcpEnabled)) + !TryDetermineIfMcpIsEnabled(options.McpEnabled, out mcpEnabled)) { return false; } @@ -317,6 +317,17 @@ private static bool TryDetermineIfApiIsEnabled(bool apiDisabledOptionValue, CliB return true; } + /// + /// Helper method to determine if the mcp api is enabled or not based on the enabled/disabled options in the dab init command. + /// + /// True, if MCP is enabled + /// Out param isMcpEnabled + /// True if MCP is enabled + private static bool TryDetermineIfMcpIsEnabled(CliBool mcpEnabledOptionValue, out bool isMcpEnabled) + { + return TryDetermineIfApiIsEnabled(false, mcpEnabledOptionValue, ApiType.MCP, out isMcpEnabled); + } + /// /// Helper method to determine if the multiple create operation is enabled or not based on the inputs from dab init command. /// @@ -750,7 +761,7 @@ private static bool TryUpdateConfiguredRuntimeOptions( if (options.RuntimeMcpEnabled != null || options.RuntimeMcpPath != null) { - McpRuntimeOptions? updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new(); + McpRuntimeOptions updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new(); bool status = TryUpdateConfiguredMcpValues(options, ref updatedMcpOptions); if (status) @@ -973,7 +984,7 @@ private static bool TryUpdateConfiguredGraphQLValues( /// True if the value needs to be updated in the runtime config, else false private static bool TryUpdateConfiguredMcpValues( ConfigureOptions options, - ref McpRuntimeOptions? updatedMcpOptions) + ref McpRuntimeOptions updatedMcpOptions) { object? updatedValue; From 9c7c95b325a7ac440af122d4a522928bba142a6c Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 19 Sep 2025 16:09:15 -0700 Subject: [PATCH 34/63] Fix CLI commands --- src/Cli/Commands/ConfigureOptions.cs | 17 +++++++++-------- src/Cli/Commands/InitOptions.cs | 11 ++++++++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 5081951b26..c4209a6f49 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -165,6 +165,15 @@ public ConfigureOptions( [Option("runtime.graphql.multiple-mutations.create.enabled", Required = false, HelpText = "Enable/Disable multiple-mutation create operations on DAB's generated GraphQL schema. Default: true (boolean).")] public bool? RuntimeGraphQLMultipleMutationsCreateEnabled { get; } + [Option("runtime.rest.enabled", Required = false, HelpText = "Enable DAB's Rest endpoint. Default: true (boolean).")] + public bool? RuntimeRestEnabled { get; } + + [Option("runtime.rest.path", Required = false, HelpText = "Customize DAB's REST endpoint path. Default: '/api' Conditions: Prefix path with '/'.")] + public string? RuntimeRestPath { get; } + + [Option("runtime.rest.request-body-strict", Required = false, HelpText = "Prohibit extraneous REST request body fields. Default: true (boolean).")] + public bool? RuntimeRestRequestBodyStrict { get; } + [Option("runtime.mcp.enabled", Required = false, HelpText = "Enable DAB's MCP endpoint. Default: true (boolean).")] public bool? RuntimeMcpEnabled { get; } @@ -192,14 +201,6 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } - [Option("runtime.rest.enabled", Required = false, HelpText = "Enable DAB's Rest endpoint. Default: true (boolean).")] - public bool? RuntimeRestEnabled { get; } - - [Option("runtime.rest.path", Required = false, HelpText = "Customize DAB's REST endpoint path. Default: '/api' Conditions: Prefix path with '/'.")] - public string? RuntimeRestPath { get; } - - [Option("runtime.rest.request-body-strict", Required = false, HelpText = "Prohibit extraneous REST request body fields. Default: true (boolean).")] - public bool? RuntimeRestRequestBodyStrict { get; } [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] public bool? RuntimeCacheEnabled { get; } diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index c04ac90f94..91786d99ff 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -36,6 +36,7 @@ public InitOptions( string graphQLPath = GraphQLRuntimeOptions.DEFAULT_PATH, bool graphqlDisabled = false, string mcpPath = McpRuntimeOptions.DEFAULT_PATH, + bool mcpDisabled = false, CliBool restEnabled = CliBool.None, CliBool graphqlEnabled = CliBool.None, CliBool mcpEnabled = CliBool.None, @@ -61,6 +62,7 @@ public InitOptions( GraphQLPath = graphQLPath; GraphQLDisabled = graphqlDisabled; McpPath = mcpPath; + McpDisabled = mcpDisabled; RestEnabled = restEnabled; GraphQLEnabled = graphqlEnabled; McpEnabled = mcpEnabled; @@ -104,9 +106,6 @@ public InitOptions( [Option("rest.path", Default = RestRuntimeOptions.DEFAULT_PATH, Required = false, HelpText = "Specify the REST endpoint's default prefix.")] public string RestPath { get; } - [Option("mcp.path", Default = McpRuntimeOptions.DEFAULT_PATH, Required = false, HelpText = "Specify the MCP endpoint's default prefix.")] - public string McpPath { get; } - [Option("runtime.base-route", Default = null, Required = false, HelpText = "Specifies the base route for API requests.")] public string? RuntimeBaseRoute { get; } @@ -119,6 +118,12 @@ public InitOptions( [Option("graphql.disabled", Default = false, Required = false, HelpText = "Disables GraphQL endpoint for all entities.")] public bool GraphQLDisabled { get; } + [Option("mcp.path", Default = McpRuntimeOptions.DEFAULT_PATH, Required = false, HelpText = "Specify the MCP endpoint's default prefix.")] + public string McpPath { get; } + + [Option("mcp.disabled", Default = false, Required = false, HelpText = "Disables MCP endpoint for all entities.")] + public bool McpDisabled { get; } + [Option("rest.enabled", Required = false, HelpText = "(Default: true) Enables REST endpoint for all entities. Supported values: true, false.")] public CliBool RestEnabled { get; } From 8985d5eeb1fcdcb6a5c5c5bc75d048cb3abfe806 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 19 Sep 2025 16:16:00 -0700 Subject: [PATCH 35/63] Fix formatting error --- src/Cli/Commands/ConfigureOptions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index c4209a6f49..1b50de4879 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -201,7 +201,6 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } - [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] public bool? RuntimeCacheEnabled { get; } From 395f765d6724f2f39a61ca9870d539596fa538d0 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Sun, 21 Sep 2025 18:33:11 -0700 Subject: [PATCH 36/63] [MCP] Clean up for Cli and Cli.Tests packages (#2879) ## Why make this change? This change is made to clean up the `Cli and Cli.Tests` package for the core MCP PR. ## What is this change? This change includes basic function cleanup by ecxtracting redundant code into a new function and re-writing the function summary. This also includes a bug fix where `TryUpdateConfiguredMcpValues` returns true if the value needs to be updated in the runtime config, else false ## How was this tested? Local build was successful. Testing in the PR pipeline. --- src/Cli.Tests/ConfigGeneratorTests.cs | 4 ++++ ...dProcedureWithRestMethodsAndGraphQLOperations.verified.txt | 4 ++++ ...dAfterAddingEntityWithSourceAsStoredProcedure.verified.txt | 4 ++++ ...tedAfterAddingEntityWithSourceWithDefaultType.verified.txt | 4 ++++ ...GeneratedAfterAddingEntityWithoutIEnumerables.verified.txt | 4 ++++ .../EndToEndTests.TestInitForCosmosDBNoSql.verified.txt | 4 ++++ ...ts.TestUpdatingStoredProcedureWithRestMethods.verified.txt | 4 ++++ ...dProcedureWithRestMethodsAndGraphQLOperations.verified.txt | 4 ++++ .../Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt | 4 ++++ .../InitTests.CosmosDbPostgreSqlDatabase.verified.txt | 4 ++++ ...erentAuthenticationProviders_171ea8114ff71814.verified.txt | 4 ++++ ...erentAuthenticationProviders_2df7a1794712f154.verified.txt | 4 ++++ ...erentAuthenticationProviders_59fe1a10aa78899d.verified.txt | 4 ++++ ...erentAuthenticationProviders_b95b637ea87f16a7.verified.txt | 4 ++++ ...erentAuthenticationProviders_daacbd948b7ef72f.verified.txt | 4 ++++ ...raphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt | 4 ++++ src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt | 4 ++++ ...s.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt | 4 ++++ ...TestInitializingConfigWithoutConnectionString.verified.txt | 4 ++++ ...Tests.TestSpecialCharactersInConnectionString.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_0546bef37027a950.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_0c06949221514e77.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_18667ab7db033e9d.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_2f42f44c328eb020.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_53350b8b47df2112.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_8ea187616dbb5577.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_905845c29560a3ef.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_bd7cd088755287c9.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_d2eccba2f836b380.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_eab4a6010e602b59.verified.txt | 4 ++++ ...nWithMultipleMutationOptions_ecaa688829b4030e.verified.txt | 4 ++++ src/Cli/ConfigGenerator.cs | 4 ++-- 39 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/Cli.Tests/ConfigGeneratorTests.cs b/src/Cli.Tests/ConfigGeneratorTests.cs index 6094189f93..58e006b75d 100644 --- a/src/Cli.Tests/ConfigGeneratorTests.cs +++ b/src/Cli.Tests/ConfigGeneratorTests.cs @@ -163,6 +163,10 @@ public void TestSpecialCharactersInConnectionString() ""path"": ""/An_"", ""allow-introspection"": true }, + ""mcp"": { + ""enabled"": true, + ""path"": ""/mcp"" + }, ""host"": { ""cors"": { ""origins"": [], diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt index a76f72b9a0..226c4e2a20 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt index 95415c1685..c4eb43648c 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt index ee8dbf6199..a77ecc134b 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt index 0d0afda2bf..a19694b688 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt index cbb2df5fb8..081c5f8e55 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt index 0c20e9fc25..5a6a50d38e 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt index 27b20753d3..540a1b5a1d 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt index 2af3cbc907..b3f63dd336 100644 --- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt index ca3b61588b..42e0ff5e2f 100644 --- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt index 93190d1d9d..0af93023dc 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt index 5c52bc12c1..9e77b24d74 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt index 7b0a4674eb..32f72a7a54 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt index dc60d762cc..24416a0d02 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt index 7a67eca701..6c674a4772 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt index 8c2ffbbcac..b6aac13236 100644 --- a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt @@ -16,6 +16,10 @@ Path: /abc, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt index da7937d1d9..8841c0f326 100644 --- a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt index ef8c7173d5..68e4d231fd 100644 --- a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt index 72f66f82c9..3c281ad6aa 100644 --- a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt index 7b0a4674eb..32f72a7a54 100644 --- a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { AllowCredentials: false diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt index cbaaa45754..888466ab4a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt index da7937d1d9..8841c0f326 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt index 62fc407842..d56e05c483 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt @@ -21,6 +21,10 @@ } } }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt index 3285438ab7..bc31484242 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt index cbaaa45754..888466ab4a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt index 3285438ab7..bc31484242 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt index a43e68277c..48f5e7a7c9 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt index 9740a85a77..8fa9677f1d 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt index be47d537b2..e3108801f5 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt @@ -21,6 +21,10 @@ } } }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt index 673c21dae4..59f6636fb2 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt index cbaaa45754..888466ab4a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt @@ -16,6 +16,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt index 9740a85a77..8fa9677f1d 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt index 9740a85a77..8fa9677f1d 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt index a43e68277c..48f5e7a7c9 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt index 673c21dae4..59f6636fb2 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt index a43e68277c..48f5e7a7c9 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt index 3285438ab7..bc31484242 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt index 673c21dae4..59f6636fb2 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 7dadb28f0f..9e0a95816d 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -984,8 +984,8 @@ private static bool TryUpdateConfiguredGraphQLValues( /// updatedMcpOptions /// True if the value needs to be updated in the runtime config, else false private static bool TryUpdateConfiguredMcpValues( - ConfigureOptions options, - ref McpRuntimeOptions updatedMcpOptions) + ConfigureOptions options, + ref McpRuntimeOptions updatedMcpOptions) { object? updatedValue; From fd5926fd2e8057c70b634c687ea48851806be7f4 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 22 Sep 2025 07:28:01 +0530 Subject: [PATCH 37/63] rename -record to -entity --- schemas/dab.draft.schema.json | 20 ++-- ...reateRecordTool.cs => CreateEntityTool.cs} | 4 +- src/Cli/Commands/ConfigureOptions.cs | 40 +++---- src/Cli/ConfigGenerator.cs | 50 ++++----- .../Converters/McpDmlToolsOptionsConverter.cs | 62 +++++------ .../Converters/McpOptionsConverterFactory.cs | 70 ++++++------ src/Config/ObjectModel/DmlToolsConfig.cs | 30 +++--- src/Config/ObjectModel/McpDmlToolsOptions.cs | 100 +++++++++--------- src/Service.Tests/dab-config.MsSql.json | 10 +- 9 files changed, 193 insertions(+), 193 deletions(-) rename src/Azure.DataApiBuilder.Mcp/BuiltInTools/{CreateRecordTool.cs => CreateEntityTool.cs} (97%) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 938b5c2c6e..66f8ef7ca8 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -269,29 +269,29 @@ "description": "Enable/disable the describe-entities tool.", "default": false }, - "create-record": { + "create-entity": { "type": "boolean", - "description": "Enable/disable the create-record tool.", + "description": "Enable/disable the create-entity tool.", "default": false }, - "read-record": { + "read-entity": { "type": "boolean", - "description": "Enable/disable the read-record tool.", + "description": "Enable/disable the read-entity tool.", "default": false }, - "update-record": { + "update-entity": { "type": "boolean", - "description": "Enable/disable the update-record tool.", + "description": "Enable/disable the update-entity tool.", "default": false }, - "delete-record": { + "delete-entity": { "type": "boolean", - "description": "Enable/disable the delete-record tool.", + "description": "Enable/disable the delete-entity tool.", "default": false }, - "execute-record": { + "execute-entity": { "type": "boolean", - "description": "Enable/disable the execute-record tool.", + "description": "Enable/disable the execute-entity tool.", "default": false } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs similarity index 97% rename from src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs index 1a23a37216..41573caf16 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs @@ -8,7 +8,7 @@ namespace Azure.DataApiBuilder.Mcp.BuiltInTools { - public class CreateRecordTool : IMcpTool + public class CreateEntityTool : IMcpTool { public ToolType ToolType { get; } = ToolType.BuiltIn; @@ -16,7 +16,7 @@ public Tool GetToolMetadata() { return new Tool { - Name = "create_record", + Name = "create_entity", Description = "Creates a new record in the specified entity.", InputSchema = JsonSerializer.Deserialize( @"{ diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 1b50de4879..32ff39facf 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -40,11 +40,11 @@ public ConfigureOptions( string? runtimeMcpPath = null, bool? runtimeMcpDmlToolsEnabled = null, bool? runtimeMcpDmlToolsDescribeEntitiesEnabled = null, - bool? runtimeMcpDmlToolsCreateRecordEnabled = null, - bool? runtimeMcpDmlToolsReadRecordEnabled = null, - bool? runtimeMcpDmlToolsUpdateRecordEnabled = null, - bool? runtimeMcpDmlToolsDeleteRecordEnabled = null, - bool? runtimeMcpDmlToolsExecuteRecordEnabled = null, + bool? runtimeMcpDmlToolsCreateEntityEnabled = null, + bool? runtimeMcpDmlToolsReadEntityEnabled = null, + bool? runtimeMcpDmlToolsUpdateEntityEnabled = null, + bool? runtimeMcpDmlToolsDeleteEntityEnabled = null, + bool? runtimeMcpDmlToolsExecuteEntityEnabled = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, HostMode? runtimeHostMode = null, @@ -95,11 +95,11 @@ public ConfigureOptions( RuntimeMcpPath = runtimeMcpPath; RuntimeMcpDmlToolsEnabled = runtimeMcpDmlToolsEnabled; RuntimeMcpDmlToolsDescribeEntitiesEnabled = runtimeMcpDmlToolsDescribeEntitiesEnabled; - RuntimeMcpDmlToolsCreateRecordEnabled = runtimeMcpDmlToolsCreateRecordEnabled; - RuntimeMcpDmlToolsReadRecordEnabled = runtimeMcpDmlToolsReadRecordEnabled; - RuntimeMcpDmlToolsUpdateRecordEnabled = runtimeMcpDmlToolsUpdateRecordEnabled; - RuntimeMcpDmlToolsDeleteRecordEnabled = runtimeMcpDmlToolsDeleteRecordEnabled; - RuntimeMcpDmlToolsExecuteRecordEnabled = runtimeMcpDmlToolsExecuteRecordEnabled; + RuntimeMcpDmlToolsCreateEntityEnabled = runtimeMcpDmlToolsCreateEntityEnabled; + RuntimeMcpDmlToolsReadEntityEnabled = runtimeMcpDmlToolsReadEntityEnabled; + RuntimeMcpDmlToolsUpdateEntityEnabled = runtimeMcpDmlToolsUpdateEntityEnabled; + RuntimeMcpDmlToolsDeleteEntityEnabled = runtimeMcpDmlToolsDeleteEntityEnabled; + RuntimeMcpDmlToolsExecuteEntityEnabled = runtimeMcpDmlToolsExecuteEntityEnabled; // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; @@ -186,20 +186,20 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP describe entities tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDescribeEntitiesEnabled { get; } - [Option("runtime.mcp.dml-tools.create-record.enabled", Required = false, HelpText = "Enable DAB's MCP create record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsCreateRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.create-entity.enabled", Required = false, HelpText = "Enable DAB's MCP create record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsCreateEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.read-record.enabled", Required = false, HelpText = "Enable DAB's MCP read record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsReadRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.read-entity.enabled", Required = false, HelpText = "Enable DAB's MCP read record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsReadEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.update-record.enabled", Required = false, HelpText = "Enable DAB's MCP update record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsUpdateRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.update-entity.enabled", Required = false, HelpText = "Enable DAB's MCP update record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsUpdateEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsDeleteRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.delete-entity.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsDeleteEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsExecuteEntityEnabled { get; } [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] public bool? RuntimeCacheEnabled { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9e0a95816d..22e5f6ff3d 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1031,11 +1031,11 @@ private static bool TryUpdateConfiguredMcpValues( // Handle individual tool updates bool? describeEntities = currentDmlTools?.DescribeEntities; - bool? createRecord = currentDmlTools?.CreateRecord; - bool? readRecord = currentDmlTools?.ReadRecord; - bool? updateRecord = currentDmlTools?.UpdateRecord; - bool? deleteRecord = currentDmlTools?.DeleteRecord; - bool? executeRecord = currentDmlTools?.ExecuteRecord; + bool? createEntity = currentDmlTools?.CreateEntity; + bool? readEntity = currentDmlTools?.ReadEntity; + bool? updateEntity = currentDmlTools?.UpdateEntity; + bool? deleteEntity = currentDmlTools?.DeleteEntity; + bool? executeEntity = currentDmlTools?.ExecuteEntity; updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; if (updatedValue != null) @@ -1045,44 +1045,44 @@ private static bool TryUpdateConfiguredMcpValues( _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsCreateEntityEnabled; if (updatedValue != null) { - createRecord = (bool)updatedValue; + createEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.create-entity as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsReadRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsReadEntityEnabled; if (updatedValue != null) { - readRecord = (bool)updatedValue; + readEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Read-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.read-entity as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsUpdateEntityEnabled; if (updatedValue != null) { - updateRecord = (bool)updatedValue; + updateEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.update-entity as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsDeleteEntityEnabled; if (updatedValue != null) { - deleteRecord = (bool)updatedValue; + deleteEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.delete-entity as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsExecuteEntityEnabled; if (updatedValue != null) { - executeRecord = (bool)updatedValue; + executeEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.execute-entity as '{updatedValue}'", updatedValue); } if (hasToolUpdates) @@ -1093,11 +1093,11 @@ private static bool TryUpdateConfiguredMcpValues( { AllToolsEnabled = false, DescribeEntities = describeEntities, - CreateRecord = createRecord, - ReadRecord = readRecord, - UpdateRecord = updateRecord, - DeleteRecord = deleteRecord, - ExecuteRecord = executeRecord + CreateEntity = createEntity, + ReadEntity = readEntity, + UpdateEntity = updateEntity, + DeleteEntity = deleteEntity, + ExecuteEntity = executeEntity } }; } diff --git a/src/Config/Converters/McpDmlToolsOptionsConverter.cs b/src/Config/Converters/McpDmlToolsOptionsConverter.cs index efe9dc13f0..24ae76598b 100644 --- a/src/Config/Converters/McpDmlToolsOptionsConverter.cs +++ b/src/Config/Converters/McpDmlToolsOptionsConverter.cs @@ -29,17 +29,17 @@ internal class McpDmlToolsOptionsConverter : JsonConverter if (reader.TokenType is JsonTokenType.StartObject) { bool? describeEntities = null; - bool? createRecord = null; - bool? readRecord = null; - bool? updateRecord = null; - bool? deleteRecord = null; - bool? executeRecord = null; + bool? createEntity = null; + bool? readEntity = null; + bool? updateEntity = null; + bool? deleteEntity = null; + bool? executeEntity = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { - return new McpDmlToolsOptions(describeEntities, createRecord, readRecord, updateRecord, deleteRecord, executeRecord); + return new McpDmlToolsOptions(describeEntities, createEntity, readEntity, updateEntity, deleteEntity, executeEntity); } string? propertyName = reader.GetString(); @@ -55,42 +55,42 @@ internal class McpDmlToolsOptionsConverter : JsonConverter break; - case "create-record": + case "create-entity": if (reader.TokenType is not JsonTokenType.Null) { - createRecord = reader.GetBoolean(); + createEntity = reader.GetBoolean(); } break; - case "read-record": + case "read-entity": if (reader.TokenType is not JsonTokenType.Null) { - readRecord = reader.GetBoolean(); + readEntity = reader.GetBoolean(); } break; - case "update-record": + case "update-entity": if (reader.TokenType is not JsonTokenType.Null) { - updateRecord = reader.GetBoolean(); + updateEntity = reader.GetBoolean(); } break; - case "delete-record": + case "delete-entity": if (reader.TokenType is not JsonTokenType.Null) { - deleteRecord = reader.GetBoolean(); + deleteEntity = reader.GetBoolean(); } break; - case "execute-record": + case "execute-entity": if (reader.TokenType is not JsonTokenType.Null) { - executeRecord = reader.GetBoolean(); + executeEntity = reader.GetBoolean(); } break; @@ -120,34 +120,34 @@ public override void Write(Utf8JsonWriter writer, McpDmlToolsOptions value, Json JsonSerializer.Serialize(writer, value.DescribeEntities, options); } - if (value?.UserProvidedCreateRecord is true) + if (value?.UserProvidedCreateEntity is true) { - writer.WritePropertyName("create-record"); - JsonSerializer.Serialize(writer, value.CreateRecord, options); + writer.WritePropertyName("create-entity"); + JsonSerializer.Serialize(writer, value.CreateEntity, options); } - if (value?.UserProvidedReadRecord is true) + if (value?.UserProvidedReadEntity is true) { - writer.WritePropertyName("read-record"); - JsonSerializer.Serialize(writer, value.ReadRecord, options); + writer.WritePropertyName("read-entity"); + JsonSerializer.Serialize(writer, value.ReadEntity, options); } - if (value?.UserProvidedUpdateRecord is true) + if (value?.UserProvidedUpdateEntity is true) { - writer.WritePropertyName("update-record"); - JsonSerializer.Serialize(writer, value.UpdateRecord, options); + writer.WritePropertyName("update-entity"); + JsonSerializer.Serialize(writer, value.UpdateEntity, options); } - if (value?.UserProvidedDeleteRecord is true) + if (value?.UserProvidedDeleteEntity is true) { - writer.WritePropertyName("delete-record"); - JsonSerializer.Serialize(writer, value.DeleteRecord, options); + writer.WritePropertyName("delete-entity"); + JsonSerializer.Serialize(writer, value.DeleteEntity, options); } - if (value?.UserProvidedExecuteRecord is true) + if (value?.UserProvidedExecuteEntity is true) { - writer.WritePropertyName("execute-record"); - JsonSerializer.Serialize(writer, value.ExecuteRecord, options); + writer.WritePropertyName("execute-entity"); + JsonSerializer.Serialize(writer, value.ExecuteEntity, options); } } } diff --git a/src/Config/Converters/McpOptionsConverterFactory.cs b/src/Config/Converters/McpOptionsConverterFactory.cs index baba7fb18a..dcd4bb2ddb 100644 --- a/src/Config/Converters/McpOptionsConverterFactory.cs +++ b/src/Config/Converters/McpOptionsConverterFactory.cs @@ -49,11 +49,11 @@ private class DmlToolsConfigConverter : JsonConverter if (reader.TokenType is JsonTokenType.StartObject) { bool? describeEntities = null; - bool? createRecord = null; - bool? readRecord = null; - bool? updateRecord = null; - bool? deleteRecord = null; - bool? executeRecord = null; + bool? createEntity = null; + bool? readEntity = null; + bool? updateEntity = null; + bool? deleteEntity = null; + bool? executeEntity = null; while (reader.Read()) { @@ -63,11 +63,11 @@ private class DmlToolsConfigConverter : JsonConverter { AllToolsEnabled = false, // Default when using object format DescribeEntities = describeEntities, - CreateRecord = createRecord, - ReadRecord = readRecord, - UpdateRecord = updateRecord, - DeleteRecord = deleteRecord, - ExecuteRecord = executeRecord + CreateEntity = createEntity, + ReadEntity = readEntity, + UpdateEntity = updateEntity, + DeleteEntity = deleteEntity, + ExecuteEntity = executeEntity }; } @@ -79,20 +79,20 @@ private class DmlToolsConfigConverter : JsonConverter case "describe-entities": describeEntities = reader.GetBoolean(); break; - case "create-record": - createRecord = reader.GetBoolean(); + case "create-entity": + createEntity = reader.GetBoolean(); break; - case "read-record": - readRecord = reader.GetBoolean(); + case "read-entity": + readEntity = reader.GetBoolean(); break; - case "update-record": - updateRecord = reader.GetBoolean(); + case "update-entity": + updateEntity = reader.GetBoolean(); break; - case "delete-record": - deleteRecord = reader.GetBoolean(); + case "delete-entity": + deleteEntity = reader.GetBoolean(); break; - case "execute-record": - executeRecord = reader.GetBoolean(); + case "execute-entity": + executeEntity = reader.GetBoolean(); break; default: throw new JsonException($"Unexpected property '{property}' in dml-tools configuration."); @@ -118,11 +118,11 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer // Check if this can be simplified to a boolean bool hasIndividualSettings = value.DescribeEntities.HasValue || - value.CreateRecord.HasValue || - value.ReadRecord.HasValue || - value.UpdateRecord.HasValue || - value.DeleteRecord.HasValue || - value.ExecuteRecord.HasValue; + value.CreateEntity.HasValue || + value.ReadEntity.HasValue || + value.UpdateEntity.HasValue || + value.DeleteEntity.HasValue || + value.ExecuteEntity.HasValue; if (!hasIndividualSettings) { @@ -137,29 +137,29 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); } - if (value.CreateRecord.HasValue) + if (value.CreateEntity.HasValue) { - writer.WriteBoolean("create-record", value.CreateRecord.Value); + writer.WriteBoolean("create-entity", value.CreateEntity.Value); } - if (value.ReadRecord.HasValue) + if (value.ReadEntity.HasValue) { - writer.WriteBoolean("read-record", value.ReadRecord.Value); + writer.WriteBoolean("read-entity", value.ReadEntity.Value); } - if (value.UpdateRecord.HasValue) + if (value.UpdateEntity.HasValue) { - writer.WriteBoolean("update-record", value.UpdateRecord.Value); + writer.WriteBoolean("update-entity", value.UpdateEntity.Value); } - if (value.DeleteRecord.HasValue) + if (value.DeleteEntity.HasValue) { - writer.WriteBoolean("delete-record", value.DeleteRecord.Value); + writer.WriteBoolean("delete-entity", value.DeleteEntity.Value); } - if (value.ExecuteRecord.HasValue) + if (value.ExecuteEntity.HasValue) { - writer.WriteBoolean("execute-record", value.ExecuteRecord.Value); + writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); } writer.WriteEndObject(); diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index 3019a8932d..95a81a0917 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -10,11 +10,11 @@ public record DmlToolsConfig { public bool AllToolsEnabled { get; init; } public bool? DescribeEntities { get; init; } - public bool? CreateRecord { get; init; } - public bool? ReadRecord { get; init; } - public bool? UpdateRecord { get; init; } - public bool? DeleteRecord { get; init; } - public bool? ExecuteRecord { get; init; } + public bool? CreateEntity { get; init; } + public bool? ReadEntity { get; init; } + public bool? UpdateEntity { get; init; } + public bool? DeleteEntity { get; init; } + public bool? ExecuteEntity { get; init; } /// /// Creates a DmlToolsConfig with all tools enabled/disabled @@ -25,11 +25,11 @@ public static DmlToolsConfig FromBoolean(bool enabled) { AllToolsEnabled = enabled, DescribeEntities = null, - CreateRecord = null, - ReadRecord = null, - UpdateRecord = null, - DeleteRecord = null, - ExecuteRecord = null + CreateEntity = null, + ReadEntity = null, + UpdateEntity = null, + DeleteEntity = null, + ExecuteEntity = null }; } @@ -41,11 +41,11 @@ public bool IsToolEnabled(string toolName) return toolName switch { "describe-entities" => DescribeEntities ?? AllToolsEnabled, - "create-record" => CreateRecord ?? AllToolsEnabled, - "read-record" => ReadRecord ?? AllToolsEnabled, - "update-record" => UpdateRecord ?? AllToolsEnabled, - "delete-record" => DeleteRecord ?? AllToolsEnabled, - "execute-record" => ExecuteRecord ?? AllToolsEnabled, + "create-entity" => CreateEntity ?? AllToolsEnabled, + "read-entity" => ReadEntity ?? AllToolsEnabled, + "update-entity" => UpdateEntity ?? AllToolsEnabled, + "delete-entity" => DeleteEntity ?? AllToolsEnabled, + "execute-entity" => ExecuteEntity ?? AllToolsEnabled, _ => false }; } diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs index 57bb02f16c..89959e7094 100644 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -13,23 +13,23 @@ public record McpDmlToolsOptions { public bool DescribeEntities { get; init; } - public bool CreateRecord { get; init; } + public bool CreateEntity { get; init; } - public bool ReadRecord { get; init; } + public bool ReadEntity { get; init; } - public bool UpdateRecord { get; init; } + public bool UpdateEntity { get; init; } - public bool DeleteRecord { get; init; } + public bool DeleteEntity { get; init; } - public bool ExecuteRecord { get; init; } + public bool ExecuteEntity { get; init; } public McpDmlToolsOptions( bool? DescribeEntities = null, - bool? CreateRecord = null, - bool? ReadRecord = null, - bool? UpdateRecord = null, - bool? DeleteRecord = null, - bool? ExecuteRecord = null) + bool? CreateEntity = null, + bool? ReadEntity = null, + bool? UpdateEntity = null, + bool? DeleteEntity = null, + bool? ExecuteEntity = null) { if (DescribeEntities is not null) { @@ -41,54 +41,54 @@ public McpDmlToolsOptions( this.DescribeEntities = false; } - if (CreateRecord is not null) + if (CreateEntity is not null) { - this.CreateRecord = (bool)CreateRecord; - UserProvidedCreateRecord = true; + this.CreateEntity = (bool)CreateEntity; + UserProvidedCreateEntity = true; } else { - this.CreateRecord = false; + this.CreateEntity = false; } - if (ReadRecord is not null) + if (ReadEntity is not null) { - this.ReadRecord = (bool)ReadRecord; - UserProvidedReadRecord = true; + this.ReadEntity = (bool)ReadEntity; + UserProvidedReadEntity = true; } else { - this.ReadRecord = false; + this.ReadEntity = false; } - if (UpdateRecord is not null) + if (UpdateEntity is not null) { - this.UpdateRecord = (bool)UpdateRecord; - UserProvidedUpdateRecord = true; + this.UpdateEntity = (bool)UpdateEntity; + UserProvidedUpdateEntity = true; } else { - this.UpdateRecord = false; + this.UpdateEntity = false; } - if (DeleteRecord is not null) + if (DeleteEntity is not null) { - this.DeleteRecord = (bool)DeleteRecord; - UserProvidedDeleteRecord = true; + this.DeleteEntity = (bool)DeleteEntity; + UserProvidedDeleteEntity = true; } else { - this.DeleteRecord = false; + this.DeleteEntity = false; } - if (ExecuteRecord is not null) + if (ExecuteEntity is not null) { - this.ExecuteRecord = (bool)ExecuteRecord; - UserProvidedExecuteRecord = true; + this.ExecuteEntity = (bool)ExecuteEntity; + UserProvidedExecuteEntity = true; } else { - this.ExecuteRecord = false; + this.ExecuteEntity = false; } } @@ -103,52 +103,52 @@ public McpDmlToolsOptions( public bool UserProvidedDescribeEntities { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write create-record + /// Flag which informs CLI and JSON serializer whether to write create-entity /// property and value to the runtime config file. - /// When user doesn't provide the create-record property/value, which signals DAB to use the default, + /// When user doesn't provide the create-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(CreateRecord))] - public bool UserProvidedCreateRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(CreateEntity))] + public bool UserProvidedCreateEntity { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write read-record + /// Flag which informs CLI and JSON serializer whether to write read-entity /// property and value to the runtime config file. - /// When user doesn't provide the read-record property/value, which signals DAB to use the default, + /// When user doesn't provide the read-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ReadRecord))] - public bool UserProvidedReadRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(ReadEntity))] + public bool UserProvidedReadEntity { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write update-record + /// Flag which informs CLI and JSON serializer whether to write update-entity /// property and value to the runtime config file. - /// When user doesn't provide the update-record property/value, which signals DAB to use the default, + /// When user doesn't provide the update-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(UpdateRecord))] - public bool UserProvidedUpdateRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(UpdateEntity))] + public bool UserProvidedUpdateEntity { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write delete-record + /// Flag which informs CLI and JSON serializer whether to write delete-entity /// property and value to the runtime config file. - /// When user doesn't provide the delete-record property/value, which signals DAB to use the default, + /// When user doesn't provide the delete-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(DeleteRecord))] - public bool UserProvidedDeleteRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(DeleteEntity))] + public bool UserProvidedDeleteEntity { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write execute-record + /// Flag which informs CLI and JSON serializer whether to write execute-entity /// property and value to the runtime config file. - /// When user doesn't provide the execute-record property/value, which signals DAB to use the default, + /// When user doesn't provide the execute-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ExecuteRecord))] - public bool UserProvidedExecuteRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(ExecuteEntity))] + public bool UserProvidedExecuteEntity { get; init; } = false; } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 4c672842ab..d810c409a3 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -28,11 +28,11 @@ "path": "/mcp", "dml-tools": { "describe-entities": true, - "create-record": true, - "read-record": true, - "update-record": false, - "delete-record": false, - "execute-record": true + "create-entity": true, + "read-entity": true, + "update-entity": false, + "delete-entity": false, + "execute-entity": true } }, "host": { From 54ef9807f17a76ce265d743a8f739a2690fb2191 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 22 Sep 2025 19:39:24 +0530 Subject: [PATCH 38/63] Addressed some review comments --- schemas/dab.draft.schema.json | 2 +- src/Cli/Commands/ConfigureOptions.cs | 10 +- ...y.cs => DmlToolsConfigConverterFactory.cs} | 3 +- .../Converters/McpDmlToolsOptionsConverter.cs | 153 ------------------ src/Config/ObjectModel/McpDmlToolsOptions.cs | 2 +- src/Config/ObjectModel/McpRuntimeOptions.cs | 4 +- src/Config/RuntimeConfigLoader.cs | 2 +- src/Service.Tests/ModuleInitializer.cs | 2 - 8 files changed, 11 insertions(+), 167 deletions(-) rename src/Config/Converters/{McpOptionsConverterFactory.cs => DmlToolsConfigConverterFactory.cs} (98%) delete mode 100644 src/Config/Converters/McpDmlToolsOptionsConverter.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 66f8ef7ca8..ae274ff6c1 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -251,7 +251,7 @@ "enabled": { "type": "boolean", "description": "Allow enabling/disabling MCP requests for all entities.", - "default": false + "default": true }, "dml-tools": { "oneOf": [ diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 32ff39facf..4a64688cdc 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -186,19 +186,19 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP describe entities tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDescribeEntitiesEnabled { get; } - [Option("runtime.mcp.dml-tools.create-entity.enabled", Required = false, HelpText = "Enable DAB's MCP create record tool. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.create-entity.enabled", Required = false, HelpText = "Enable DAB's MCP create entity tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsCreateEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.read-entity.enabled", Required = false, HelpText = "Enable DAB's MCP read record tool. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.read-entity.enabled", Required = false, HelpText = "Enable DAB's MCP read entity tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsReadEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.update-entity.enabled", Required = false, HelpText = "Enable DAB's MCP update record tool. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.update-entity.enabled", Required = false, HelpText = "Enable DAB's MCP update entity tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsUpdateEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.delete-entity.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.delete-entity.enabled", Required = false, HelpText = "Enable DAB's MCP delete entity tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDeleteEntityEnabled { get; } - [Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] + [Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute entity tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsExecuteEntityEnabled { get; } [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] diff --git a/src/Config/Converters/McpOptionsConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs similarity index 98% rename from src/Config/Converters/McpOptionsConverterFactory.cs rename to src/Config/Converters/DmlToolsConfigConverterFactory.cs index dcd4bb2ddb..4340f6edaf 100644 --- a/src/Config/Converters/McpOptionsConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -10,7 +10,7 @@ namespace Azure.DataApiBuilder.Config.Converters; /// /// JSON converter factory for DmlToolsConfig that handles both boolean and object formats. /// -internal class McpOptionsConverterFactory : JsonConverterFactory +internal class DmlToolsConfigConverterFactory : JsonConverterFactory { /// public override bool CanConvert(Type typeToConvert) @@ -112,7 +112,6 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer { if (value is null) { - writer.WriteNullValue(); return; } diff --git a/src/Config/Converters/McpDmlToolsOptionsConverter.cs b/src/Config/Converters/McpDmlToolsOptionsConverter.cs deleted file mode 100644 index 24ae76598b..0000000000 --- a/src/Config/Converters/McpDmlToolsOptionsConverter.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using System.Text.Json.Serialization; -using Azure.DataApiBuilder.Config.ObjectModel; - -namespace Azure.DataApiBuilder.Config.Converters; - -internal class McpDmlToolsOptionsConverter : JsonConverter -{ - /// - /// Defines how DAB reads MCP DML Tools options and defines which values are - /// used to instantiate McpDmlToolsOptions. - /// - /// Thrown when improperly formatted MCP DML Tools options are provided. - public override McpDmlToolsOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.True) - { - return new McpDmlToolsOptions(true, true, true, true, true, true); - } - - if (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.Null) - { - return new McpDmlToolsOptions(); - } - - if (reader.TokenType is JsonTokenType.StartObject) - { - bool? describeEntities = null; - bool? createEntity = null; - bool? readEntity = null; - bool? updateEntity = null; - bool? deleteEntity = null; - bool? executeEntity = null; - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndObject) - { - return new McpDmlToolsOptions(describeEntities, createEntity, readEntity, updateEntity, deleteEntity, executeEntity); - } - - string? propertyName = reader.GetString(); - - reader.Read(); - switch (propertyName) - { - case "describe-entities": - if (reader.TokenType is not JsonTokenType.Null) - { - describeEntities = reader.GetBoolean(); - } - - break; - - case "create-entity": - if (reader.TokenType is not JsonTokenType.Null) - { - createEntity = reader.GetBoolean(); - } - - break; - - case "read-entity": - if (reader.TokenType is not JsonTokenType.Null) - { - readEntity = reader.GetBoolean(); - } - - break; - - case "update-entity": - if (reader.TokenType is not JsonTokenType.Null) - { - updateEntity = reader.GetBoolean(); - } - - break; - - case "delete-entity": - if (reader.TokenType is not JsonTokenType.Null) - { - deleteEntity = reader.GetBoolean(); - } - - break; - - case "execute-entity": - if (reader.TokenType is not JsonTokenType.Null) - { - executeEntity = reader.GetBoolean(); - } - - break; - - default: - throw new JsonException($"Unexpected property {propertyName}"); - } - } - } - - throw new JsonException("Failed to read the MCP DML Tools Options"); - } - - /// - /// When writing the McpDmlToolsOptions back to a JSON file, only write the properties - /// if they are user provided. This avoids polluting the written JSON file with properties - /// the user most likely omitted when writing the original DAB runtime config file. - /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. - /// - public override void Write(Utf8JsonWriter writer, McpDmlToolsOptions value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - if (value?.UserProvidedDescribeEntities is true) - { - writer.WritePropertyName("describe-entities"); - JsonSerializer.Serialize(writer, value.DescribeEntities, options); - } - - if (value?.UserProvidedCreateEntity is true) - { - writer.WritePropertyName("create-entity"); - JsonSerializer.Serialize(writer, value.CreateEntity, options); - } - - if (value?.UserProvidedReadEntity is true) - { - writer.WritePropertyName("read-entity"); - JsonSerializer.Serialize(writer, value.ReadEntity, options); - } - - if (value?.UserProvidedUpdateEntity is true) - { - writer.WritePropertyName("update-entity"); - JsonSerializer.Serialize(writer, value.UpdateEntity, options); - } - - if (value?.UserProvidedDeleteEntity is true) - { - writer.WritePropertyName("delete-entity"); - JsonSerializer.Serialize(writer, value.DeleteEntity, options); - } - - if (value?.UserProvidedExecuteEntity is true) - { - writer.WritePropertyName("execute-entity"); - JsonSerializer.Serialize(writer, value.ExecuteEntity, options); - } - } -} diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs index 89959e7094..92e9dbf8ac 100644 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -7,7 +7,7 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// -/// DML Tools found in global MCP configuration. +/// DML Tools for general CRUD operations on configured entities /// public record McpDmlToolsOptions { diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 454b5e3a5b..0950936728 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -20,12 +20,12 @@ public record McpRuntimeOptions public string? Path { get; init; } [JsonPropertyName("dml-tools")] - [JsonConverter(typeof(McpOptionsConverterFactory))] + [JsonConverter(typeof(DmlToolsConfigConverterFactory))] public DmlToolsConfig? DmlTools { get; init; } [JsonConstructor] public McpRuntimeOptions( - bool Enabled = false, + bool Enabled = true, string? Path = null, DmlToolsConfig? DmlTools = null) { diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 4f149c6716..73f5797e36 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -246,7 +246,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityHealthOptionsConvertorFactory()); options.Converters.Add(new RestRuntimeOptionsConverterFactory()); options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new McpOptionsConverterFactory()); + options.Converters.Add(new DmlToolsConfigConverterFactory()); options.Converters.Add(new EntitySourceConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index c387fe37eb..a491cf5f0c 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -77,8 +77,6 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.IsGraphQLEnabled); // Ignore the IsRestEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRestEnabled); - // Ignore the IsMcpEnabled as that's unimportant from a test standpoint. - VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. From b139945665ed55569e14fc04d2a6f3b1ac323234 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 23 Sep 2025 13:32:08 +0530 Subject: [PATCH 39/63] Update execute-entity and rest to -record --- schemas/dab.draft.schema.json | 20 ++-- ...reateEntityTool.cs => CreateRecordTool.cs} | 4 +- src/Cli/Commands/ConfigureOptions.cs | 40 +++---- src/Cli/ConfigGenerator.cs | 50 ++++----- .../DmlToolsConfigConverterFactory.cs | 70 ++++++------ src/Config/ObjectModel/DmlToolsConfig.cs | 30 +++--- src/Config/ObjectModel/McpDmlToolsOptions.cs | 100 +++++++++--------- src/Service.Tests/dab-config.MsSql.json | 10 +- 8 files changed, 162 insertions(+), 162 deletions(-) rename src/Azure.DataApiBuilder.Mcp/BuiltInTools/{CreateEntityTool.cs => CreateRecordTool.cs} (97%) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index ae274ff6c1..32d4180dc1 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -269,29 +269,29 @@ "description": "Enable/disable the describe-entities tool.", "default": false }, - "create-entity": { + "create-record": { "type": "boolean", - "description": "Enable/disable the create-entity tool.", + "description": "Enable/disable the create-record tool.", "default": false }, - "read-entity": { + "read-records": { "type": "boolean", - "description": "Enable/disable the read-entity tool.", + "description": "Enable/disable the read-records tool.", "default": false }, - "update-entity": { + "update-record": { "type": "boolean", - "description": "Enable/disable the update-entity tool.", + "description": "Enable/disable the update-record tool.", "default": false }, - "delete-entity": { + "delete-record": { "type": "boolean", - "description": "Enable/disable the delete-entity tool.", + "description": "Enable/disable the delete-record tool.", "default": false }, - "execute-entity": { + "execute-record": { "type": "boolean", - "description": "Enable/disable the execute-entity tool.", + "description": "Enable/disable the execute-record tool.", "default": false } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs similarity index 97% rename from src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 41573caf16..1a23a37216 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -8,7 +8,7 @@ namespace Azure.DataApiBuilder.Mcp.BuiltInTools { - public class CreateEntityTool : IMcpTool + public class CreateRecordTool : IMcpTool { public ToolType ToolType { get; } = ToolType.BuiltIn; @@ -16,7 +16,7 @@ public Tool GetToolMetadata() { return new Tool { - Name = "create_entity", + Name = "create_record", Description = "Creates a new record in the specified entity.", InputSchema = JsonSerializer.Deserialize( @"{ diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 4a64688cdc..4ec1d4734c 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -40,11 +40,11 @@ public ConfigureOptions( string? runtimeMcpPath = null, bool? runtimeMcpDmlToolsEnabled = null, bool? runtimeMcpDmlToolsDescribeEntitiesEnabled = null, - bool? runtimeMcpDmlToolsCreateEntityEnabled = null, - bool? runtimeMcpDmlToolsReadEntityEnabled = null, - bool? runtimeMcpDmlToolsUpdateEntityEnabled = null, - bool? runtimeMcpDmlToolsDeleteEntityEnabled = null, - bool? runtimeMcpDmlToolsExecuteEntityEnabled = null, + bool? runtimeMcpDmlToolsCreateRecordEnabled = null, + bool? runtimeMcpDmlToolsReadRecordsEnabled = null, + bool? runtimeMcpDmlToolsUpdateRecordEnabled = null, + bool? runtimeMcpDmlToolsDeleteRecordEnabled = null, + bool? runtimeMcpDmlToolsExecuteRecordEnabled = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, HostMode? runtimeHostMode = null, @@ -95,11 +95,11 @@ public ConfigureOptions( RuntimeMcpPath = runtimeMcpPath; RuntimeMcpDmlToolsEnabled = runtimeMcpDmlToolsEnabled; RuntimeMcpDmlToolsDescribeEntitiesEnabled = runtimeMcpDmlToolsDescribeEntitiesEnabled; - RuntimeMcpDmlToolsCreateEntityEnabled = runtimeMcpDmlToolsCreateEntityEnabled; - RuntimeMcpDmlToolsReadEntityEnabled = runtimeMcpDmlToolsReadEntityEnabled; - RuntimeMcpDmlToolsUpdateEntityEnabled = runtimeMcpDmlToolsUpdateEntityEnabled; - RuntimeMcpDmlToolsDeleteEntityEnabled = runtimeMcpDmlToolsDeleteEntityEnabled; - RuntimeMcpDmlToolsExecuteEntityEnabled = runtimeMcpDmlToolsExecuteEntityEnabled; + RuntimeMcpDmlToolsCreateRecordEnabled = runtimeMcpDmlToolsCreateRecordEnabled; + RuntimeMcpDmlToolsReadRecordsEnabled = runtimeMcpDmlToolsReadRecordsEnabled; + RuntimeMcpDmlToolsUpdateRecordEnabled = runtimeMcpDmlToolsUpdateRecordEnabled; + RuntimeMcpDmlToolsDeleteRecordEnabled = runtimeMcpDmlToolsDeleteRecordEnabled; + RuntimeMcpDmlToolsExecuteRecordEnabled = runtimeMcpDmlToolsExecuteRecordEnabled; // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; @@ -186,20 +186,20 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.describe-entities.enabled", Required = false, HelpText = "Enable DAB's MCP describe entities tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDescribeEntitiesEnabled { get; } - [Option("runtime.mcp.dml-tools.create-entity.enabled", Required = false, HelpText = "Enable DAB's MCP create entity tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsCreateEntityEnabled { get; } + [Option("runtime.mcp.dml-tools.create-record.enabled", Required = false, HelpText = "Enable DAB's MCP create record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsCreateRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.read-entity.enabled", Required = false, HelpText = "Enable DAB's MCP read entity tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsReadEntityEnabled { get; } + [Option("runtime.mcp.dml-tools.read-records.enabled", Required = false, HelpText = "Enable DAB's MCP read record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsReadRecordsEnabled { get; } - [Option("runtime.mcp.dml-tools.update-entity.enabled", Required = false, HelpText = "Enable DAB's MCP update entity tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsUpdateEntityEnabled { get; } + [Option("runtime.mcp.dml-tools.update-record.enabled", Required = false, HelpText = "Enable DAB's MCP update record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsUpdateRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.delete-entity.enabled", Required = false, HelpText = "Enable DAB's MCP delete entity tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsDeleteEntityEnabled { get; } + [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsDeleteRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute entity tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsExecuteEntityEnabled { get; } + [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] public bool? RuntimeCacheEnabled { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 22e5f6ff3d..130121ee02 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1031,11 +1031,11 @@ private static bool TryUpdateConfiguredMcpValues( // Handle individual tool updates bool? describeEntities = currentDmlTools?.DescribeEntities; - bool? createEntity = currentDmlTools?.CreateEntity; - bool? readEntity = currentDmlTools?.ReadEntity; - bool? updateEntity = currentDmlTools?.UpdateEntity; - bool? deleteEntity = currentDmlTools?.DeleteEntity; - bool? executeEntity = currentDmlTools?.ExecuteEntity; + bool? createRecord = currentDmlTools?.CreateRecord; + bool? readRecords = currentDmlTools?.ReadRecords; + bool? updateRecord = currentDmlTools?.UpdateRecord; + bool? deleteRecord = currentDmlTools?.DeleteRecord; + bool? executeRecord = currentDmlTools?.ExecuteRecord; updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; if (updatedValue != null) @@ -1045,44 +1045,44 @@ private static bool TryUpdateConfiguredMcpValues( _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsCreateEntityEnabled; + updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; if (updatedValue != null) { - createEntity = (bool)updatedValue; + createRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.create-entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsReadEntityEnabled; + updatedValue = options?.RuntimeMcpDmlToolsReadRecordsEnabled; if (updatedValue != null) { - readEntity = (bool)updatedValue; + readRecords = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.read-entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.read-records as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsUpdateEntityEnabled; + updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; if (updatedValue != null) { - updateEntity = (bool)updatedValue; + updateRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.update-entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsDeleteEntityEnabled; + updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; if (updatedValue != null) { - deleteEntity = (bool)updatedValue; + deleteRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.delete-entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsExecuteEntityEnabled; + updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; if (updatedValue != null) { - executeEntity = (bool)updatedValue; + executeRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.execute-entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); } if (hasToolUpdates) @@ -1093,11 +1093,11 @@ private static bool TryUpdateConfiguredMcpValues( { AllToolsEnabled = false, DescribeEntities = describeEntities, - CreateEntity = createEntity, - ReadEntity = readEntity, - UpdateEntity = updateEntity, - DeleteEntity = deleteEntity, - ExecuteEntity = executeEntity + CreateRecord = createRecord, + ReadRecords = readRecords, + UpdateRecord = updateRecord, + DeleteRecord = deleteRecord, + ExecuteRecord = executeRecord } }; } diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index 4340f6edaf..f2b10ae207 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -49,11 +49,11 @@ private class DmlToolsConfigConverter : JsonConverter if (reader.TokenType is JsonTokenType.StartObject) { bool? describeEntities = null; - bool? createEntity = null; - bool? readEntity = null; - bool? updateEntity = null; - bool? deleteEntity = null; - bool? executeEntity = null; + bool? createRecord = null; + bool? readRecords = null; + bool? updateRecord = null; + bool? deleteRecord = null; + bool? executeRecord = null; while (reader.Read()) { @@ -63,11 +63,11 @@ private class DmlToolsConfigConverter : JsonConverter { AllToolsEnabled = false, // Default when using object format DescribeEntities = describeEntities, - CreateEntity = createEntity, - ReadEntity = readEntity, - UpdateEntity = updateEntity, - DeleteEntity = deleteEntity, - ExecuteEntity = executeEntity + CreateRecord = createRecord, + ReadRecords = readRecords, + UpdateRecord = updateRecord, + DeleteRecord = deleteRecord, + ExecuteRecord = executeRecord }; } @@ -79,20 +79,20 @@ private class DmlToolsConfigConverter : JsonConverter case "describe-entities": describeEntities = reader.GetBoolean(); break; - case "create-entity": - createEntity = reader.GetBoolean(); + case "create-record": + createRecord = reader.GetBoolean(); break; - case "read-entity": - readEntity = reader.GetBoolean(); + case "read-records": + readRecords = reader.GetBoolean(); break; - case "update-entity": - updateEntity = reader.GetBoolean(); + case "update-record": + updateRecord = reader.GetBoolean(); break; - case "delete-entity": - deleteEntity = reader.GetBoolean(); + case "delete-record": + deleteRecord = reader.GetBoolean(); break; - case "execute-entity": - executeEntity = reader.GetBoolean(); + case "execute-record": + executeRecord = reader.GetBoolean(); break; default: throw new JsonException($"Unexpected property '{property}' in dml-tools configuration."); @@ -117,11 +117,11 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer // Check if this can be simplified to a boolean bool hasIndividualSettings = value.DescribeEntities.HasValue || - value.CreateEntity.HasValue || - value.ReadEntity.HasValue || - value.UpdateEntity.HasValue || - value.DeleteEntity.HasValue || - value.ExecuteEntity.HasValue; + value.CreateRecord.HasValue || + value.ReadRecords.HasValue || + value.UpdateRecord.HasValue || + value.DeleteRecord.HasValue || + value.ExecuteRecord.HasValue; if (!hasIndividualSettings) { @@ -136,29 +136,29 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); } - if (value.CreateEntity.HasValue) + if (value.CreateRecord.HasValue) { - writer.WriteBoolean("create-entity", value.CreateEntity.Value); + writer.WriteBoolean("create-record", value.CreateRecord.Value); } - if (value.ReadEntity.HasValue) + if (value.ReadRecords.HasValue) { - writer.WriteBoolean("read-entity", value.ReadEntity.Value); + writer.WriteBoolean("read-records", value.ReadRecords.Value); } - if (value.UpdateEntity.HasValue) + if (value.UpdateRecord.HasValue) { - writer.WriteBoolean("update-entity", value.UpdateEntity.Value); + writer.WriteBoolean("update-record", value.UpdateRecord.Value); } - if (value.DeleteEntity.HasValue) + if (value.DeleteRecord.HasValue) { - writer.WriteBoolean("delete-entity", value.DeleteEntity.Value); + writer.WriteBoolean("delete-record", value.DeleteRecord.Value); } - if (value.ExecuteEntity.HasValue) + if (value.ExecuteRecord.HasValue) { - writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); + writer.WriteBoolean("execute-record", value.ExecuteRecord.Value); } writer.WriteEndObject(); diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index 95a81a0917..83dcf54d6b 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -10,11 +10,11 @@ public record DmlToolsConfig { public bool AllToolsEnabled { get; init; } public bool? DescribeEntities { get; init; } - public bool? CreateEntity { get; init; } - public bool? ReadEntity { get; init; } - public bool? UpdateEntity { get; init; } - public bool? DeleteEntity { get; init; } - public bool? ExecuteEntity { get; init; } + public bool? CreateRecord { get; init; } + public bool? ReadRecords { get; init; } + public bool? UpdateRecord { get; init; } + public bool? DeleteRecord { get; init; } + public bool? ExecuteRecord { get; init; } /// /// Creates a DmlToolsConfig with all tools enabled/disabled @@ -25,11 +25,11 @@ public static DmlToolsConfig FromBoolean(bool enabled) { AllToolsEnabled = enabled, DescribeEntities = null, - CreateEntity = null, - ReadEntity = null, - UpdateEntity = null, - DeleteEntity = null, - ExecuteEntity = null + CreateRecord = null, + ReadRecords = null, + UpdateRecord = null, + DeleteRecord = null, + ExecuteRecord = null }; } @@ -41,11 +41,11 @@ public bool IsToolEnabled(string toolName) return toolName switch { "describe-entities" => DescribeEntities ?? AllToolsEnabled, - "create-entity" => CreateEntity ?? AllToolsEnabled, - "read-entity" => ReadEntity ?? AllToolsEnabled, - "update-entity" => UpdateEntity ?? AllToolsEnabled, - "delete-entity" => DeleteEntity ?? AllToolsEnabled, - "execute-entity" => ExecuteEntity ?? AllToolsEnabled, + "create-record" => CreateRecord ?? AllToolsEnabled, + "read-records" => ReadRecords ?? AllToolsEnabled, + "update-record" => UpdateRecord ?? AllToolsEnabled, + "delete-record" => DeleteRecord ?? AllToolsEnabled, + "execute-record" => ExecuteRecord ?? AllToolsEnabled, _ => false }; } diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs index 92e9dbf8ac..8b6156b712 100644 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -13,23 +13,23 @@ public record McpDmlToolsOptions { public bool DescribeEntities { get; init; } - public bool CreateEntity { get; init; } + public bool CreateRecord { get; init; } - public bool ReadEntity { get; init; } + public bool ReadRecords { get; init; } - public bool UpdateEntity { get; init; } + public bool UpdateRecord { get; init; } - public bool DeleteEntity { get; init; } + public bool DeleteRecord { get; init; } - public bool ExecuteEntity { get; init; } + public bool ExecuteRecord { get; init; } public McpDmlToolsOptions( bool? DescribeEntities = null, - bool? CreateEntity = null, - bool? ReadEntity = null, - bool? UpdateEntity = null, - bool? DeleteEntity = null, - bool? ExecuteEntity = null) + bool? CreateRecord = null, + bool? ReadRecords = null, + bool? UpdateRecord = null, + bool? DeleteRecord = null, + bool? ExecuteRecord = null) { if (DescribeEntities is not null) { @@ -41,54 +41,54 @@ public McpDmlToolsOptions( this.DescribeEntities = false; } - if (CreateEntity is not null) + if (CreateRecord is not null) { - this.CreateEntity = (bool)CreateEntity; - UserProvidedCreateEntity = true; + this.CreateRecord = (bool)CreateRecord; + UserProvidedCreateRecord = true; } else { - this.CreateEntity = false; + this.CreateRecord = false; } - if (ReadEntity is not null) + if (ReadRecords is not null) { - this.ReadEntity = (bool)ReadEntity; - UserProvidedReadEntity = true; + this.ReadRecords = (bool)ReadRecords; + UserProvidedReadRecords = true; } else { - this.ReadEntity = false; + this.ReadRecords = false; } - if (UpdateEntity is not null) + if (UpdateRecord is not null) { - this.UpdateEntity = (bool)UpdateEntity; - UserProvidedUpdateEntity = true; + this.UpdateRecord = (bool)UpdateRecord; + UserProvidedUpdateRecord = true; } else { - this.UpdateEntity = false; + this.UpdateRecord = false; } - if (DeleteEntity is not null) + if (DeleteRecord is not null) { - this.DeleteEntity = (bool)DeleteEntity; - UserProvidedDeleteEntity = true; + this.DeleteRecord = (bool)DeleteRecord; + UserProvidedDeleteRecord = true; } else { - this.DeleteEntity = false; + this.DeleteRecord = false; } - if (ExecuteEntity is not null) + if (ExecuteRecord is not null) { - this.ExecuteEntity = (bool)ExecuteEntity; - UserProvidedExecuteEntity = true; + this.ExecuteRecord = (bool)ExecuteRecord; + UserProvidedExecuteRecord = true; } else { - this.ExecuteEntity = false; + this.ExecuteRecord = false; } } @@ -103,52 +103,52 @@ public McpDmlToolsOptions( public bool UserProvidedDescribeEntities { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write create-entity + /// Flag which informs CLI and JSON serializer whether to write create-record /// property and value to the runtime config file. - /// When user doesn't provide the create-entity property/value, which signals DAB to use the default, + /// When user doesn't provide the create-record property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(CreateEntity))] - public bool UserProvidedCreateEntity { get; init; } = false; + [MemberNotNullWhen(true, nameof(CreateRecord))] + public bool UserProvidedCreateRecord { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write read-entity + /// Flag which informs CLI and JSON serializer whether to write read-records /// property and value to the runtime config file. - /// When user doesn't provide the read-entity property/value, which signals DAB to use the default, + /// When user doesn't provide the read-records property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ReadEntity))] - public bool UserProvidedReadEntity { get; init; } = false; + [MemberNotNullWhen(true, nameof(ReadRecords))] + public bool UserProvidedReadRecords { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write update-entity + /// Flag which informs CLI and JSON serializer whether to write update-record /// property and value to the runtime config file. - /// When user doesn't provide the update-entity property/value, which signals DAB to use the default, + /// When user doesn't provide the update-record property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(UpdateEntity))] - public bool UserProvidedUpdateEntity { get; init; } = false; + [MemberNotNullWhen(true, nameof(UpdateRecord))] + public bool UserProvidedUpdateRecord { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write delete-entity + /// Flag which informs CLI and JSON serializer whether to write delete-record /// property and value to the runtime config file. - /// When user doesn't provide the delete-entity property/value, which signals DAB to use the default, + /// When user doesn't provide the delete-record property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(DeleteEntity))] - public bool UserProvidedDeleteEntity { get; init; } = false; + [MemberNotNullWhen(true, nameof(DeleteRecord))] + public bool UserProvidedDeleteRecord { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write execute-entity + /// Flag which informs CLI and JSON serializer whether to write execute-record /// property and value to the runtime config file. - /// When user doesn't provide the execute-entity property/value, which signals DAB to use the default, + /// When user doesn't provide the execute-record property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ExecuteEntity))] - public bool UserProvidedExecuteEntity { get; init; } = false; + [MemberNotNullWhen(true, nameof(ExecuteRecord))] + public bool UserProvidedExecuteRecord { get; init; } = false; } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index d810c409a3..101d6514c1 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -28,11 +28,11 @@ "path": "/mcp", "dml-tools": { "describe-entities": true, - "create-entity": true, - "read-entity": true, - "update-entity": false, - "delete-entity": false, - "execute-entity": true + "create-record": true, + "read-records": true, + "update-record": false, + "delete-record": false, + "execute-record": true } }, "host": { From b71c3822ff915568e93e5749d0edc0141239f92f Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 23 Sep 2025 16:59:55 +0530 Subject: [PATCH 40/63] set default true for all and write to JSON if user modified --- src/Cli/ConfigGenerator.cs | 6 +- .../DmlToolsConfigConverterFactory.cs | 66 ++++---- src/Config/ObjectModel/DmlToolsConfig.cs | 145 +++++++++++++++++- src/Config/ObjectModel/McpDmlToolsOptions.cs | 20 +-- 4 files changed, 189 insertions(+), 48 deletions(-) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 130121ee02..db8e122d8c 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1032,7 +1032,7 @@ private static bool TryUpdateConfiguredMcpValues( // Handle individual tool updates bool? describeEntities = currentDmlTools?.DescribeEntities; bool? createRecord = currentDmlTools?.CreateRecord; - bool? readRecords = currentDmlTools?.ReadRecords; + bool? readRecord = currentDmlTools?.ReadRecords; bool? updateRecord = currentDmlTools?.UpdateRecord; bool? deleteRecord = currentDmlTools?.DeleteRecord; bool? executeRecord = currentDmlTools?.ExecuteRecord; @@ -1056,7 +1056,7 @@ private static bool TryUpdateConfiguredMcpValues( updatedValue = options?.RuntimeMcpDmlToolsReadRecordsEnabled; if (updatedValue != null) { - readRecords = (bool)updatedValue; + readRecord = (bool)updatedValue; hasToolUpdates = true; _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.read-records as '{updatedValue}'", updatedValue); } @@ -1094,7 +1094,7 @@ private static bool TryUpdateConfiguredMcpValues( AllToolsEnabled = false, DescribeEntities = describeEntities, CreateRecord = createRecord, - ReadRecords = readRecords, + ReadRecords = readRecord, UpdateRecord = updateRecord, DeleteRecord = deleteRecord, ExecuteRecord = executeRecord diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index f2b10ae207..1831825afd 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -30,12 +30,14 @@ private class DmlToolsConfigConverter : JsonConverter /// Reads DmlToolsConfig from JSON which can be either: /// - A boolean: all tools are enabled/disabled /// - An object: individual tool settings + /// - Null/undefined: defaults to all tools enabled (true) /// public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.Null) { - return null; + // Return default config with all tools enabled + return DmlToolsConfig.Default; } // Handle boolean format: "dml-tools": true/false @@ -50,7 +52,7 @@ private class DmlToolsConfigConverter : JsonConverter { bool? describeEntities = null; bool? createRecord = null; - bool? readRecords = null; + bool? readRecord = null; bool? updateRecord = null; bool? deleteRecord = null; bool? executeRecord = null; @@ -59,16 +61,14 @@ private class DmlToolsConfigConverter : JsonConverter { if (reader.TokenType is JsonTokenType.EndObject) { - return new DmlToolsConfig - { - AllToolsEnabled = false, // Default when using object format - DescribeEntities = describeEntities, - CreateRecord = createRecord, - ReadRecords = readRecords, - UpdateRecord = updateRecord, - DeleteRecord = deleteRecord, - ExecuteRecord = executeRecord - }; + return new DmlToolsConfig( + allToolsEnabled: null, + describeEntities: describeEntities, + createRecord: createRecord, + readRecords: readRecord, + updateRecord: updateRecord, + deleteRecord: deleteRecord, + executeRecord: executeRecord); } string? property = reader.GetString(); @@ -83,7 +83,7 @@ private class DmlToolsConfigConverter : JsonConverter createRecord = reader.GetBoolean(); break; case "read-records": - readRecords = reader.GetBoolean(); + readRecord = reader.GetBoolean(); break; case "update-record": updateRecord = reader.GetBoolean(); @@ -105,58 +105,66 @@ private class DmlToolsConfigConverter : JsonConverter /// /// Writes DmlToolsConfig to JSON. - /// If all tools have the same state, writes as boolean. - /// Otherwise, writes as an object with individual tool settings. + /// Only writes user-provided properties to avoid bloating the config file. + /// If no individual settings provided, writes as boolean. /// public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSerializerOptions options) { if (value is null) { + // Don't write null - omit the property entirely return; } - // Check if this can be simplified to a boolean - bool hasIndividualSettings = value.DescribeEntities.HasValue || - value.CreateRecord.HasValue || - value.ReadRecords.HasValue || - value.UpdateRecord.HasValue || - value.DeleteRecord.HasValue || - value.ExecuteRecord.HasValue; + // Check if any individual settings were provided by the user + bool hasIndividualSettings = value.UserProvidedDescribeEntities || + value.UserProvidedCreateRecord || + value.UserProvidedReadRecords || + value.UserProvidedUpdateRecord || + value.UserProvidedDeleteRecord || + value.UserProvidedExecuteRecord; if (!hasIndividualSettings) { - writer.WriteBooleanValue(value.AllToolsEnabled); + // Only write the boolean value if it's not the default (true) + // This prevents writing "dml-tools": true when it's the default + if (value.AllToolsEnabled != DmlToolsConfig.DEFAULT_ENABLED) + { + writer.WriteBooleanValue(value.AllToolsEnabled); + } + // Otherwise, don't write anything (property will be omitted) } else { + // Write as object with only user-provided properties writer.WriteStartObject(); - if (value.DescribeEntities.HasValue) + if (value.UserProvidedDescribeEntities) { writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); } - if (value.CreateRecord.HasValue) + if (value.UserProvidedCreateRecord) { writer.WriteBoolean("create-record", value.CreateRecord.Value); } - if (value.ReadRecords.HasValue) + if (value.UserProvidedReadRecords) { writer.WriteBoolean("read-records", value.ReadRecords.Value); } - if (value.UpdateRecord.HasValue) + if (value.UserProvidedUpdateRecord) { writer.WriteBoolean("update-record", value.UpdateRecord.Value); } - if (value.DeleteRecord.HasValue) + if (value.UserProvidedDeleteRecord) { writer.WriteBoolean("delete-record", value.DeleteRecord.Value); } - if (value.ExecuteRecord.HasValue) + if (value.UserProvidedExecuteRecord) { writer.WriteBoolean("execute-record", value.ExecuteRecord.Value); } diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index 83dcf54d6b..8202845827 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + namespace Azure.DataApiBuilder.Config.ObjectModel; /// @@ -8,16 +11,97 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// public record DmlToolsConfig { + /// + /// Default value for all tools when not specified + /// + public const bool DEFAULT_ENABLED = true; + + /// + /// Indicates if all tools are enabled/disabled uniformly + /// public bool AllToolsEnabled { get; init; } + + /// + /// Whether describe-entities tool is enabled + /// public bool? DescribeEntities { get; init; } + + /// + /// Whether create-record tool is enabled + /// public bool? CreateRecord { get; init; } + + /// + /// Whether read-records tool is enabled + /// public bool? ReadRecords { get; init; } + + /// + /// Whether update-record tool is enabled + /// public bool? UpdateRecord { get; init; } + + /// + /// Whether delete-record tool is enabled + /// public bool? DeleteRecord { get; init; } + + /// + /// Whether execute-record tool is enabled + /// public bool? ExecuteRecord { get; init; } + [JsonConstructor] + public DmlToolsConfig( + bool? allToolsEnabled = null, + bool? describeEntities = null, + bool? createRecord = null, + bool? readRecords = null, + bool? updateRecord = null, + bool? deleteRecord = null, + bool? executeRecord = null) + { + AllToolsEnabled = allToolsEnabled ?? DEFAULT_ENABLED; + + if (describeEntities is not null) + { + DescribeEntities = describeEntities; + UserProvidedDescribeEntities = true; + } + + if (createRecord is not null) + { + CreateRecord = createRecord; + UserProvidedCreateRecord = true; + } + + if (readRecords is not null) + { + ReadRecords = readRecords; + UserProvidedReadRecords = true; + } + + if (updateRecord is not null) + { + UpdateRecord = updateRecord; + UserProvidedUpdateRecord = true; + } + + if (deleteRecord is not null) + { + DeleteRecord = deleteRecord; + UserProvidedDeleteRecord = true; + } + + if (executeRecord is not null) + { + ExecuteRecord = executeRecord; + UserProvidedExecuteRecord = true; + } + } + /// - /// Creates a DmlToolsConfig with all tools enabled/disabled + /// Creates a DmlToolsConfig with all tools set to the same state /// public static DmlToolsConfig FromBoolean(bool enabled) { @@ -34,8 +118,15 @@ public static DmlToolsConfig FromBoolean(bool enabled) } /// - /// Checks if a specific tool is enabled + /// Creates a default DmlToolsConfig with all tools enabled + /// + public static DmlToolsConfig Default => FromBoolean(DEFAULT_ENABLED); + + /// + /// Checks if a specific tool is enabled based on its name /// + /// The name of the tool to check + /// True if the tool is enabled, false otherwise public bool IsToolEnabled(string toolName) { return toolName switch @@ -46,7 +137,55 @@ public bool IsToolEnabled(string toolName) "update-record" => UpdateRecord ?? AllToolsEnabled, "delete-record" => DeleteRecord ?? AllToolsEnabled, "execute-record" => ExecuteRecord ?? AllToolsEnabled, - _ => false + _ => AllToolsEnabled }; } + + /// + /// Flag which informs CLI and JSON serializer whether to write describe-entities + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(DescribeEntities))] + public bool UserProvidedDescribeEntities { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write create-record + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(CreateRecord))] + public bool UserProvidedCreateRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write read-records + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(ReadRecords))] + public bool UserProvidedReadRecords { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write update-record + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(UpdateRecord))] + public bool UserProvidedUpdateRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write delete-record + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(DeleteRecord))] + public bool UserProvidedDeleteRecord { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write execute-record + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(ExecuteRecord))] + public bool UserProvidedExecuteRecord { get; init; } = false; } diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs index 8b6156b712..d99519b857 100644 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -38,7 +37,7 @@ public McpDmlToolsOptions( } else { - this.DescribeEntities = false; + this.DescribeEntities = true; } if (CreateRecord is not null) @@ -48,7 +47,7 @@ public McpDmlToolsOptions( } else { - this.CreateRecord = false; + this.CreateRecord = true; } if (ReadRecords is not null) @@ -58,7 +57,7 @@ public McpDmlToolsOptions( } else { - this.ReadRecords = false; + this.ReadRecords = true; } if (UpdateRecord is not null) @@ -68,7 +67,7 @@ public McpDmlToolsOptions( } else { - this.UpdateRecord = false; + this.UpdateRecord = true; } if (DeleteRecord is not null) @@ -78,7 +77,7 @@ public McpDmlToolsOptions( } else { - this.DeleteRecord = false; + this.DeleteRecord = true; } if (ExecuteRecord is not null) @@ -88,7 +87,7 @@ public McpDmlToolsOptions( } else { - this.ExecuteRecord = false; + this.ExecuteRecord = true; } } @@ -99,7 +98,6 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(DescribeEntities))] public bool UserProvidedDescribeEntities { get; init; } = false; /// @@ -109,7 +107,6 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(CreateRecord))] public bool UserProvidedCreateRecord { get; init; } = false; /// @@ -119,7 +116,6 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ReadRecords))] public bool UserProvidedReadRecords { get; init; } = false; /// @@ -129,8 +125,8 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(UpdateRecord))] public bool UserProvidedUpdateRecord { get; init; } = false; + /// /// Flag which informs CLI and JSON serializer whether to write delete-record /// property and value to the runtime config file. @@ -138,7 +134,6 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(DeleteRecord))] public bool UserProvidedDeleteRecord { get; init; } = false; /// @@ -148,7 +143,6 @@ public McpDmlToolsOptions( /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ExecuteRecord))] public bool UserProvidedExecuteRecord { get; init; } = false; } From 2423646766c8282f6ec2b681496a58fb33380add Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 23 Sep 2025 21:36:42 +0530 Subject: [PATCH 41/63] rename execute-record to execute-entity --- schemas/dab.draft.schema.json | 4 ++-- src/Cli/Commands/ConfigureOptions.cs | 8 +++---- src/Cli/ConfigGenerator.cs | 10 ++++----- .../DmlToolsConfigConverterFactory.cs | 14 ++++++------ src/Config/ObjectModel/DmlToolsConfig.cs | 22 +++++++++---------- src/Config/ObjectModel/McpDmlToolsOptions.cs | 18 +++++++-------- src/Service.Tests/dab-config.MsSql.json | 11 ++-------- 7 files changed, 40 insertions(+), 47 deletions(-) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 32d4180dc1..b348ac4a4f 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -289,9 +289,9 @@ "description": "Enable/disable the delete-record tool.", "default": false }, - "execute-record": { + "execute-entity": { "type": "boolean", - "description": "Enable/disable the execute-record tool.", + "description": "Enable/disable the execute-entity tool.", "default": false } } diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 4ec1d4734c..60cb12c3f8 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -44,7 +44,7 @@ public ConfigureOptions( bool? runtimeMcpDmlToolsReadRecordsEnabled = null, bool? runtimeMcpDmlToolsUpdateRecordEnabled = null, bool? runtimeMcpDmlToolsDeleteRecordEnabled = null, - bool? runtimeMcpDmlToolsExecuteRecordEnabled = null, + bool? runtimeMcpDmlToolsExecuteEntityEnabled = null, bool? runtimeCacheEnabled = null, int? runtimeCacheTtl = null, HostMode? runtimeHostMode = null, @@ -99,7 +99,7 @@ public ConfigureOptions( RuntimeMcpDmlToolsReadRecordsEnabled = runtimeMcpDmlToolsReadRecordsEnabled; RuntimeMcpDmlToolsUpdateRecordEnabled = runtimeMcpDmlToolsUpdateRecordEnabled; RuntimeMcpDmlToolsDeleteRecordEnabled = runtimeMcpDmlToolsDeleteRecordEnabled; - RuntimeMcpDmlToolsExecuteRecordEnabled = runtimeMcpDmlToolsExecuteRecordEnabled; + RuntimeMcpDmlToolsExecuteEntityEnabled = runtimeMcpDmlToolsExecuteEntityEnabled; // Cache RuntimeCacheEnabled = runtimeCacheEnabled; RuntimeCacheTTL = runtimeCacheTtl; @@ -198,8 +198,8 @@ public ConfigureOptions( [Option("runtime.mcp.dml-tools.delete-record.enabled", Required = false, HelpText = "Enable DAB's MCP delete record tool. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsDeleteRecordEnabled { get; } - [Option("runtime.mcp.dml-tools.execute-record.enabled", Required = false, HelpText = "Enable DAB's MCP execute record tool. Default: true (boolean).")] - public bool? RuntimeMcpDmlToolsExecuteRecordEnabled { get; } + [Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute entity tool. Default: true (boolean).")] + public bool? RuntimeMcpDmlToolsExecuteEntityEnabled { get; } [Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")] public bool? RuntimeCacheEnabled { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index db8e122d8c..a0b0d47d49 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1035,7 +1035,7 @@ private static bool TryUpdateConfiguredMcpValues( bool? readRecord = currentDmlTools?.ReadRecords; bool? updateRecord = currentDmlTools?.UpdateRecord; bool? deleteRecord = currentDmlTools?.DeleteRecord; - bool? executeRecord = currentDmlTools?.ExecuteRecord; + bool? executeEntity = currentDmlTools?.ExecuteEntity; updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled; if (updatedValue != null) @@ -1077,12 +1077,12 @@ private static bool TryUpdateConfiguredMcpValues( _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); } - updatedValue = options?.RuntimeMcpDmlToolsExecuteRecordEnabled; + updatedValue = options?.RuntimeMcpDmlToolsExecuteEntityEnabled; if (updatedValue != null) { - executeRecord = (bool)updatedValue; + executeEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Entity as '{updatedValue}'", updatedValue); } if (hasToolUpdates) @@ -1097,7 +1097,7 @@ private static bool TryUpdateConfiguredMcpValues( ReadRecords = readRecord, UpdateRecord = updateRecord, DeleteRecord = deleteRecord, - ExecuteRecord = executeRecord + ExecuteEntity = executeEntity } }; } diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index 1831825afd..fd261185f7 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -55,7 +55,7 @@ private class DmlToolsConfigConverter : JsonConverter bool? readRecord = null; bool? updateRecord = null; bool? deleteRecord = null; - bool? executeRecord = null; + bool? executeEntity = null; while (reader.Read()) { @@ -68,7 +68,7 @@ private class DmlToolsConfigConverter : JsonConverter readRecords: readRecord, updateRecord: updateRecord, deleteRecord: deleteRecord, - executeRecord: executeRecord); + executeEntity: executeEntity); } string? property = reader.GetString(); @@ -91,8 +91,8 @@ private class DmlToolsConfigConverter : JsonConverter case "delete-record": deleteRecord = reader.GetBoolean(); break; - case "execute-record": - executeRecord = reader.GetBoolean(); + case "execute-entity": + executeEntity = reader.GetBoolean(); break; default: throw new JsonException($"Unexpected property '{property}' in dml-tools configuration."); @@ -122,7 +122,7 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer value.UserProvidedReadRecords || value.UserProvidedUpdateRecord || value.UserProvidedDeleteRecord || - value.UserProvidedExecuteRecord; + value.UserProvidedExecuteEntity; if (!hasIndividualSettings) { @@ -164,9 +164,9 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer writer.WriteBoolean("delete-record", value.DeleteRecord.Value); } - if (value.UserProvidedExecuteRecord) + if (value.UserProvidedExecuteEntity) { - writer.WriteBoolean("execute-record", value.ExecuteRecord.Value); + writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); } writer.WriteEndObject(); diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index 8202845827..9b36d227dc 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -47,9 +47,9 @@ public record DmlToolsConfig public bool? DeleteRecord { get; init; } /// - /// Whether execute-record tool is enabled + /// Whether execute-entity tool is enabled /// - public bool? ExecuteRecord { get; init; } + public bool? ExecuteEntity { get; init; } [JsonConstructor] public DmlToolsConfig( @@ -59,7 +59,7 @@ public DmlToolsConfig( bool? readRecords = null, bool? updateRecord = null, bool? deleteRecord = null, - bool? executeRecord = null) + bool? executeEntity = null) { AllToolsEnabled = allToolsEnabled ?? DEFAULT_ENABLED; @@ -93,10 +93,10 @@ public DmlToolsConfig( UserProvidedDeleteRecord = true; } - if (executeRecord is not null) + if (executeEntity is not null) { - ExecuteRecord = executeRecord; - UserProvidedExecuteRecord = true; + ExecuteEntity = executeEntity; + UserProvidedExecuteEntity = true; } } @@ -113,7 +113,7 @@ public static DmlToolsConfig FromBoolean(bool enabled) ReadRecords = null, UpdateRecord = null, DeleteRecord = null, - ExecuteRecord = null + ExecuteEntity = null }; } @@ -136,7 +136,7 @@ public bool IsToolEnabled(string toolName) "read-records" => ReadRecords ?? AllToolsEnabled, "update-record" => UpdateRecord ?? AllToolsEnabled, "delete-record" => DeleteRecord ?? AllToolsEnabled, - "execute-record" => ExecuteRecord ?? AllToolsEnabled, + "execute-entity" => ExecuteEntity ?? AllToolsEnabled, _ => AllToolsEnabled }; } @@ -182,10 +182,10 @@ public bool IsToolEnabled(string toolName) public bool UserProvidedDeleteRecord { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write execute-record + /// Flag which informs CLI and JSON serializer whether to write execute-entity /// property/value to the runtime config file. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - [MemberNotNullWhen(true, nameof(ExecuteRecord))] - public bool UserProvidedExecuteRecord { get; init; } = false; + [MemberNotNullWhen(true, nameof(ExecuteEntity))] + public bool UserProvidedExecuteEntity { get; init; } = false; } diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs index d99519b857..36b8260681 100644 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ b/src/Config/ObjectModel/McpDmlToolsOptions.cs @@ -20,7 +20,7 @@ public record McpDmlToolsOptions public bool DeleteRecord { get; init; } - public bool ExecuteRecord { get; init; } + public bool ExecuteEntity { get; init; } public McpDmlToolsOptions( bool? DescribeEntities = null, @@ -28,7 +28,7 @@ public McpDmlToolsOptions( bool? ReadRecords = null, bool? UpdateRecord = null, bool? DeleteRecord = null, - bool? ExecuteRecord = null) + bool? ExecuteEntity = null) { if (DescribeEntities is not null) { @@ -80,14 +80,14 @@ public McpDmlToolsOptions( this.DeleteRecord = true; } - if (ExecuteRecord is not null) + if (ExecuteEntity is not null) { - this.ExecuteRecord = (bool)ExecuteRecord; - UserProvidedExecuteRecord = true; + this.ExecuteEntity = (bool)ExecuteEntity; + UserProvidedExecuteEntity = true; } else { - this.ExecuteRecord = true; + this.ExecuteEntity = true; } } @@ -137,12 +137,12 @@ public McpDmlToolsOptions( public bool UserProvidedDeleteRecord { get; init; } = false; /// - /// Flag which informs CLI and JSON serializer whether to write execute-record + /// Flag which informs CLI and JSON serializer whether to write execute-entity /// property and value to the runtime config file. - /// When user doesn't provide the execute-record property/value, which signals DAB to use the default, + /// When user doesn't provide the execute-entity property/value, which signals DAB to use the default, /// the DAB CLI should not write the default value to a serialized config. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedExecuteRecord { get; init; } = false; + public bool UserProvidedExecuteEntity { get; init; } = false; } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 101d6514c1..5a5f6c3c1c 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", + "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", "options": { "set-session-context": true } @@ -26,14 +26,7 @@ "mcp": { "enabled": true, "path": "/mcp", - "dml-tools": { - "describe-entities": true, - "create-record": true, - "read-records": true, - "update-record": false, - "delete-record": false, - "execute-record": true - } + "dml-tools": true }, "host": { "cors": { From 3f0cdd7dc2c69e1a21b5447706a7f6309868745e Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 24 Sep 2025 12:30:57 +0530 Subject: [PATCH 42/63] review comments- nits, null checks --- .../Core/McpServerConfiguration.cs | 4 ++-- src/Cli/ConfigGenerator.cs | 12 ++++++------ .../Converters/DmlToolsConfigConverterFactory.cs | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index 549d50bb64..9b602f5474 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -55,7 +55,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se throw new McpException("Tool name is required."); } - if (!toolRegistry.TryGetTool(toolName, out IMcpTool? tool) || tool == null) + if (!toolRegistry.TryGetTool(toolName, out IMcpTool? tool)) { throw new McpException($"Unknown tool: '{toolName}'"); } @@ -81,7 +81,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se throw new InvalidOperationException("Service provider is not available in the request context."); } - return await tool.ExecuteAsync(arguments, request.Services, ct); + return await tool!.ExecuteAsync(arguments, request.Services, ct); } finally { diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index a0b0d47d49..27412f3256 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -1042,7 +1042,7 @@ private static bool TryUpdateConfiguredMcpValues( { describeEntities = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Describe-Entities as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.describe-entities as '{updatedValue}'", updatedValue); } updatedValue = options?.RuntimeMcpDmlToolsCreateRecordEnabled; @@ -1050,7 +1050,7 @@ private static bool TryUpdateConfiguredMcpValues( { createRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Create-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.create-record as '{updatedValue}'", updatedValue); } updatedValue = options?.RuntimeMcpDmlToolsReadRecordsEnabled; @@ -1058,7 +1058,7 @@ private static bool TryUpdateConfiguredMcpValues( { readRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.read-records as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.read-records as '{updatedValue}'", updatedValue); } updatedValue = options?.RuntimeMcpDmlToolsUpdateRecordEnabled; @@ -1066,7 +1066,7 @@ private static bool TryUpdateConfiguredMcpValues( { updateRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Update-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.update-record as '{updatedValue}'", updatedValue); } updatedValue = options?.RuntimeMcpDmlToolsDeleteRecordEnabled; @@ -1074,7 +1074,7 @@ private static bool TryUpdateConfiguredMcpValues( { deleteRecord = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Delete-Record as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.delete-record as '{updatedValue}'", updatedValue); } updatedValue = options?.RuntimeMcpDmlToolsExecuteEntityEnabled; @@ -1082,7 +1082,7 @@ private static bool TryUpdateConfiguredMcpValues( { executeEntity = (bool)updatedValue; hasToolUpdates = true; - _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Dml-Tools.Execute-Entity as '{updatedValue}'", updatedValue); + _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.execute-entity as '{updatedValue}'", updatedValue); } if (hasToolUpdates) diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index fd261185f7..9f780e2811 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -52,7 +52,7 @@ private class DmlToolsConfigConverter : JsonConverter { bool? describeEntities = null; bool? createRecord = null; - bool? readRecord = null; + bool? readRecords = null; bool? updateRecord = null; bool? deleteRecord = null; bool? executeEntity = null; @@ -65,7 +65,7 @@ private class DmlToolsConfigConverter : JsonConverter allToolsEnabled: null, describeEntities: describeEntities, createRecord: createRecord, - readRecords: readRecord, + readRecords: readRecords, updateRecord: updateRecord, deleteRecord: deleteRecord, executeEntity: executeEntity); @@ -83,7 +83,7 @@ private class DmlToolsConfigConverter : JsonConverter createRecord = reader.GetBoolean(); break; case "read-records": - readRecord = reader.GetBoolean(); + readRecords = reader.GetBoolean(); break; case "update-record": updateRecord = reader.GetBoolean(); From 4f18f6e74fea5c0881e18a1beeafe6aaba369e90 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 24 Sep 2025 12:31:30 +0530 Subject: [PATCH 43/63] Update src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs Co-authored-by: Aniruddh Munde --- src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index 9b602f5474..a0b6178c21 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -21,7 +21,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se { services.AddMcpServer(options => { - options.ServerInfo = new() { Name = "Data API Builder MCP Server", Version = "1.0.0" }; + options.ServerInfo = new() { Name = "Data API builder MCP Server", Version = "1.0.0" }; options.Capabilities = new() { Tools = new() From f71232a5356b529670d41f18e219ad61f2366bcd Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 24 Sep 2025 13:17:26 +0530 Subject: [PATCH 44/63] use DEFAULT_PATH constant for mcp endpoint --- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 666becccbf..a1dcdae014 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -14,7 +14,7 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; public record McpOptions { public bool Enabled { get; init; } = true; - public string Path { get; init; } = "/mcp"; + public string Path { get; init; } = McpRuntimeOptions.DEFAULT_PATH; public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; } From 629d3fbc35759832ade781cea9e01a7f2a4eddd9 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 24 Sep 2025 16:35:17 +0530 Subject: [PATCH 45/63] Fixed unit tests --- .../DmlToolsConfigConverterFactory.cs | 125 +++++++++++------- src/Config/ObjectModel/McpRuntimeOptions.cs | 73 ++++++---- src/Service.Tests/dab-config.MsSql.json | 2 +- 3 files changed, 125 insertions(+), 75 deletions(-) diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index 9f780e2811..6ec748ae90 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -29,11 +29,12 @@ private class DmlToolsConfigConverter : JsonConverter /// /// Reads DmlToolsConfig from JSON which can be either: /// - A boolean: all tools are enabled/disabled - /// - An object: individual tool settings + /// - An object: individual tool settings (unspecified tools default to true) /// - Null/undefined: defaults to all tools enabled (true) /// public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + // Handle null if (reader.TokenType is JsonTokenType.Null) { // Return default config with all tools enabled @@ -50,6 +51,7 @@ private class DmlToolsConfigConverter : JsonConverter // Handle object format if (reader.TokenType is JsonTokenType.StartObject) { + // When using object format, unspecified tools default to true bool? describeEntities = null; bool? createRecord = null; bool? readRecords = null; @@ -61,58 +63,79 @@ private class DmlToolsConfigConverter : JsonConverter { if (reader.TokenType is JsonTokenType.EndObject) { - return new DmlToolsConfig( - allToolsEnabled: null, - describeEntities: describeEntities, - createRecord: createRecord, - readRecords: readRecords, - updateRecord: updateRecord, - deleteRecord: deleteRecord, - executeEntity: executeEntity); + break; } - string? property = reader.GetString(); - reader.Read(); - - switch (property) + if (reader.TokenType is JsonTokenType.PropertyName) { - case "describe-entities": - describeEntities = reader.GetBoolean(); - break; - case "create-record": - createRecord = reader.GetBoolean(); - break; - case "read-records": - readRecords = reader.GetBoolean(); - break; - case "update-record": - updateRecord = reader.GetBoolean(); - break; - case "delete-record": - deleteRecord = reader.GetBoolean(); - break; - case "execute-entity": - executeEntity = reader.GetBoolean(); - break; - default: - throw new JsonException($"Unexpected property '{property}' in dml-tools configuration."); + string? property = reader.GetString(); + reader.Read(); + + // Handle the property value + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + bool value = reader.GetBoolean(); + + switch (property?.ToLowerInvariant()) + { + case "describe-entities": + describeEntities = value; + break; + case "create-record": + createRecord = value; + break; + case "read-records": + readRecords = value; + break; + case "update-record": + updateRecord = value; + break; + case "delete-record": + deleteRecord = value; + break; + case "execute-entity": + case "execute-record": // Support both names for backward compatibility + executeEntity = value; + break; + default: + // Skip unknown properties + break; + } + } + else + { + // Skip non-boolean values + reader.Skip(); + } } } + + // Create the config with specified values + // Unspecified values (null) will default to true in the DmlToolsConfig constructor + return new DmlToolsConfig( + allToolsEnabled: null, + describeEntities: describeEntities, + createRecord: createRecord, + readRecords: readRecords, + updateRecord: updateRecord, + deleteRecord: deleteRecord, + executeEntity: executeEntity); } - throw new JsonException("DML Tools configuration is missing closing brace."); + // For any other unexpected token type, return default (all enabled) + return DmlToolsConfig.Default; } /// /// Writes DmlToolsConfig to JSON. - /// Only writes user-provided properties to avoid bloating the config file. - /// If no individual settings provided, writes as boolean. + /// - If all tools have the same value, writes as boolean + /// - Otherwise writes as object with only user-provided properties /// public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSerializerOptions options) { if (value is null) { - // Don't write null - omit the property entirely + writer.WriteNullValue(); return; } @@ -124,47 +147,47 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer value.UserProvidedDeleteRecord || value.UserProvidedExecuteEntity; - if (!hasIndividualSettings) + if (!hasIndividualSettings && value.AllToolsEnabled == DmlToolsConfig.DEFAULT_ENABLED) { - // Only write the boolean value if it's not the default (true) - // This prevents writing "dml-tools": true when it's the default - if (value.AllToolsEnabled != DmlToolsConfig.DEFAULT_ENABLED) - { - writer.WriteBooleanValue(value.AllToolsEnabled); - } - // Otherwise, don't write anything (property will be omitted) + // If using default (all true), write as boolean true + writer.WriteBooleanValue(true); + } + else if (!hasIndividualSettings) + { + // If all tools have the same non-default value, write as boolean + writer.WriteBooleanValue(value.AllToolsEnabled); } else { // Write as object with only user-provided properties writer.WriteStartObject(); - if (value.UserProvidedDescribeEntities) + if (value.UserProvidedDescribeEntities && value.DescribeEntities.HasValue) { writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); } - if (value.UserProvidedCreateRecord) + if (value.UserProvidedCreateRecord && value.CreateRecord.HasValue) { writer.WriteBoolean("create-record", value.CreateRecord.Value); } - if (value.UserProvidedReadRecords) + if (value.UserProvidedReadRecords && value.ReadRecords.HasValue) { writer.WriteBoolean("read-records", value.ReadRecords.Value); } - if (value.UserProvidedUpdateRecord) + if (value.UserProvidedUpdateRecord && value.UpdateRecord.HasValue) { writer.WriteBoolean("update-record", value.UpdateRecord.Value); } - if (value.UserProvidedDeleteRecord) + if (value.UserProvidedDeleteRecord && value.DeleteRecord.HasValue) { writer.WriteBoolean("delete-record", value.DeleteRecord.Value); } - if (value.UserProvidedExecuteEntity) + if (value.UserProvidedExecuteEntity && value.ExecuteEntity.HasValue) { writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); } diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 0950936728..923b499797 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -4,33 +4,60 @@ using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config.Converters; -namespace Azure.DataApiBuilder.Config.ObjectModel; - -/// -/// Global MCP endpoint runtime configuration. -/// -public record McpRuntimeOptions +namespace Azure.DataApiBuilder.Config.ObjectModel { - public const string DEFAULT_PATH = "/mcp"; + /// + /// Runtime configuration options for MCP (Model Context Protocol) + /// + public record McpRuntimeOptions + { + public const string DEFAULT_PATH = "/mcp"; - [JsonPropertyName("enabled")] - public bool Enabled { get; init; } + /// + /// Whether MCP endpoints are enabled + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; init; } = true; - [JsonPropertyName("path")] - public string? Path { get; init; } + /// + /// The path where MCP endpoints will be exposed + /// + [JsonPropertyName("path")] + public string? Path { get; init; } = DEFAULT_PATH; - [JsonPropertyName("dml-tools")] - [JsonConverter(typeof(DmlToolsConfigConverterFactory))] - public DmlToolsConfig? DmlTools { get; init; } + /// + /// Configuration for DML tools + /// + [JsonPropertyName("dml-tools")] + [JsonConverter(typeof(DmlToolsConfigConverterFactory))] + public DmlToolsConfig? DmlTools { get; init; } - [JsonConstructor] - public McpRuntimeOptions( - bool Enabled = true, - string? Path = null, - DmlToolsConfig? DmlTools = null) - { - this.Enabled = Enabled; - this.Path = Path ?? DEFAULT_PATH; - this.DmlTools = DmlTools; + /// + /// Default parameterless constructor + /// + public McpRuntimeOptions() + { + } + + /// + /// Constructor with all parameters + /// + [JsonConstructor] + public McpRuntimeOptions( + bool Enabled = true, + string? Path = null, + DmlToolsConfig? DmlTools = null) + { + this.Enabled = Enabled; + this.Path = Path ?? DEFAULT_PATH; + this.DmlTools = DmlTools; + } + + /// + /// Constructor for backward compatibility with two parameters + /// + public McpRuntimeOptions(bool enabled, string? path) : this(Enabled: enabled, Path: path, DmlTools: null) + { + } } } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 5a5f6c3c1c..830dc4336e 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", + "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", "options": { "set-session-context": true } From b3040ad0527d41ed1ac7e5cc62475f0ea89004d6 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 24 Sep 2025 16:41:01 +0530 Subject: [PATCH 46/63] fix formatting --- src/Config/Converters/DmlToolsConfigConverterFactory.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs index 6ec748ae90..a04994e44c 100644 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ b/src/Config/Converters/DmlToolsConfigConverterFactory.cs @@ -75,7 +75,7 @@ private class DmlToolsConfigConverter : JsonConverter if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) { bool value = reader.GetBoolean(); - + switch (property?.ToLowerInvariant()) { case "describe-entities": @@ -94,7 +94,6 @@ private class DmlToolsConfigConverter : JsonConverter deleteRecord = value; break; case "execute-entity": - case "execute-record": // Support both names for backward compatibility executeEntity = value; break; default: From b6235894dda00befb5e4b2fcfc6f8cfb2b30bcb2 Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Wed, 24 Sep 2025 16:34:02 -0700 Subject: [PATCH 47/63] Fix the incorrectly changed log messages --- src/Cli/ConfigGenerator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 27412f3256..886447b256 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -2396,7 +2396,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyMaxCount.Value < 1) { - _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); + _logger.LogError("Failed to update configuration with runtime.azure-key-vault.retry-policy.max-count. Value must be a positive integer greater than 0."); return false; } @@ -2411,7 +2411,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyDelaySeconds.Value < 1) { - _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); + _logger.LogError("Failed to update configuration with runtime.azure-key-vault.retry-policy.delay-seconds. Value must be a positive integer greater than 0."); return false; } @@ -2426,7 +2426,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value < 1) { - _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); + _logger.LogError("Failed to update configuration with runtime.azure-key-vault.retry-policy.max-delay-seconds. Value must be a positive integer greater than 0."); return false; } @@ -2441,7 +2441,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { if (options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value < 1) { - _logger.LogError("Failed to update configuration with runtime.telemetry.azure-log-analytics.flush-interval-seconds. Value must be a positive integer greater than 0."); + _logger.LogError("Failed to update configuration with runtime.azure-key-vault.retry-policy.network-timeout-seconds. Value must be a positive integer greater than 0."); return false; } From 64b1371c9930e28fdc45e558471602aceb88f5f3 Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Wed, 24 Sep 2025 16:39:52 -0700 Subject: [PATCH 48/63] Remove nullable Path property --- src/Config/ObjectModel/McpRuntimeOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 923b499797..1b3af9ad3b 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -23,7 +23,7 @@ public record McpRuntimeOptions /// The path where MCP endpoints will be exposed /// [JsonPropertyName("path")] - public string? Path { get; init; } = DEFAULT_PATH; + public string Path { get; init; } = DEFAULT_PATH; /// /// Configuration for DML tools From f0e6e85853973336371d8da80f1cac02b49ae08c Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Wed, 24 Sep 2025 16:42:34 -0700 Subject: [PATCH 49/63] Apply suggestions from code review function parameters should be camelCased. --- src/Config/ObjectModel/McpRuntimeOptions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 1b3af9ad3b..727fe45a27 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -44,13 +44,13 @@ public McpRuntimeOptions() /// [JsonConstructor] public McpRuntimeOptions( - bool Enabled = true, - string? Path = null, - DmlToolsConfig? DmlTools = null) + bool enabled = true, + string? path = null, + DmlToolsConfig? dmlTools = null) { - this.Enabled = Enabled; - this.Path = Path ?? DEFAULT_PATH; - this.DmlTools = DmlTools; + this.Enabled = enabled; + this.Path = path ?? DEFAULT_PATH; + this.DmlTools = dmlTools; } /// From 3b893c866359f0e0e439d089eed5f4ced13e8e18 Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Wed, 24 Sep 2025 17:06:20 -0700 Subject: [PATCH 50/63] Keeping the parameters UpperCases to align with other RuntimeOptions constructor parameters. --- src/Config/ObjectModel/McpRuntimeOptions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 727fe45a27..1b3af9ad3b 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -44,13 +44,13 @@ public McpRuntimeOptions() /// [JsonConstructor] public McpRuntimeOptions( - bool enabled = true, - string? path = null, - DmlToolsConfig? dmlTools = null) + bool Enabled = true, + string? Path = null, + DmlToolsConfig? DmlTools = null) { - this.Enabled = enabled; - this.Path = path ?? DEFAULT_PATH; - this.DmlTools = dmlTools; + this.Enabled = Enabled; + this.Path = Path ?? DEFAULT_PATH; + this.DmlTools = DmlTools; } /// From 2eacd6463c553af24995fd8b671d9929bc1d197b Mon Sep 17 00:00:00 2001 From: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Date: Thu, 25 Sep 2025 04:17:43 +0000 Subject: [PATCH 51/63] Draft PR: Service Tests Fix (#2884) ## Why make this change? - Fixes service tests for mcp-core PR ## What is this change? - It creates the JsonConverterFactory that is needed to serialize and deserialize the MCP properties correctly. - Fixes tests to ensure that the serialization/deserialization works as intended. ## How was this tested? - [ ] Integration Tests - [X] Unit Tests --- src/Cli.Tests/ModuleInitializer.cs | 4 + .../Converters/DmlToolsConfigConverter.cs | 182 ++++++++++++++++ .../DmlToolsConfigConverterFactory.cs | 198 ------------------ .../McpRuntimeOptionsConverterFactory.cs | 140 +++++++++++++ src/Config/DataApiBuilderException.cs | 4 + src/Config/ObjectModel/DmlToolsConfig.cs | 18 +- src/Config/ObjectModel/McpRuntimeOptions.cs | 92 ++++---- src/Config/ObjectModel/RuntimeConfig.cs | 14 +- src/Config/RuntimeConfigLoader.cs | 3 +- .../Configurations/RuntimeConfigValidator.cs | 1 + src/Core/Services/RestService.cs | 8 + .../Configuration/HealthEndpointTests.cs | 4 +- src/Service.Tests/ModuleInitializer.cs | 6 + ...ReadingRuntimeConfigForCosmos.verified.txt | 4 + ...tReadingRuntimeConfigForMsSql.verified.txt | 14 ++ ...tReadingRuntimeConfigForMySql.verified.txt | 4 + ...ingRuntimeConfigForPostgreSql.verified.txt | 4 + src/Service.Tests/dab-config.MsSql.json | 12 +- src/Service/HealthCheck/HealthCheckHelper.cs | 1 + .../HealthCheck/Model/ConfigurationDetails.cs | 3 + src/Service/Startup.cs | 1 - 21 files changed, 444 insertions(+), 273 deletions(-) create mode 100644 src/Config/Converters/DmlToolsConfigConverter.cs delete mode 100644 src/Config/Converters/DmlToolsConfigConverterFactory.cs create mode 100644 src/Config/Converters/McpRuntimeOptionsConverterFactory.cs diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 295757dc36..e00dc00a89 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -73,6 +73,8 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.IsRestEnabled); // Ignore the IsMcpEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); + // Ignore the McpDmlTools as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.McpDmlTools); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. @@ -109,6 +111,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.UserProvidedDepthLimit); // Ignore EnableLegacyDateTimeScalar as that's not serialized in our config file. VerifierSettings.IgnoreMember(options => options.EnableLegacyDateTimeScalar); + // Ignore UserProvidedPath as that's not serialized in our config file. + VerifierSettings.IgnoreMember(options => options.UserProvidedPath); // Customise the path where we store snapshots, so they are easier to locate in a PR review. VerifyBase.DerivePathInfo( (sourceFile, projectDirectory, type, method) => new( diff --git a/src/Config/Converters/DmlToolsConfigConverter.cs b/src/Config/Converters/DmlToolsConfigConverter.cs new file mode 100644 index 0000000000..e635029815 --- /dev/null +++ b/src/Config/Converters/DmlToolsConfigConverter.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// JSON converter for DmlToolsConfig that handles both boolean and object formats. +/// +internal class DmlToolsConfigConverter : JsonConverter +{ + /// + /// Reads DmlToolsConfig from JSON which can be either: + /// - A boolean: all tools are enabled/disabled + /// - An object: individual tool settings (unspecified tools default to true) + /// - Null/undefined: defaults to all tools enabled (true) + /// + public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Handle null + if (reader.TokenType is JsonTokenType.Null) + { + // Return default config with all tools enabled + return DmlToolsConfig.Default; + } + + // Handle boolean format: "dml-tools": true/false + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + bool enabled = reader.GetBoolean(); + return DmlToolsConfig.FromBoolean(enabled); + } + + // Handle object format + if (reader.TokenType is JsonTokenType.StartObject) + { + // When using object format, unspecified tools default to true + bool? describeEntities = null; + bool? createRecord = null; + bool? readRecords = null; + bool? updateRecord = null; + bool? deleteRecord = null; + bool? executeEntity = null; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType is JsonTokenType.PropertyName) + { + string? property = reader.GetString(); + reader.Read(); + + // Handle the property value + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + bool value = reader.GetBoolean(); + + switch (property?.ToLowerInvariant()) + { + case "describe-entities": + describeEntities = value; + break; + case "create-record": + createRecord = value; + break; + case "read-records": + readRecords = value; + break; + case "update-record": + updateRecord = value; + break; + case "delete-record": + deleteRecord = value; + break; + case "execute-entity": + executeEntity = value; + break; + default: + // Skip unknown properties + break; + } + } + else + { + // Skip non-boolean values + reader.Skip(); + } + } + } + + // Create the config with specified values + // Unspecified values (null) will default to true in the DmlToolsConfig constructor + return new DmlToolsConfig( + allToolsEnabled: null, + describeEntities: describeEntities, + createRecord: createRecord, + readRecords: readRecords, + updateRecord: updateRecord, + deleteRecord: deleteRecord, + executeEntity: executeEntity); + } + + // For any other unexpected token type, return default (all enabled) + return DmlToolsConfig.Default; + } + + /// + /// Writes DmlToolsConfig to JSON. + /// - If all tools have the same value, writes as boolean + /// - Otherwise writes as object with only user-provided properties + /// + public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + // Check if any individual settings were provided by the user + bool hasIndividualSettings = value.UserProvidedDescribeEntities || + value.UserProvidedCreateRecord || + value.UserProvidedReadRecords || + value.UserProvidedUpdateRecord || + value.UserProvidedDeleteRecord || + value.UserProvidedExecuteEntity; + + // Only write the boolean value if it's provided by user + // This prevents writing "dml-tools": true when it's the default + if (!hasIndividualSettings && value.UserProvidedAllToolsEnabled) + { + writer.WritePropertyName("dml-tools"); + writer.WriteBooleanValue(value.AllToolsEnabled); + } + else + { + writer.WritePropertyName("dml-tools"); + + // Write as object with only user-provided properties + writer.WriteStartObject(); + + if (value.UserProvidedDescribeEntities && value.DescribeEntities.HasValue) + { + writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); + } + + if (value.UserProvidedCreateRecord && value.CreateRecord.HasValue) + { + writer.WriteBoolean("create-record", value.CreateRecord.Value); + } + + if (value.UserProvidedReadRecords && value.ReadRecords.HasValue) + { + writer.WriteBoolean("read-records", value.ReadRecords.Value); + } + + if (value.UserProvidedUpdateRecord && value.UpdateRecord.HasValue) + { + writer.WriteBoolean("update-record", value.UpdateRecord.Value); + } + + if (value.UserProvidedDeleteRecord && value.DeleteRecord.HasValue) + { + writer.WriteBoolean("delete-record", value.DeleteRecord.Value); + } + + if (value.UserProvidedExecuteEntity && value.ExecuteEntity.HasValue) + { + writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/Converters/DmlToolsConfigConverterFactory.cs b/src/Config/Converters/DmlToolsConfigConverterFactory.cs deleted file mode 100644 index a04994e44c..0000000000 --- a/src/Config/Converters/DmlToolsConfigConverterFactory.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using System.Text.Json.Serialization; -using Azure.DataApiBuilder.Config.ObjectModel; - -namespace Azure.DataApiBuilder.Config.Converters; - -/// -/// JSON converter factory for DmlToolsConfig that handles both boolean and object formats. -/// -internal class DmlToolsConfigConverterFactory : JsonConverterFactory -{ - /// - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert.IsAssignableTo(typeof(DmlToolsConfig)); - } - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - return new DmlToolsConfigConverter(); - } - - private class DmlToolsConfigConverter : JsonConverter - { - /// - /// Reads DmlToolsConfig from JSON which can be either: - /// - A boolean: all tools are enabled/disabled - /// - An object: individual tool settings (unspecified tools default to true) - /// - Null/undefined: defaults to all tools enabled (true) - /// - public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // Handle null - if (reader.TokenType is JsonTokenType.Null) - { - // Return default config with all tools enabled - return DmlToolsConfig.Default; - } - - // Handle boolean format: "dml-tools": true/false - if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) - { - bool enabled = reader.GetBoolean(); - return DmlToolsConfig.FromBoolean(enabled); - } - - // Handle object format - if (reader.TokenType is JsonTokenType.StartObject) - { - // When using object format, unspecified tools default to true - bool? describeEntities = null; - bool? createRecord = null; - bool? readRecords = null; - bool? updateRecord = null; - bool? deleteRecord = null; - bool? executeEntity = null; - - while (reader.Read()) - { - if (reader.TokenType is JsonTokenType.EndObject) - { - break; - } - - if (reader.TokenType is JsonTokenType.PropertyName) - { - string? property = reader.GetString(); - reader.Read(); - - // Handle the property value - if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) - { - bool value = reader.GetBoolean(); - - switch (property?.ToLowerInvariant()) - { - case "describe-entities": - describeEntities = value; - break; - case "create-record": - createRecord = value; - break; - case "read-records": - readRecords = value; - break; - case "update-record": - updateRecord = value; - break; - case "delete-record": - deleteRecord = value; - break; - case "execute-entity": - executeEntity = value; - break; - default: - // Skip unknown properties - break; - } - } - else - { - // Skip non-boolean values - reader.Skip(); - } - } - } - - // Create the config with specified values - // Unspecified values (null) will default to true in the DmlToolsConfig constructor - return new DmlToolsConfig( - allToolsEnabled: null, - describeEntities: describeEntities, - createRecord: createRecord, - readRecords: readRecords, - updateRecord: updateRecord, - deleteRecord: deleteRecord, - executeEntity: executeEntity); - } - - // For any other unexpected token type, return default (all enabled) - return DmlToolsConfig.Default; - } - - /// - /// Writes DmlToolsConfig to JSON. - /// - If all tools have the same value, writes as boolean - /// - Otherwise writes as object with only user-provided properties - /// - public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - return; - } - - // Check if any individual settings were provided by the user - bool hasIndividualSettings = value.UserProvidedDescribeEntities || - value.UserProvidedCreateRecord || - value.UserProvidedReadRecords || - value.UserProvidedUpdateRecord || - value.UserProvidedDeleteRecord || - value.UserProvidedExecuteEntity; - - if (!hasIndividualSettings && value.AllToolsEnabled == DmlToolsConfig.DEFAULT_ENABLED) - { - // If using default (all true), write as boolean true - writer.WriteBooleanValue(true); - } - else if (!hasIndividualSettings) - { - // If all tools have the same non-default value, write as boolean - writer.WriteBooleanValue(value.AllToolsEnabled); - } - else - { - // Write as object with only user-provided properties - writer.WriteStartObject(); - - if (value.UserProvidedDescribeEntities && value.DescribeEntities.HasValue) - { - writer.WriteBoolean("describe-entities", value.DescribeEntities.Value); - } - - if (value.UserProvidedCreateRecord && value.CreateRecord.HasValue) - { - writer.WriteBoolean("create-record", value.CreateRecord.Value); - } - - if (value.UserProvidedReadRecords && value.ReadRecords.HasValue) - { - writer.WriteBoolean("read-records", value.ReadRecords.Value); - } - - if (value.UserProvidedUpdateRecord && value.UpdateRecord.HasValue) - { - writer.WriteBoolean("update-record", value.UpdateRecord.Value); - } - - if (value.UserProvidedDeleteRecord && value.DeleteRecord.HasValue) - { - writer.WriteBoolean("delete-record", value.DeleteRecord.Value); - } - - if (value.UserProvidedExecuteEntity && value.ExecuteEntity.HasValue) - { - writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value); - } - - writer.WriteEndObject(); - } - } - } -} diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs new file mode 100644 index 0000000000..db9acfa603 --- /dev/null +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// JSON converter factory for McpRuntimeOptions that handles both boolean and object formats. +/// +internal class McpRuntimeOptionsConverterFactory : JsonConverterFactory +{ + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(McpRuntimeOptions)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new McpRuntimeOptionsConverter(_replaceEnvVar); + } + + internal McpRuntimeOptionsConverterFactory(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + + private class McpRuntimeOptionsConverter : JsonConverter + { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// Whether to replace environment variable with its + /// value or not while deserializing. + internal McpRuntimeOptionsConverter(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + + /// + /// Defines how DAB reads MCP options and defines which values are + /// used to instantiate McpRuntimeOptions. + /// + /// Thrown when improperly formatted MCP options are provided. + public override McpRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) + { + return new McpRuntimeOptions(Enabled: reader.GetBoolean()); + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + DmlToolsConfigConverter dmlToolsConfigConverter = new(); + + bool enabled = true; + string? path = null; + DmlToolsConfig? dmlTools = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new McpRuntimeOptions(enabled, path, dmlTools); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "enabled": + if (reader.TokenType is not JsonTokenType.Null) + { + enabled = reader.GetBoolean(); + } + + break; + + case "path": + if (reader.TokenType is not JsonTokenType.Null) + { + path = reader.DeserializeString(_replaceEnvVar); + } + + break; + + case "dml-tools": + dmlTools = dmlToolsConfigConverter.Read(ref reader, typeToConvert, options); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the MCP Options"); + } + + /// + /// When writing the McpRuntimeOptions back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, McpRuntimeOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteBoolean("enabled", value.Enabled); + + if (value?.UserProvidedPath is true) + { + writer.WritePropertyName("path"); + JsonSerializer.Serialize(writer, value.Path, options); + } + + // Only write the boolean value if it's not the default (true) + // This prevents writing "dml-tools": true when it's the default + if (value?.DmlTools is not null) + { + DmlToolsConfigConverter dmlToolsOptionsConverter = options.GetConverter(typeof(DmlToolsConfig)) as DmlToolsConfigConverter ?? + throw new JsonException("Failed to get mcp.dml-tools options converter"); + + dmlToolsOptionsConverter.Write(writer, value.DmlTools, options); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index 18b0395541..b7696c4deb 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -105,6 +105,10 @@ public enum SubStatusCodes /// GlobalRestEndpointDisabled, /// + /// Global MCP endpoint disabled in runtime configuration. + /// + GlobalMcpEndpointDisabled, + /// /// DataSource not found for multiple db scenario. /// DataSourceNotFound, diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index 9b36d227dc..dc64fd37ef 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -61,7 +61,15 @@ public DmlToolsConfig( bool? deleteRecord = null, bool? executeEntity = null) { - AllToolsEnabled = allToolsEnabled ?? DEFAULT_ENABLED; + if (allToolsEnabled is not null) + { + AllToolsEnabled = allToolsEnabled.Value; + UserProvidedAllToolsEnabled = true; + } + else + { + AllToolsEnabled = DEFAULT_ENABLED; + } if (describeEntities is not null) { @@ -141,6 +149,14 @@ public bool IsToolEnabled(string toolName) }; } + /// + /// Flag which informs CLI and JSON serializer whether to write all-tools-enabled + /// property/value to the runtime config file. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(AllToolsEnabled))] + public bool UserProvidedAllToolsEnabled { get; init; } = false; + /// /// Flag which informs CLI and JSON serializer whether to write describe-entities /// property/value to the runtime config file. diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 1b3af9ad3b..73d695ee4a 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -1,63 +1,63 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config.Converters; -namespace Azure.DataApiBuilder.Config.ObjectModel +namespace Azure.DataApiBuilder.Config.ObjectModel; + +public record McpRuntimeOptions { + public const string DEFAULT_PATH = "/mcp"; + + /// + /// Whether MCP endpoints are enabled + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; init; } = true; + + /// + /// The path where MCP endpoints will be exposed + /// + [JsonPropertyName("path")] + public string Path { get; init; } = DEFAULT_PATH; + /// - /// Runtime configuration options for MCP (Model Context Protocol) + /// Configuration for DML tools /// - public record McpRuntimeOptions + [JsonPropertyName("dml-tools")] + [JsonConverter(typeof(DmlToolsConfigConverter))] + public DmlToolsConfig? DmlTools { get; init; } + + [JsonConstructor] + public McpRuntimeOptions( + bool Enabled = true, + string? Path = null, + DmlToolsConfig? DmlTools = null) { - public const string DEFAULT_PATH = "/mcp"; - - /// - /// Whether MCP endpoints are enabled - /// - [JsonPropertyName("enabled")] - public bool Enabled { get; init; } = true; - - /// - /// The path where MCP endpoints will be exposed - /// - [JsonPropertyName("path")] - public string Path { get; init; } = DEFAULT_PATH; - - /// - /// Configuration for DML tools - /// - [JsonPropertyName("dml-tools")] - [JsonConverter(typeof(DmlToolsConfigConverterFactory))] - public DmlToolsConfig? DmlTools { get; init; } - - /// - /// Default parameterless constructor - /// - public McpRuntimeOptions() - { - } + this.Enabled = Enabled; - /// - /// Constructor with all parameters - /// - [JsonConstructor] - public McpRuntimeOptions( - bool Enabled = true, - string? Path = null, - DmlToolsConfig? DmlTools = null) + if (Path is not null) { - this.Enabled = Enabled; - this.Path = Path ?? DEFAULT_PATH; - this.DmlTools = DmlTools; + this.Path = Path; + UserProvidedPath = true; } - - /// - /// Constructor for backward compatibility with two parameters - /// - public McpRuntimeOptions(bool enabled, string? path) : this(Enabled: enabled, Path: path, DmlTools: null) + else { + this.Path = DEFAULT_PATH; } + + this.DmlTools = DmlTools; } + + /// + /// Flag which informs CLI and JSON serializer whether to write path + /// property and value to the runtime config file. + /// When user doesn't provide the path property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Enabled))] + public bool UserProvidedPath { get; init; } = false; } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a1dcdae014..7d93fa9e08 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -11,18 +11,6 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record McpOptions -{ - public bool Enabled { get; init; } = true; - public string Path { get; init; } = McpRuntimeOptions.DEFAULT_PATH; - public McpDmlTool[] DmlTools { get; init; } = [McpDmlTool.DescribeEntities]; -} - -public enum McpDmlTool -{ - DescribeEntities -} - public record RuntimeConfig { [JsonPropertyName("$schema")] @@ -753,7 +741,7 @@ public LogLevel GetConfiguredLogLevel(string loggerFilter = "") /// public bool IsMcpDmlToolEnabled(string toolName) { - if (Runtime?.Mcp?.Enabled != true || Runtime.Mcp.DmlTools == null) + if (Runtime?.Mcp?.Enabled is not true || Runtime.Mcp.DmlTools is null) { return false; } diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 73f5797e36..f78c32ebc1 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -246,7 +246,8 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityHealthOptionsConvertorFactory()); options.Converters.Add(new RestRuntimeOptionsConverterFactory()); options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new DmlToolsConfigConverterFactory()); + options.Converters.Add(new McpRuntimeOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new DmlToolsConfigConverter()); options.Converters.Add(new EntitySourceConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 82fa7fa0ff..fd8f811c9e 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -735,6 +735,7 @@ public void ValidateGlobalEndpointRouteConfig(RuntimeConfig runtimeConfig) ValidateRestURI(runtimeConfig); ValidateGraphQLURI(runtimeConfig); + ValidateMcpUri(runtimeConfig); // Do not check for conflicts if two of the endpoints are disabled between GraphQL, REST, and MCP. if ((!runtimeConfig.IsRestEnabled && !runtimeConfig.IsGraphQLEnabled) || (!runtimeConfig.IsRestEnabled && !runtimeConfig.IsMcpEnabled) || diff --git a/src/Core/Services/RestService.cs b/src/Core/Services/RestService.cs index 0cf9f8a374..8b1ce14a0b 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -391,6 +391,14 @@ public string GetRouteAfterPathBase(string route) // forward slash '/'. configuredRestPathBase = configuredRestPathBase.Substring(1); + if (route.Contains(_runtimeConfigProvider.GetConfig().McpPath.Substring(1))) + { + throw new DataApiBuilderException( + message: $"Invalid Path for route: {route}.", + statusCode: HttpStatusCode.NotFound, + subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalMcpEndpointDisabled); + } + if (!route.StartsWith(configuredRestPathBase)) { throw new DataApiBuilderException( diff --git a/src/Service.Tests/Configuration/HealthEndpointTests.cs b/src/Service.Tests/Configuration/HealthEndpointTests.cs index 29c2d1a5b2..70e14e0108 100644 --- a/src/Service.Tests/Configuration/HealthEndpointTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointTests.cs @@ -87,8 +87,8 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents( string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" - }; + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index a491cf5f0c..40d030a669 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -77,6 +77,10 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.IsGraphQLEnabled); // Ignore the IsRestEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRestEnabled); + // Ignore the IsMcpEnabled as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsMcpEnabled); + // Ignore the McpDmlTools as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.McpDmlTools); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. @@ -111,6 +115,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.UserProvidedDepthLimit); // Ignore EnableLegacyDateTimeScalar as that's not serialized in our config file. VerifierSettings.IgnoreMember(options => options.EnableLegacyDateTimeScalar); + // Ignore UserProvidedPath as that's not serialized in our config file. + VerifierSettings.IgnoreMember(options => options.UserProvidedPath); // Customise the path where we store snapshots, so they are easier to locate in a PR review. VerifyBase.DerivePathInfo( (sourceFile, projectDirectory, type, method) => new( diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt index 51d8543ed5..420977ed26 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt @@ -17,6 +17,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 51b733b94e..7fcc8ccc39 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -21,6 +21,20 @@ } } }, + Mcp: { + Enabled: true, + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + UserProvidedAllToolsEnabled: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } + }, Host: { Cors: { Origins: [ diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt index 23f67259d4..f34141c964 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt index a534867fee..75490a804b 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt @@ -13,6 +13,10 @@ Path: /graphql, AllowIntrospection: true }, + Mcp: { + Enabled: true, + Path: /mcp + }, Host: { Cors: { Origins: [ diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 830dc4336e..fe47ebe5e2 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2320,17 +2320,7 @@ "source": { "object": "notebooks", "type": "table", - "object-description": "Table containing notebook information", - "parameters" : { - "id": { - "type": "int", - "description": "An integer parameter for testing" - }, - "name": { - "type": "string", - "description": "A string parameter for testing" - } - } + "object-description": "Table containing notebook information" }, "graphql": { "enabled": true, diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 9225c3aeb0..452cb803a9 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -140,6 +140,7 @@ private static void UpdateDabConfigurationDetails(ref ComprehensiveHealthCheckRe { Rest = runtimeConfig.IsRestEnabled, GraphQL = runtimeConfig.IsGraphQLEnabled, + Mcp = runtimeConfig.IsMcpEnabled, Caching = runtimeConfig.IsCachingEnabled, Telemetry = runtimeConfig?.Runtime?.Telemetry != null, Mode = runtimeConfig?.Runtime?.Host?.Mode ?? HostMode.Production, // Modify to runtimeConfig.HostMode in Roles PR diff --git a/src/Service/HealthCheck/Model/ConfigurationDetails.cs b/src/Service/HealthCheck/Model/ConfigurationDetails.cs index c3989e0167..9ff007754e 100644 --- a/src/Service/HealthCheck/Model/ConfigurationDetails.cs +++ b/src/Service/HealthCheck/Model/ConfigurationDetails.cs @@ -18,6 +18,9 @@ public record ConfigurationDetails [JsonPropertyName("graphql")] public bool GraphQL { get; init; } + [JsonPropertyName("mcp")] + public bool Mcp { get; init; } + [JsonPropertyName("caching")] public bool Caching { get; init; } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 719cdad5f7..48a39d31d0 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -454,7 +454,6 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); - // special for MCP services.AddDabMcpServer(configProvider); services.AddControllers(); From a29485cd0b9a5896f757390d7110ba4bb93b28b0 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Wed, 24 Sep 2025 22:05:41 -0700 Subject: [PATCH 52/63] Fix mssql snapshot --- ...sts.TestReadingRuntimeConfigForMsSql.verified.txt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 7fcc8ccc39..4283eb432e 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -23,17 +23,7 @@ }, Mcp: { Enabled: true, - Path: /mcp, - DmlTools: { - AllToolsEnabled: true, - UserProvidedAllToolsEnabled: false, - UserProvidedDescribeEntities: false, - UserProvidedCreateRecord: false, - UserProvidedReadRecords: false, - UserProvidedUpdateRecord: false, - UserProvidedDeleteRecord: false, - UserProvidedExecuteEntity: false - } + Path: /mcp }, Host: { Cors: { From 8080c831a09f7d4c13642d1532b66afd145d48b1 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 25 Sep 2025 14:16:04 +0530 Subject: [PATCH 53/63] Addressed review comments. mostly nits --- .../BuiltInTools/DescribeEntitiesTool.cs | 4 +- .../BuiltInTools/EchoTool.cs | 51 ------------------- .../Core/McpEndpointRouteBuilderExtensions.cs | 2 +- .../Core/McpServerConfiguration.cs | 7 +-- .../Converters/DmlToolsConfigConverter.cs | 10 +++- .../UnitTests/ConfigValidationUnitTests.cs | 1 - 6 files changed, 12 insertions(+), 63 deletions(-) delete mode 100644 src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 70d9680f0e..3e7ade6075 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -19,8 +19,8 @@ public Tool GetToolMetadata() { return new Tool { - Name = "describe_entities", - Description = "Lists all entities in the database." + Name = "describe-entities", + Description = "Lists and describes all entities in the database." }; } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs deleted file mode 100644 index ec86d278c3..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/EchoTool.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Azure.DataApiBuilder.Mcp.Model; -using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.McpEnums; - -namespace Azure.DataApiBuilder.Mcp.BuiltInTools -{ - public class EchoTool : IMcpTool - { - public ToolType ToolType { get; } = ToolType.BuiltIn; - - public Tool GetToolMetadata() - { - return new Tool - { - Name = "echonew", - Description = "Echoes the input back to the client.", - InputSchema = JsonSerializer.Deserialize( - @"{ - ""type"": ""object"", - ""properties"": { ""message"": { ""type"": ""string"" } }, - ""required"": [""message""] - }" - ) - }; - } - - public Task ExecuteAsync( - JsonDocument? arguments, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) - { - string? message = null; - - if (arguments?.RootElement.TryGetProperty("message", out JsonElement messageEl) == true) - { - message = messageEl.ValueKind == JsonValueKind.String - ? messageEl.GetString() - : messageEl.ToString(); - } - - return Task.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Type = "text", Text = $"Echo: {message}" }] - }); - } - } -} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs index 2c6b9688d4..bf1ef96dd3 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs @@ -10,7 +10,7 @@ namespace Azure.DataApiBuilder.Mcp.Core { /// - /// Extension methods for mapping MCP endpoints + /// Extension methods for mapping MCP endpoints to an . /// public static class McpEndpointRouteBuilderExtensions { diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index a0b6178c21..86cccd2aaf 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -76,12 +76,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se try { - if (request.Services == null) - { - throw new InvalidOperationException("Service provider is not available in the request context."); - } - - return await tool!.ExecuteAsync(arguments, request.Services, ct); + return await tool!.ExecuteAsync(arguments, request.Services!, ct); } finally { diff --git a/src/Config/Converters/DmlToolsConfigConverter.cs b/src/Config/Converters/DmlToolsConfigConverter.cs index e635029815..fdb37648c0 100644 --- a/src/Config/Converters/DmlToolsConfigConverter.cs +++ b/src/Config/Converters/DmlToolsConfigConverter.cs @@ -89,7 +89,14 @@ internal class DmlToolsConfigConverter : JsonConverter } else { - // Skip non-boolean values + // Error on non-boolean values for known properties + if (property?.ToLowerInvariant() is "describe-entities" or "create-record" + or "read-records" or "update-record" or "delete-record" or "execute-entity") + { + throw new JsonException($"Property '{property}' must be a boolean value."); + } + + // Skip unknown properties reader.Skip(); } } @@ -120,7 +127,6 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer { if (value is null) { - writer.WriteNullValue(); return; } diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs index 6cea36f2ff..16caf29b49 100644 --- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs @@ -1458,7 +1458,6 @@ public void ValidateValidEntityDefinitionsDoesNotGenerateDuplicateQueries(Databa // Extra case: conflict with MCP [DataRow("/mcp", "/api", "/mcp", true, DisplayName = "MCP and GraphQL conflict (same path).")] [DataRow("/graphql", "/mcp", "/mcp", true, DisplayName = "MCP and REST conflict (same path).")] - public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restConfiguredPath, string mcpConfiguredPath, bool expectError) { GraphQLRuntimeOptions graphQL = new(Path: graphQLConfiguredPath); From b4d7bd6d333d3111a01db64dd5638aa3017c1a96 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 25 Sep 2025 16:21:10 +0530 Subject: [PATCH 54/63] Fix failing test by reverting to original version --- .../Configuration/ConfigurationTests.cs | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 380cfe2185..dac6e25654 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2524,93 +2524,80 @@ public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() /// Expected HTTP status code code for the GraphQL request [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, MCP all enabled globally")] - [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled")] - [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - GraphQL enabled, Rest and MCP disabled")] - [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - MCP enabled, Rest and GraphQL disabled")] - [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, MCP all enabled globally")] - [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled")] - [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - GraphQL enabled, Rest and MCP disabled")] - [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - MCP enabled, Rest and GraphQL disabled")] - public async Task TestGlobalFlagToEnableRestGraphQLMcpForHostedAndNonHostedEnvironment( + [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Both Rest and GraphQL endpoints enabled globally")] + [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled and GraphQL endpoints disabled globally")] + [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled and GraphQL endpoints enabled globally")] + [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Both Rest and GraphQL endpoints enabled globally")] + [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled and GraphQL endpoints disabled globally")] + [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled and GraphQL endpoints enabled globally")] + public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvironment( bool isRestEnabled, bool isGraphQLEnabled, - bool isMcpEnabled, HttpStatusCode expectedStatusCodeForREST, HttpStatusCode expectedStatusCodeForGraphQL, - HttpStatusCode expectedStatusCodeForMcp, string configurationEndpoint) { GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); - DataSource dataSource = new( - DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), - Options: null); - - RuntimeConfig configuration = InitMinimalRuntimeConfig( - dataSource, - graphqlOptions, - restRuntimeOptions, - mcpRuntimeOptions); + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, null); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" }; + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; // Non-Hosted Scenario using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - // GraphQL request - string query = @"{ book_by_pk(id: 1) { id, title, publisher_id } }"; + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + object payload = new { query }; + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") { Content = JsonContent.Create(payload) }; + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); - // REST request HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); HttpResponseMessage restResponse = await client.SendAsync(restRequest); Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode); - - // MCP request - object mcpPayload = new - { - jsonrpc = "2.0", - id = 1, - method = "tools/list" - }; - HttpRequestMessage mcpRequest = new(HttpMethod.Post, "/mcp") - { - Content = JsonContent.Create(mcpPayload) - }; - HttpResponseMessage mcpResponse = await client.SendAsync(mcpRequest); - Assert.AreEqual(expectedStatusCodeForMcp, mcpResponse.StatusCode); } // Hosted Scenario + // Instantiate new server with no runtime config for post-startup configuration hydration tests. using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) using (HttpClient client = server.CreateClient()) { JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); + + HttpResponseMessage postResult = + await client.PostAsync(configurationEndpoint, content); Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client); + Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode); HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client); + Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode); - HttpStatusCode mcpResponseCode = await GetMcpResponsePostConfigHydration(client); - Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode); } } From fd9d16368816f3b059af4a22ccbec01b28d7bad2 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 25 Sep 2025 16:26:56 +0530 Subject: [PATCH 55/63] Fix formatting errors --- .../Configuration/ConfigurationTests.cs | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index dac6e25654..0be24fa886 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5314,49 +5314,6 @@ private static async Task GetGraphQLResponsePostConfigHydration( return responseCode; } - /// - /// Executing MCP POST requests against the engine until a non-503 error is received. - /// - /// Client used for request execution. - /// ServiceUnavailable if service is not successfully hydrated with config, - /// else the response code from the MCP request - private static async Task GetMcpResponsePostConfigHydration(HttpClient httpClient) - { - // Retry request RETRY_COUNT times in 1 second increments to allow required services - // time to instantiate and hydrate permissions. - int retryCount = RETRY_COUNT; - HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount > 0) - { - // Minimal MCP request (list tools) – valid JSON-RPC request - object payload = new - { - jsonrpc = "2.0", - id = 1, - method = "tools/list" - }; - - HttpRequestMessage mcpRequest = new(HttpMethod.Post, "/mcp") - { - Content = JsonContent.Create(payload) - }; - - HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); - responseCode = mcpResponse.StatusCode; - - if (responseCode == HttpStatusCode.ServiceUnavailable) - { - retryCount--; - Thread.Sleep(TimeSpan.FromSeconds(RETRY_WAIT_SECONDS)); - continue; - } - - break; - } - - return responseCode; - } - /// /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. /// From 44a59f5261ac8040109634463b57af02b787d732 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 25 Sep 2025 16:37:00 +0530 Subject: [PATCH 56/63] fixed the summary --- .../Core/McpEndpointRouteBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs index bf1ef96dd3..6401e17e22 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpEndpointRouteBuilderExtensions.cs @@ -15,7 +15,7 @@ namespace Azure.DataApiBuilder.Mcp.Core public static class McpEndpointRouteBuilderExtensions { /// - /// Maps MCP endpoints and health checks if MCP is enabled + /// Maps the MCP endpoint to the specified if MCP is enabled in the runtime configuration. /// public static IEndpointRouteBuilder MapDabMcp( this IEndpointRouteBuilder endpoints, From 96d8f8fc2358c0945b113805cd015822111fe11c Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 25 Sep 2025 16:40:17 +0530 Subject: [PATCH 57/63] fix formatting --- src/Config/Converters/DmlToolsConfigConverter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config/Converters/DmlToolsConfigConverter.cs b/src/Config/Converters/DmlToolsConfigConverter.cs index fdb37648c0..9acef0f9b2 100644 --- a/src/Config/Converters/DmlToolsConfigConverter.cs +++ b/src/Config/Converters/DmlToolsConfigConverter.cs @@ -90,12 +90,12 @@ internal class DmlToolsConfigConverter : JsonConverter else { // Error on non-boolean values for known properties - if (property?.ToLowerInvariant() is "describe-entities" or "create-record" + if (property?.ToLowerInvariant() is "describe-entities" or "create-record" or "read-records" or "update-record" or "delete-record" or "execute-entity") { throw new JsonException($"Property '{property}' must be a boolean value."); } - + // Skip unknown properties reader.Skip(); } From 30120d8db07b9340c4c510ab9529d6a952ac384a Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 09:15:07 +0530 Subject: [PATCH 58/63] added execute-entity --- .../CustomTools/ExecuteStoredProcedureTool.cs | 220 ++++++++++++++++++ .../CustomTools/GetBooksByPublisherTool.cs | 207 ++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs diff --git a/src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs b/src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs new file mode 100644 index 0000000000..9b43d52966 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Data; +using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.CustomTools +{ + /// + /// Custom tool for executing stored procedures configured in DAB + /// + public class ExecuteStoredProcedureTool : IMcpTool + { + public ToolType ToolType { get; } = ToolType.Custom; + + McpEnums.ToolType IMcpTool.ToolType => throw new NotImplementedException(); + + public Tool GetToolMetadata() + { + return new Tool + { + Name = "execute_stored_procedure", + Description = "Executes a stored procedure configured in DAB and returns the results", + InputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + entityName = new + { + type = "string", + description = "Name of the stored procedure entity as configured in dab-config" + }, + parameters = new + { + type = "object", + description = "Parameters to pass to the stored procedure as key-value pairs", + additionalProperties = true + } + }, + required = new[] { "entityName" } + }) + }; + } + + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + try + { + if (arguments?.RootElement.ValueKind != JsonValueKind.Object) + { + return CreateErrorResult("Invalid arguments: expected object"); + } + + // Extract entity name + if (!arguments.RootElement.TryGetProperty("entityName", out var entityNameElement) || + entityNameElement.ValueKind != JsonValueKind.String) + { + return CreateErrorResult("Missing or invalid 'entityName' parameter"); + } + + string entityName = entityNameElement.GetString()!; + + // Get runtime configuration + var runtimeConfigProvider = serviceProvider.GetRequiredService(); + var runtimeConfig = runtimeConfigProvider.GetConfig(); + + // Validate entity exists + if (!runtimeConfig.Entities.TryGetValue(entityName, out var entity)) + { + return CreateErrorResult($"Entity '{entityName}' not found in configuration"); + } + + // Validate it's a stored procedure + if (entity.Source.Type != EntitySourceType.StoredProcedure) + { + return CreateErrorResult($"Entity '{entityName}' is not a stored procedure"); + } + + // Build parameters + Dictionary parameters = new(); + + // Add default parameters from configuration + if (entity.Source.Parameters != null) + { + foreach (var param in entity.Source.Parameters) + { + parameters[param.Key] = param.Value; + } + } + + // Override with runtime parameters + if (arguments.RootElement.TryGetProperty("parameters", out var parametersElement) && + parametersElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in parametersElement.EnumerateObject()) + { + parameters[property.Name] = GetParameterValue(property.Value); + } + } + + // Get stored procedure name from entity configuration + string storedProcedureName = entity.Source.Object!; + + // Get the database connection string + string dataSourceName = runtimeConfig.DefaultDataSourceName; + var dataSource = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName); + string connectionString = dataSource.ConnectionString; + + // Execute stored procedure directly + var results = new List>(); + + using (var connection = new SqlConnection(connectionString)) + { + await connection.OpenAsync(cancellationToken); + + using (var command = new SqlCommand(storedProcedureName, connection)) + { + command.CommandType = CommandType.StoredProcedure; + + // Add parameters + foreach (var param in parameters) + { + command.Parameters.AddWithValue($"@{param.Key}", param.Value ?? DBNull.Value); + } + + // Execute and read results + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Dictionary(); + for (int i = 0; i < reader.FieldCount; i++) + { + string columnName = reader.GetName(i); + object? value = reader.IsDBNull(i) ? null : reader.GetValue(i); + row[columnName] = value; + } + results.Add(row); + } + } + } + } + + // Format the response + var response = new + { + success = true, + entity = entityName, + storedProcedure = storedProcedureName, + rowCount = results.Count, + data = results + }; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + WriteIndented = true + }) + } + } + }; + } + catch (DataApiBuilderException ex) + { + return CreateErrorResult($"DAB Error: {ex.Message}"); + } + catch (Exception ex) + { + return CreateErrorResult($"Unexpected error: {ex.Message}"); + } + } + + private static object? GetParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt32(out var intValue) ? intValue : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + private static CallToolResult CreateErrorResult(string errorMessage) + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(new { error = errorMessage }) + } + }, + IsError = true + }; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs b/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs new file mode 100644 index 0000000000..01edc91e77 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.CustomTools +{ + + /// + /// Custom tool for retrieving books by publisher using a stored procedure configured in DAB + /// + public class GetBooksByPublisherTool : IMcpTool + { + public ToolType ToolType { get; } = ToolType.Custom; + + McpEnums.ToolType IMcpTool.ToolType => throw new NotImplementedException(); + + public Tool GetToolMetadata() + { + return new Tool + { + Name = "get_books_by_publisher", + Description = "Retrieves books published by a specific publisher using a stored procedure", + InputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + publisher = new + { + type = "string", + description = "Name of the publisher to search for" + }, + includeOutOfStock = new + { + type = "boolean", + description = "Whether to include out of stock books", + @default = false + } + }, + required = new[] { "publisher" } + }) + }; + } + + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + try + { + if (arguments?.RootElement.ValueKind != JsonValueKind.Object) + { + return CreateErrorResult("Invalid arguments: expected object"); + } + + // Extract publisher parameter + if (!arguments.RootElement.TryGetProperty("publisher", out var publisherElement) || + publisherElement.ValueKind != JsonValueKind.String) + { + return CreateErrorResult("Missing or invalid 'publisher' parameter"); + } + + string publisher = publisherElement.GetString()!; + + // Extract optional includeOutOfStock parameter + bool includeOutOfStock = false; + if (arguments.RootElement.TryGetProperty("includeOutOfStock", out var includeOutOfStockElement)) + { + includeOutOfStock = includeOutOfStockElement.GetBoolean(); + } + + // Define the entity name as configured in dab-config.json + const string entityName = "GetBooksByPublisher"; + + // Get required services + var runtimeConfigProvider = serviceProvider.GetRequiredService(); + var runtimeConfig = runtimeConfigProvider.GetConfig(); + + // Validate entity exists + if (!runtimeConfig.Entities.TryGetValue(entityName, out var entity)) + { + return CreateErrorResult($"Stored procedure entity '{entityName}' not found in configuration"); + } + + // Set up HTTP context with parameters for the stored procedure + var httpContextAccessor = serviceProvider.GetRequiredService(); + if (httpContextAccessor.HttpContext != null) + { + // Create request body with parameters + var requestBody = new Dictionary + { + { "publisher", publisher }, + { "includeOutOfStock", includeOutOfStock } + }; + + var json = JsonSerializer.Serialize(requestBody); + var stream = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + httpContextAccessor.HttpContext.Request.Body = stream; + httpContextAccessor.HttpContext.Request.ContentType = "application/json"; + httpContextAccessor.HttpContext.Request.ContentLength = stream.Length; + } + + // Use RestService to execute the stored procedure + var restService = serviceProvider.GetRequiredService(); + var result = await restService.ExecuteAsync(entityName, EntityActionOperation.Execute, primaryKeyRoute: null); + + if (result == null) + { + var noDataResponse = new + { + success = true, + message = "No books found for the specified publisher", + data = Array.Empty() + }; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(noDataResponse) + } + } + }; + } + + // Extract the response data + object? responseData = null; + if (result is ObjectResult objectResult) + { + responseData = objectResult.Value; + } + else if (result is JsonResult jsonResult) + { + responseData = jsonResult.Value; + } + + var response = new + { + success = true, + publisher = publisher, + includeOutOfStock = includeOutOfStock, + data = responseData + }; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + WriteIndented = true + }) + } + } + }; + } + catch (DataApiBuilderException ex) + { + return CreateErrorResult($"Database operation failed: {ex.Message}"); + } + catch (Exception ex) + { + return CreateErrorResult($"Unexpected error: {ex.Message}"); + } + } + + private static CallToolResult CreateErrorResult(string errorMessage) + { + var errorResponse = new + { + error = errorMessage, + success = false + }; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(errorResponse) + } + }, + IsError = true + }; + } + } +} From 11f3b0bb85d9f2b05fa20fa7f67b10935b7480b9 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 23:15:22 +0530 Subject: [PATCH 59/63] Added delete-record and execute-entity tools --- .../BuiltInTools/DeleteRecordTool.cs | 285 ++++++++++++++++++ .../ExecuteEntityTool.cs} | 46 +-- .../CustomTools/GetBooksByPublisherTool.cs | 207 ------------- 3 files changed, 308 insertions(+), 230 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs rename src/Azure.DataApiBuilder.Mcp/{CustomTools/ExecuteStoredProcedureTool.cs => BuiltInTools/ExecuteEntityTool.cs} (83%) delete mode 100644 src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs new file mode 100644 index 0000000000..6565a0d59b --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. +// 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.Service.Exceptions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.BuiltInTools +{ + /// + /// Tool to delete records from a table/view entity configured in DAB. + /// + public class DeleteRecordTool : IMcpTool + { + public ToolType ToolType { get; } = ToolType.BuiltIn; + + public Tool GetToolMetadata() + { + return new Tool + { + Name = "delete-record", + Description = "Deletes records from a table or view based on specified conditions", + InputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + entityName = new + { + type = "string", + description = "Name of the entity (table/view) as configured in dab-config" + }, + primaryKey = new + { + type = "object", + description = "Primary key values to identify the record(s) to delete", + additionalProperties = true + }, + filter = new + { + type = "string", + description = "Optional WHERE clause conditions (e.g., 'age > 18 AND status = \"active\"')" + } + }, + required = new[] { "entityName" }, + oneOf = new[] + { + new { required = new[] { "primaryKey" } }, + new { required = new[] { "filter" } } + } + }) + }; + } + + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + try + { + if (arguments?.RootElement.ValueKind != JsonValueKind.Object) + { + return CreateErrorResult("Invalid arguments: expected object"); + } + + // Extract entity name + if (!arguments.RootElement.TryGetProperty("entityName", out JsonElement entityNameElement) || + entityNameElement.ValueKind != JsonValueKind.String) + { + return CreateErrorResult("Missing or invalid 'entityName' parameter"); + } + + string entityName = entityNameElement.GetString()!; + + // Get runtime configuration + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); + + // Validate entity exists + if (!runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity)) + { + return CreateErrorResult($"Entity '{entityName}' not found in configuration"); + } + + // Validate it's a table or view + if (entity.Source.Type != EntitySourceType.Table && entity.Source.Type != EntitySourceType.View) + { + return CreateErrorResult($"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures."); + } + + // Check permissions for delete action + bool hasDeletePermission = entity.Permissions?.Any(p => + p.Actions?.Any(a => a.Action == EntityActionOperation.Delete) == true) == true; + + if (!hasDeletePermission) + { + return CreateErrorResult($"Delete operation is not permitted for entity '{entityName}'"); + } + + // Get table/view name from entity configuration + string tableName = entity.Source.Object!; + + // Build WHERE clause + string whereClause; + Dictionary parameters = new(); + + bool hasPrimaryKey = arguments.RootElement.TryGetProperty("primaryKey", out JsonElement primaryKeyElement) && + primaryKeyElement.ValueKind == JsonValueKind.Object; + bool hasFilter = arguments.RootElement.TryGetProperty("filter", out JsonElement filterElement) && + filterElement.ValueKind == JsonValueKind.String; + + if (!hasPrimaryKey && !hasFilter) + { + return CreateErrorResult("Either 'primaryKey' or 'filter' must be provided"); + } + + if (hasPrimaryKey && hasFilter) + { + return CreateErrorResult("Cannot specify both 'primaryKey' and 'filter'. Use one or the other."); + } + + if (hasPrimaryKey) + { + // Build WHERE clause from primary key + List conditions = new(); + foreach (JsonProperty prop in primaryKeyElement.EnumerateObject()) + { + string paramName = $"pk_{prop.Name}"; + conditions.Add($"[{prop.Name}] = @{paramName}"); + parameters[paramName] = GetParameterValue(prop.Value); + } + + whereClause = string.Join(" AND ", conditions); + } + else + { + // Use the provided filter + whereClause = filterElement.GetString()!; + + // Basic SQL injection prevention - check for dangerous patterns + string[] dangerousPatterns = { "--", "/*", "*/", "xp_", "sp_", "exec", "execute", "drop", "create", "alter" }; + string filterLower = whereClause.ToLower(); + foreach (string pattern in dangerousPatterns) + { + if (filterLower.Contains(pattern)) + { + return CreateErrorResult($"Filter contains potentially dangerous SQL pattern: '{pattern}'"); + } + } + } + + // Get the database connection string + string dataSourceName = runtimeConfig.DefaultDataSourceName; + DataSource? dataSource = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName); + string connectionString = dataSource.ConnectionString; + + // Execute delete operation + int rowsAffected = 0; + List> deletedRecords = new(); + + using (SqlConnection connection = new(connectionString)) + { + await connection.OpenAsync(cancellationToken); + + // First, get a preview of what will be deleted (for response) + string selectQuery = $"SELECT * FROM [{tableName}] WHERE {whereClause}"; + + using (SqlCommand selectCommand = new(selectQuery, connection)) + { + // Add parameters if using primary key + foreach (KeyValuePair param in parameters) + { + selectCommand.Parameters.AddWithValue($"@{param.Key}", param.Value ?? DBNull.Value); + } + + using (SqlDataReader reader = await selectCommand.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + Dictionary row = new(); + + for (int i = 0; i < reader.FieldCount; i++) + { + string columnName = reader.GetName(i); + object? value = reader.IsDBNull(i) ? null : reader.GetValue(i); + row[columnName] = value; + } + + deletedRecords.Add(row); + } + } + } + + // Now execute the delete + string deleteQuery = $"DELETE FROM [{tableName}] WHERE {whereClause}"; + + using (SqlCommand deleteCommand = new(deleteQuery, connection)) + { + // Add parameters if using primary key + foreach (KeyValuePair param in parameters) + { + deleteCommand.Parameters.AddWithValue($"@{param.Key}", param.Value ?? DBNull.Value); + } + + rowsAffected = await deleteCommand.ExecuteNonQueryAsync(cancellationToken); + } + } + + // Format the response + var response = new + { + success = true, + entity = entityName, + table = tableName, + rowsDeleted = rowsAffected, + deletedRecords = deletedRecords + }; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }) + } + } + }; + } + catch (SqlException ex) + { + return CreateErrorResult($"Database error: {ex.Message}"); + } + catch (DataApiBuilderException ex) + { + return CreateErrorResult($"DAB Error: {ex.Message}"); + } + catch (Exception ex) + { + return CreateErrorResult($"Unexpected error: {ex.Message}"); + } + } + + private static object? GetParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt32(out int intValue) ? intValue : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + private static CallToolResult CreateErrorResult(string errorMessage) + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Type = "text", + Text = JsonSerializer.Serialize(new { error = errorMessage }) + } + }, + IsError = true + }; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs similarity index 83% rename from src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs rename to src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index 9b43d52966..1abfdf1b49 100644 --- a/src/Azure.DataApiBuilder.Mcp/CustomTools/ExecuteStoredProcedureTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -12,22 +12,20 @@ using ModelContextProtocol.Protocol; using static Azure.DataApiBuilder.Mcp.Model.McpEnums; -namespace Azure.DataApiBuilder.Mcp.CustomTools +namespace Azure.DataApiBuilder.Mcp.BuiltInTools { /// - /// Custom tool for executing stored procedures configured in DAB + /// Tool to execute a stored procedure entity configured in DAB and return the results. /// - public class ExecuteStoredProcedureTool : IMcpTool + public class ExecuteEntityTool : IMcpTool { - public ToolType ToolType { get; } = ToolType.Custom; - - McpEnums.ToolType IMcpTool.ToolType => throw new NotImplementedException(); + public ToolType ToolType { get; } = ToolType.BuiltIn; public Tool GetToolMetadata() { return new Tool { - Name = "execute_stored_procedure", + Name = "execute-entity", Description = "Executes a stored procedure configured in DAB and returns the results", InputSchema = JsonSerializer.SerializeToElement(new { @@ -64,7 +62,7 @@ public async Task ExecuteAsync( } // Extract entity name - if (!arguments.RootElement.TryGetProperty("entityName", out var entityNameElement) || + if (!arguments.RootElement.TryGetProperty("entityName", out JsonElement entityNameElement) || entityNameElement.ValueKind != JsonValueKind.String) { return CreateErrorResult("Missing or invalid 'entityName' parameter"); @@ -73,11 +71,11 @@ public async Task ExecuteAsync( string entityName = entityNameElement.GetString()!; // Get runtime configuration - var runtimeConfigProvider = serviceProvider.GetRequiredService(); - var runtimeConfig = runtimeConfigProvider.GetConfig(); + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); // Validate entity exists - if (!runtimeConfig.Entities.TryGetValue(entityName, out var entity)) + if (!runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity)) { return CreateErrorResult($"Entity '{entityName}' not found in configuration"); } @@ -94,17 +92,17 @@ public async Task ExecuteAsync( // Add default parameters from configuration if (entity.Source.Parameters != null) { - foreach (var param in entity.Source.Parameters) + foreach (KeyValuePair param in entity.Source.Parameters) { parameters[param.Key] = param.Value; } } // Override with runtime parameters - if (arguments.RootElement.TryGetProperty("parameters", out var parametersElement) && + if (arguments.RootElement.TryGetProperty("parameters", out JsonElement parametersElement) && parametersElement.ValueKind == JsonValueKind.Object) { - foreach (var property in parametersElement.EnumerateObject()) + foreach (JsonProperty property in parametersElement.EnumerateObject()) { parameters[property.Name] = GetParameterValue(property.Value); } @@ -115,38 +113,40 @@ public async Task ExecuteAsync( // Get the database connection string string dataSourceName = runtimeConfig.DefaultDataSourceName; - var dataSource = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName); + DataSource? dataSource = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName); string connectionString = dataSource.ConnectionString; // Execute stored procedure directly - var results = new List>(); + List> results = new(); - using (var connection = new SqlConnection(connectionString)) + using (SqlConnection connection = new(connectionString)) { await connection.OpenAsync(cancellationToken); - - using (var command = new SqlCommand(storedProcedureName, connection)) + + using (SqlCommand command = new(storedProcedureName, connection)) { command.CommandType = CommandType.StoredProcedure; // Add parameters - foreach (var param in parameters) + foreach (KeyValuePair param in parameters) { command.Parameters.AddWithValue($"@{param.Key}", param.Value ?? DBNull.Value); } // Execute and read results - using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + using (SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken)) { while (await reader.ReadAsync(cancellationToken)) { - var row = new Dictionary(); + Dictionary row = new(); + for (int i = 0; i < reader.FieldCount; i++) { string columnName = reader.GetName(i); object? value = reader.IsDBNull(i) ? null : reader.GetValue(i); row[columnName] = value; } + results.Add(row); } } @@ -193,7 +193,7 @@ public async Task ExecuteAsync( return element.ValueKind switch { JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt32(out var intValue) ? intValue : element.GetDouble(), + JsonValueKind.Number => element.TryGetInt32(out int intValue) ? intValue : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, diff --git a/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs b/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs deleted file mode 100644 index 01edc91e77..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/CustomTools/GetBooksByPublisherTool.cs +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Services; -using Azure.DataApiBuilder.Mcp.Model; -using Azure.DataApiBuilder.Service.Exceptions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.McpEnums; - -namespace Azure.DataApiBuilder.Mcp.CustomTools -{ - - /// - /// Custom tool for retrieving books by publisher using a stored procedure configured in DAB - /// - public class GetBooksByPublisherTool : IMcpTool - { - public ToolType ToolType { get; } = ToolType.Custom; - - McpEnums.ToolType IMcpTool.ToolType => throw new NotImplementedException(); - - public Tool GetToolMetadata() - { - return new Tool - { - Name = "get_books_by_publisher", - Description = "Retrieves books published by a specific publisher using a stored procedure", - InputSchema = JsonSerializer.SerializeToElement(new - { - type = "object", - properties = new - { - publisher = new - { - type = "string", - description = "Name of the publisher to search for" - }, - includeOutOfStock = new - { - type = "boolean", - description = "Whether to include out of stock books", - @default = false - } - }, - required = new[] { "publisher" } - }) - }; - } - - public async Task ExecuteAsync( - JsonDocument? arguments, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) - { - try - { - if (arguments?.RootElement.ValueKind != JsonValueKind.Object) - { - return CreateErrorResult("Invalid arguments: expected object"); - } - - // Extract publisher parameter - if (!arguments.RootElement.TryGetProperty("publisher", out var publisherElement) || - publisherElement.ValueKind != JsonValueKind.String) - { - return CreateErrorResult("Missing or invalid 'publisher' parameter"); - } - - string publisher = publisherElement.GetString()!; - - // Extract optional includeOutOfStock parameter - bool includeOutOfStock = false; - if (arguments.RootElement.TryGetProperty("includeOutOfStock", out var includeOutOfStockElement)) - { - includeOutOfStock = includeOutOfStockElement.GetBoolean(); - } - - // Define the entity name as configured in dab-config.json - const string entityName = "GetBooksByPublisher"; - - // Get required services - var runtimeConfigProvider = serviceProvider.GetRequiredService(); - var runtimeConfig = runtimeConfigProvider.GetConfig(); - - // Validate entity exists - if (!runtimeConfig.Entities.TryGetValue(entityName, out var entity)) - { - return CreateErrorResult($"Stored procedure entity '{entityName}' not found in configuration"); - } - - // Set up HTTP context with parameters for the stored procedure - var httpContextAccessor = serviceProvider.GetRequiredService(); - if (httpContextAccessor.HttpContext != null) - { - // Create request body with parameters - var requestBody = new Dictionary - { - { "publisher", publisher }, - { "includeOutOfStock", includeOutOfStock } - }; - - var json = JsonSerializer.Serialize(requestBody); - var stream = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - httpContextAccessor.HttpContext.Request.Body = stream; - httpContextAccessor.HttpContext.Request.ContentType = "application/json"; - httpContextAccessor.HttpContext.Request.ContentLength = stream.Length; - } - - // Use RestService to execute the stored procedure - var restService = serviceProvider.GetRequiredService(); - var result = await restService.ExecuteAsync(entityName, EntityActionOperation.Execute, primaryKeyRoute: null); - - if (result == null) - { - var noDataResponse = new - { - success = true, - message = "No books found for the specified publisher", - data = Array.Empty() - }; - - return new CallToolResult - { - Content = new List - { - new TextContentBlock - { - Type = "text", - Text = JsonSerializer.Serialize(noDataResponse) - } - } - }; - } - - // Extract the response data - object? responseData = null; - if (result is ObjectResult objectResult) - { - responseData = objectResult.Value; - } - else if (result is JsonResult jsonResult) - { - responseData = jsonResult.Value; - } - - var response = new - { - success = true, - publisher = publisher, - includeOutOfStock = includeOutOfStock, - data = responseData - }; - - return new CallToolResult - { - Content = new List - { - new TextContentBlock - { - Type = "text", - Text = JsonSerializer.Serialize(response, new JsonSerializerOptions - { - WriteIndented = true - }) - } - } - }; - } - catch (DataApiBuilderException ex) - { - return CreateErrorResult($"Database operation failed: {ex.Message}"); - } - catch (Exception ex) - { - return CreateErrorResult($"Unexpected error: {ex.Message}"); - } - } - - private static CallToolResult CreateErrorResult(string errorMessage) - { - var errorResponse = new - { - error = errorMessage, - success = false - }; - - return new CallToolResult - { - Content = new List - { - new TextContentBlock - { - Type = "text", - Text = JsonSerializer.Serialize(errorResponse) - } - }, - IsError = true - }; - } - } -} From 1af357c128d25c767e2de14c3c635afbde4f9157 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 23:43:44 +0530 Subject: [PATCH 60/63] revert local dev changes --- src/Service.Tests/dab-config.MsSql.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index fe47ebe5e2..d5e903d4f3 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2,7 +2,7 @@ "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", "data-source": { "database-type": "mssql", - "connection-string": "Server=tcp:localhost,1433;Persist Security Info=False;Initial Catalog=Library;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=30;", + "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", "options": { "set-session-context": true } From 81e719e3009451fb8f1f5c9386aa23f43944efa1 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 23:47:40 +0530 Subject: [PATCH 61/63] revert local dev changes --- src/dab.draft.schema.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/dab.draft.schema.json diff --git a/src/dab.draft.schema.json b/src/dab.draft.schema.json deleted file mode 100644 index e69de29bb2..0000000000 From 5567fa4977ed5cd622cf4222f7d9c1018c3aaa12 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 23:54:03 +0530 Subject: [PATCH 62/63] Delete src/McpRuntimeConfig.cs --- src/McpRuntimeConfig.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/McpRuntimeConfig.cs diff --git a/src/McpRuntimeConfig.cs b/src/McpRuntimeConfig.cs deleted file mode 100644 index e69de29bb2..0000000000 From db05a8915a27ef135b95a06239d0a22befd6d2ae Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 26 Sep 2025 23:55:51 +0530 Subject: [PATCH 63/63] revert local dev changes --- .../BuiltInTools/ComplexTool.cs | 136 ---------------- src/Config/ObjectModel/McpDmlToolsOptions.cs | 148 ------------------ 2 files changed, 284 deletions(-) delete mode 100644 src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs delete mode 100644 src/Config/ObjectModel/McpDmlToolsOptions.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs deleted file mode 100644 index d1e081868c..0000000000 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ComplexTool.cs +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using Azure.DataApiBuilder.Mcp.Model; -using ModelContextProtocol.Protocol; -using static Azure.DataApiBuilder.Mcp.Model.McpEnums; - -namespace Azure.DataApiBuilder.Mcp.BuiltInTools -{ - /* This is a sample for reference and will be deleted - // to call the tool, use the following JSON payload body and make POST request to: http://localhost:5000/mcp - - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "process_data", - "arguments": { - "name": "DataOperation", - "count": 42, - "enabled": true, - "threshold": 3.14, - "tags": ["tag1", "tag2", "tag3"], - "options": { - "verbose": true, - "timeout": 60 - } - } - } - } - - */ - public class ComplexTool : IMcpTool - { - public ToolType ToolType { get; } = ToolType.BuiltIn; - - public Tool GetToolMetadata() - { - return new Tool - { - Name = "process_data", - Description = "Processes data with multiple parameters of different types.", - InputSchema = JsonSerializer.Deserialize( - @"{ - ""type"": ""object"", - ""properties"": { - ""name"": { ""type"": ""string"", ""description"": ""Name of the operation"" }, - ""count"": { ""type"": ""integer"", ""description"": ""Number of items to process"" }, - ""enabled"": { ""type"": ""boolean"", ""description"": ""Whether processing is enabled"" }, - ""threshold"": { ""type"": ""number"", ""description"": ""Processing threshold value"" }, - ""tags"": { - ""type"": ""array"", - ""items"": { ""type"": ""string"" }, - ""description"": ""List of tags"" - }, - ""options"": { - ""type"": ""object"", - ""properties"": { - ""verbose"": { ""type"": ""boolean"" }, - ""timeout"": { ""type"": ""integer"" } - } - } - }, - ""required"": [""name"", ""count""] - }" - ) - }; - } - - public Task ExecuteAsync( - JsonDocument? arguments, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) - { - if (arguments == null) - { - return Task.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Type = "text", Text = "Error: No arguments provided" }] - }); - } - - JsonElement root = arguments.RootElement; - - // Extract different types of parameters - string name = root.TryGetProperty("name", out JsonElement nameEl) && nameEl.ValueKind == JsonValueKind.String - ? nameEl.GetString() ?? "Unknown" - : "Unknown"; - - int count = root.TryGetProperty("count", out JsonElement countEl) && countEl.ValueKind == JsonValueKind.Number - ? countEl.GetInt32() - : 0; - - bool enabled = root.TryGetProperty("enabled", out JsonElement enabledEl) && enabledEl.ValueKind == JsonValueKind.True; - - double threshold = root.TryGetProperty("threshold", out JsonElement thresholdEl) && thresholdEl.ValueKind == JsonValueKind.Number - ? thresholdEl.GetDouble() - : 0.0; - - List tags = new(); - if (root.TryGetProperty("tags", out JsonElement tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement tag in tagsEl.EnumerateArray()) - { - if (tag.ValueKind == JsonValueKind.String) - { - tags.Add(tag.GetString() ?? string.Empty); - } - } - } - - bool verbose = false; - int timeout = 30; - if (root.TryGetProperty("options", out JsonElement optionsEl) && optionsEl.ValueKind == JsonValueKind.Object) - { - verbose = optionsEl.TryGetProperty("verbose", out JsonElement verboseEl) && verboseEl.ValueKind == JsonValueKind.True; - - if (optionsEl.TryGetProperty("timeout", out JsonElement timeoutEl) && timeoutEl.ValueKind == JsonValueKind.Number) - { - timeout = timeoutEl.GetInt32(); - } - } - - // Process the data (example logic) - string result = $"Processed: name={name}, count={count}, enabled={enabled}, threshold={threshold}, " + - $"tags=[{string.Join(", ", tags)}], verbose={verbose}, timeout={timeout}"; - - return Task.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Type = "text", Text = result }] - }); - } - } -} diff --git a/src/Config/ObjectModel/McpDmlToolsOptions.cs b/src/Config/ObjectModel/McpDmlToolsOptions.cs deleted file mode 100644 index 36b8260681..0000000000 --- a/src/Config/ObjectModel/McpDmlToolsOptions.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Serialization; - -namespace Azure.DataApiBuilder.Config.ObjectModel; - -/// -/// DML Tools for general CRUD operations on configured entities -/// -public record McpDmlToolsOptions -{ - public bool DescribeEntities { get; init; } - - public bool CreateRecord { get; init; } - - public bool ReadRecords { get; init; } - - public bool UpdateRecord { get; init; } - - public bool DeleteRecord { get; init; } - - public bool ExecuteEntity { get; init; } - - public McpDmlToolsOptions( - bool? DescribeEntities = null, - bool? CreateRecord = null, - bool? ReadRecords = null, - bool? UpdateRecord = null, - bool? DeleteRecord = null, - bool? ExecuteEntity = null) - { - if (DescribeEntities is not null) - { - this.DescribeEntities = (bool)DescribeEntities; - UserProvidedDescribeEntities = true; - } - else - { - this.DescribeEntities = true; - } - - if (CreateRecord is not null) - { - this.CreateRecord = (bool)CreateRecord; - UserProvidedCreateRecord = true; - } - else - { - this.CreateRecord = true; - } - - if (ReadRecords is not null) - { - this.ReadRecords = (bool)ReadRecords; - UserProvidedReadRecords = true; - } - else - { - this.ReadRecords = true; - } - - if (UpdateRecord is not null) - { - this.UpdateRecord = (bool)UpdateRecord; - UserProvidedUpdateRecord = true; - } - else - { - this.UpdateRecord = true; - } - - if (DeleteRecord is not null) - { - this.DeleteRecord = (bool)DeleteRecord; - UserProvidedDeleteRecord = true; - } - else - { - this.DeleteRecord = true; - } - - if (ExecuteEntity is not null) - { - this.ExecuteEntity = (bool)ExecuteEntity; - UserProvidedExecuteEntity = true; - } - else - { - this.ExecuteEntity = true; - } - } - - /// - /// Flag which informs CLI and JSON serializer whether to write describe-entities - /// property and value to the runtime config file. - /// When user doesn't provide the describe-entities property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedDescribeEntities { get; init; } = false; - - /// - /// Flag which informs CLI and JSON serializer whether to write create-record - /// property and value to the runtime config file. - /// When user doesn't provide the create-record property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedCreateRecord { get; init; } = false; - - /// - /// Flag which informs CLI and JSON serializer whether to write read-records - /// property and value to the runtime config file. - /// When user doesn't provide the read-records property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedReadRecords { get; init; } = false; - - /// - /// Flag which informs CLI and JSON serializer whether to write update-record - /// property and value to the runtime config file. - /// When user doesn't provide the update-record property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedUpdateRecord { get; init; } = false; - - /// - /// Flag which informs CLI and JSON serializer whether to write delete-record - /// property and value to the runtime config file. - /// When user doesn't provide the delete-record property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedDeleteRecord { get; init; } = false; - - /// - /// Flag which informs CLI and JSON serializer whether to write execute-entity - /// property and value to the runtime config file. - /// When user doesn't provide the execute-entity property/value, which signals DAB to use the default, - /// the DAB CLI should not write the default value to a serialized config. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedExecuteEntity { get; init; } = false; -} -