diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index c8d4488526..e2fbfebac3 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -695,7 +695,7 @@ }, "entities": { "type": "object", - "description": "Entities that will be exposed via REST and/or GraphQL", + "description": "Entities that will be exposed via REST, GraphQL and/or MCP", "patternProperties": { "^.*$": { "type": "object", @@ -961,6 +961,31 @@ "default": 5 } } + }, + "mcp": { + "oneOf": [ + { + "type": "boolean", + "description": "Boolean shorthand: true enables dml-tools only (custom-tool remains false), false disables all MCP functionality." + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "dml-tools": { + "type": "boolean", + "description": "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP.", + "default": true + }, + "custom-tool": { + "type": "boolean", + "description": "Enable MCP custom tool for this entity. Only valid for stored procedures.", + "default": false + } + } + } + ], + "description": "Model Context Protocol (MCP) configuration for this entity. Controls whether the entity is exposed via MCP tools." } }, "if": { @@ -1145,6 +1170,33 @@ ] } } + }, + { + "if": { + "properties": { + "mcp": { + "properties": { + "custom-tool": { + "const": true + } + } + } + }, + "required": ["mcp"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { + "const": "stored-procedure" + } + }, + "required": ["type"] + } + }, + "errorMessage": "custom-tool can only be enabled for entities with source type 'stored-procedure'." + } } ] } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 1a944d115b..cab4b69bdb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -83,6 +83,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + if (!McpMetadataHelper.TryResolveMetadata( entityName, runtimeConfig, @@ -117,17 +124,23 @@ public async Task ExecuteAsync( } JsonElement insertPayloadRoot = dataElement.Clone(); + + // Validate it's a table or view - stored procedures use execute_entity + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + InsertRequestContext insertRequestContext = new( entityName, dbObject, insertPayloadRoot, EntityActionOperation.Insert); - RequestValidator requestValidator = serviceProvider.GetRequiredService(); - - // Only validate tables + // Only validate tables. For views, skip validation and let the database handle any errors. if (dbObject.SourceType is EntitySourceType.Table) { + RequestValidator requestValidator = serviceProvider.GetRequiredService(); try { requestValidator.ValidateInsertRequestContext(insertRequestContext); @@ -137,14 +150,6 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } - else - { - return McpResponseBuilder.BuildErrorResult( - toolName, - "InvalidCreateTarget", - "The create_record tool is only available for tables.", - logger); - } IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType databaseType = sqlMetadataProvider.GetDatabaseType(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index d7837c0103..bc8efa96fe 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -101,6 +101,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + // 4) Resolve metadata for entity existence if (!McpMetadataHelper.TryResolveMetadata( entityName, diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index e780c8ddeb..8989680f9e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -107,11 +107,18 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity is required", logger); } + // Check entity-level DML tool configuration early (before metadata resolution) + if (config.Entities?.TryGetValue(entity, out Entity? entityForCheck) == true && + entityForCheck.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entity}'."); + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); // 4) Validate entity exists and is a stored procedure - if (!config.Entities.TryGetValue(entity, out Entity? entityConfig)) + if (config.Entities is null || !config.Entities.TryGetValue(entity, out Entity? entityConfig)) { return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 1ed91c30a8..dbbc338c76 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -114,6 +114,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + if (root.TryGetProperty("select", out JsonElement selectElement)) { select = selectElement.GetString(); @@ -151,6 +158,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // Authorization check in the existing entity IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); IAuthorizationService authorizationService = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 195e27a0cd..883ddde02e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -115,6 +115,13 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); @@ -130,6 +137,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // 5) Authorization after we have a known entity IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs new file mode 100644 index 0000000000..ea2fa0cfea --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -0,0 +1,381 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Data.Common; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +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.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Dynamic custom MCP tool generated from stored procedure entity configuration. + /// Each custom tool represents a single stored procedure exposed as a dedicated MCP tool. + /// + /// Note: The entity configuration is captured at tool construction time. If the RuntimeConfig + /// is hot-reloaded, GetToolMetadata() will return cached metadata (name, description, parameters) + /// from the original configuration. This is acceptable because: + /// 1. MCP clients typically call tools/list once at startup + /// 2. ExecuteAsync always validates against the current runtime configuration + /// 3. Cached metadata improves performance for repeated metadata requests + /// + public class DynamicCustomTool : IMcpTool + { + private readonly string _entityName; + private readonly Entity _entity; + + /// + /// Initializes a new instance of DynamicCustomTool. + /// + /// The entity name from configuration. + /// The entity configuration object. + public DynamicCustomTool(string entityName, Entity entity) + { + _entityName = entityName ?? throw new ArgumentNullException(nameof(entityName)); + _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + + // Validate that this is a stored procedure + if (_entity.Source.Type != EntitySourceType.StoredProcedure) + { + throw new ArgumentException( + $"Custom tools can only be created for stored procedures. Entity '{entityName}' is of type '{_entity.Source.Type}'.", + nameof(entity)); + } + } + + /// + /// Gets the type of the tool, which is Custom for dynamically generated tools. + /// + public ToolType ToolType { get; } = ToolType.Custom; + + /// + /// Gets the metadata for this custom tool, including name, description, and input schema. + /// + public Tool GetToolMetadata() + { + string toolName = ConvertToToolName(_entityName); + string description = _entity.Description ?? $"Executes the {toolName} stored procedure"; + + // Build input schema based on parameters + JsonElement inputSchema = BuildInputSchema(); + + return new Tool + { + Name = toolName, + Description = description, + InputSchema = inputSchema + }; + } + + /// + /// Executes the stored procedure represented by this custom tool. + /// + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + // 1) Resolve required services & configuration + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig config = runtimeConfigProvider.GetConfig(); + + // 2) Parse arguments from the request + Dictionary parameters = new(); + if (arguments != null) + { + foreach (JsonProperty property in arguments.RootElement.EnumerateObject()) + { + parameters[property.Name] = GetParameterValue(property.Value); + } + } + + // 3) Validate entity still exists in configuration + if (!config.Entities.TryGetValue(_entityName, out Entity? entityConfig)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{_entityName}' not found in configuration.", logger); + } + + if (entityConfig.Source.Type != EntitySourceType.StoredProcedure) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {_entityName} is not a stored procedure.", logger); + } + + // Check if custom tool is still enabled for this entity + if (entityConfig.Mcp?.CustomToolEnabled != true) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"Custom tool is disabled for entity '{_entityName}'."); + } + + // 4) Resolve metadata + if (!McpMetadataHelper.TryResolveMetadata( + _entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); + } + + // 5) Authorization check + IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); + IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); + HttpContext? httpContext = httpContextAccessor.HttpContext; + + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", roleError, logger); + } + + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + _entityName, + EntityActionOperation.Execute, + out string? effectiveRole, + out string authError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", authError, logger); + } + + // 6) Build request payload + JsonElement? requestPayloadRoot = null; + if (parameters.Count > 0) + { + string jsonPayload = JsonSerializer.Serialize(parameters); + using JsonDocument doc = JsonDocument.Parse(jsonPayload); + requestPayloadRoot = doc.RootElement.Clone(); + } + + // 7) Build stored procedure execution context + StoredProcedureRequestContext context = new( + entityName: _entityName, + dbo: dbObject, + requestPayloadRoot: requestPayloadRoot, + operationType: EntityActionOperation.Execute); + + // Add user-provided parameters + if (requestPayloadRoot != null) + { + foreach (JsonProperty property in requestPayloadRoot.Value.EnumerateObject()) + { + context.FieldValuePairsInBody[property.Name] = GetParameterValue(property.Value); + } + } + + // Add default parameters from configuration if not provided + if (entityConfig.Source.Parameters != null) + { + foreach (ParameterMetadata param in entityConfig.Source.Parameters) + { + if (!context.FieldValuePairsInBody.ContainsKey(param.Name)) + { + context.FieldValuePairsInBody[param.Name] = param.Default; + } + } + } + + // Populate resolved parameters + context.PopulateResolvedParameters(); + + // 8) Execute stored procedure + DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; + IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); + IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(dbType); + + IActionResult? queryResult = null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + queryResult = await queryEngine.ExecuteAsync(context, dataSourceName).ConfigureAwait(false); + } + catch (DataApiBuilderException dabEx) + { + logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "ExecutionError", dabEx.Message, logger); + } + catch (SqlException sqlEx) + { + logger?.LogError(sqlEx, "SQL error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {sqlEx.Message}", logger); + } + catch (DbException dbEx) + { + logger?.LogError(dbEx, "Database error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An error occurred during execution.", logger); + } + + // 9) Build success response + return BuildExecuteSuccessResponse(toolName, _entityName, parameters, queryResult, logger); + } + catch (OperationCanceledException) + { + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The operation was canceled.", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error in DynamicCustomTool for {EntityName}", _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An unexpected error occurred.", logger); + } + } + + /// + /// Converts entity name to tool name format (lowercase with underscores). + /// + private static string ConvertToToolName(string entityName) + { + // Convert PascalCase to snake_case + string result = Regex.Replace(entityName, "([a-z0-9])([A-Z])", "$1_$2"); + return result.ToLowerInvariant(); + } + + /// + /// Builds the input schema for the tool based on entity parameters. + /// + private JsonElement BuildInputSchema() + { + Dictionary schema = new() + { + ["type"] = "object", + ["properties"] = new Dictionary() + }; + + if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) + { + Dictionary properties = (Dictionary)schema["properties"]; + + foreach (ParameterMetadata param in _entity.Source.Parameters) + { + // Note: Parameter type information is not available in ParameterMetadata, + // so we allow multiple JSON types to match the behavior of GetParameterValue + // that handles string, number, boolean, and null values. + properties[param.Name] = new Dictionary + { + ["type"] = new[] { "string", "number", "boolean", "null" }, + ["description"] = param.Description ?? $"Parameter {param.Name}" + }; + } + } + + return JsonSerializer.SerializeToElement(schema); + } + + /// + /// Converts a JSON element to its appropriate CLR type. + /// + private static object? GetParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => + element.TryGetInt64(out long longValue) ? longValue : + element.TryGetDecimal(out decimal decimalValue) ? decimalValue : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + /// + /// Builds a successful response for the execute operation. + /// + private static CallToolResult BuildExecuteSuccessResponse( + string toolName, + string entityName, + Dictionary? parameters, + IActionResult? queryResult, + ILogger? logger) + { + Dictionary responseData = new() + { + ["entity"] = entityName, + ["message"] = "Stored procedure executed successfully" + }; + + // Include parameters if any were provided + if (parameters?.Count > 0) + { + responseData["parameters"] = parameters; + } + + // Handle different result types + if (queryResult is OkObjectResult okResult && okResult.Value != null) + { + // Extract the actual data from the action result + if (okResult.Value is JsonDocument jsonDoc) + { + JsonElement root = jsonDoc.RootElement; + responseData["value"] = root.ValueKind == JsonValueKind.Array ? root : JsonSerializer.SerializeToElement(new[] { root }); + } + else if (okResult.Value is JsonElement jsonElement) + { + responseData["value"] = jsonElement.ValueKind == JsonValueKind.Array ? jsonElement : JsonSerializer.SerializeToElement(new[] { jsonElement }); + } + else + { + // Serialize the value directly + JsonElement serialized = JsonSerializer.SerializeToElement(okResult.Value); + responseData["value"] = serialized; + } + } + else if (queryResult is BadRequestObjectResult badRequest) + { + return McpResponseBuilder.BuildErrorResult( + toolName, + "BadRequest", + badRequest.Value?.ToString() ?? "Bad request", + logger); + } + else if (queryResult is UnauthorizedObjectResult) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "execute", "You do not have permission to execute this entity", logger); + } + else + { + // Empty or unknown result + responseData["value"] = JsonSerializer.SerializeToElement(Array.Empty()); + } + + return McpResponseBuilder.BuildSuccessResult( + responseData, + logger, + $"Custom tool {toolName} executed successfully for entity {entityName}." + ); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs index 75335b2db1..1a5c223798 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -19,9 +19,9 @@ public static CallToolResult PermissionDenied(string toolName, string entityName } // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". - public static CallToolResult ToolDisabled(string toolName, ILogger? logger) + public static CallToolResult ToolDisabled(string toolName, ILogger? logger, string? customMessage = null) { - string message = $"The {toolName} tool is disabled in the configuration."; + string message = customMessage ?? $"The {toolName} tool is disabled in the configuration."; return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger); } } diff --git a/src/Cli.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index 9386916f7f..e96d131880 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -633,5 +633,259 @@ private Task ExecuteVerifyTest(AddOptions options, string config = INITIAL_CONFI return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test adding table entity with MCP dml-tools enabled or disabled + /// + [DataTestMethod] + [DataRow("true", "books", "Book", DisplayName = "AddTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "authors", "Author", DisplayName = "AddTableEntityWithMcpDmlToolsDisabled")] + public Task AddTableEntityWithMcpDmlTools(string mcpDmlTools, string source, string entity) + { + AddOptions options = new( + source: source, + permissions: new string[] { "anonymous", "*" }, + entity: entity, + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: mcpDmlTools, + mcpCustomTool: null + ); + + VerifySettings settings = new(); + settings.UseParameters(mcpDmlTools, source); + return ExecuteVerifyTest(options, settings: settings); + } + + /// + /// Test adding stored procedure with MCP custom-tool enabled (should serialize as object) + /// + [TestMethod] + public Task AddStoredProcedureWithMcpCustomToolEnabled() + { + AddOptions options = new( + source: "dbo.GetBookById", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetBookById", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties set to different values (should serialize as object with both) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpProperties() + { + AddOptions options = new( + source: "dbo.UpdateBook", + permissions: new string[] { "anonymous", "execute" }, + entity: "UpdateBook", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties enabled (common use case) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpPropertiesEnabled() + { + AddOptions options = new( + source: "dbo.GetAllBooks", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetAllBooks", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test that adding table entity with custom-tool fails validation + /// + [TestMethod] + public void AddTableEntityWithInvalidMcpCustomTool() + { + AddOptions options = new( + source: "reviews", + permissions: new string[] { "anonymous", "*" }, + entity: "Review", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to add table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void AddEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + AddOptions options = new( + source: "MyTable", + permissions: new string[] { "anonymous", "*" }, + entity: "MyEntity", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail with invalid MCP option values"); + } + + #endregion MCP Entity Configuration Tests } } diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index a75a882968..a03dcddd10 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,6 +65,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRequestBodyStrict); // Ignore the IsGraphQLEnabled as that's unimportant from a test standpoint. diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index b05392aa81..cac2db0a83 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1160,7 +1160,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( string? graphQLOperationForStoredProcedure = null, string? cacheEnabled = null, string? cacheTtl = null, - string? description = null + string? description = null, + string? mcpDmlTools = null, + string? mcpCustomTool = null ) { return new( @@ -1197,7 +1199,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( fieldsNameCollection: null, fieldsAliasCollection: null, fieldsDescriptionCollection: null, - fieldsPrimaryKeyCollection: null + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool ); } @@ -1211,5 +1215,419 @@ private Task ExecuteVerifyTest(string initialConfig, UpdateOptions options, Veri return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test updating table entity with MCP dml-tools from false to true, or true to false + /// Tests actual update scenario where existing MCP config is modified + /// + [DataTestMethod] + [DataRow("true", "false", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "true", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsDisabled")] + public Task TestUpdateTableEntityWithMcpDmlTools(string newMcpDmlTools, string initialMcpDmlTools) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: newMcpDmlTools, + mcpCustomTool: null + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ], + ""mcp"": " + initialMcpDmlTools + @" + } + } + }"; + + VerifySettings settings = new(); + settings.UseParameters(newMcpDmlTools); + return ExecuteVerifyTest(initialConfig, options, settings: settings); + } + + /// + /// Test updating stored procedure with MCP custom-tool from false to true + /// Tests actual update scenario where existing MCP config is modified + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetBookById", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetBookById"": { + ""source"": ""dbo.GetBookById"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties + /// Updates from both true to custom-tool=true, dml-tools=false + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpProperties() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "UpdateBook", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""UpdateBook"": { + ""source"": ""dbo.UpdateBook"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": true + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties enabled + /// Updates from both false to both true + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetAllBooks", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetAllBooks"": { + ""source"": ""dbo.GetAllBooks"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test that updating table entity with custom-tool fails validation + /// + [TestMethod] + public void TestUpdateTableEntityWithInvalidMcpCustomTool() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to update table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void TestUpdateEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + $"Should fail to update entity with invalid MCP options: dml-tools={mcpDmlTools}, custom-tool={mcpCustomTool}"); + } + + #endregion MCP Entity Configuration Tests } } diff --git a/src/Cli/Commands/AddOptions.cs b/src/Cli/Commands/AddOptions.cs index b7d9fbeb08..e7e378d94b 100644 --- a/src/Cli/Commands/AddOptions.cs +++ b/src/Cli/Commands/AddOptions.cs @@ -43,7 +43,9 @@ public AddOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base( entity, @@ -69,6 +71,8 @@ public AddOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config ) { diff --git a/src/Cli/Commands/EntityOptions.cs b/src/Cli/Commands/EntityOptions.cs index 7f26816800..3b2b77d9b2 100644 --- a/src/Cli/Commands/EntityOptions.cs +++ b/src/Cli/Commands/EntityOptions.cs @@ -34,7 +34,9 @@ public EntityOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base(config) { @@ -61,6 +63,8 @@ public EntityOptions( FieldsAliasCollection = fieldsAliasCollection; FieldsDescriptionCollection = fieldsDescriptionCollection; FieldsPrimaryKeyCollection = fieldsPrimaryKeyCollection; + McpDmlTools = mcpDmlTools; + McpCustomTool = mcpCustomTool; } // Entity is required but we have made required as false to have custom error message (more user friendly), if not provided. @@ -132,5 +136,11 @@ public EntityOptions( [Option("fields.primary-key", Required = false, Separator = ',', HelpText = "Set this field as a primary key.")] public IEnumerable? FieldsPrimaryKeyCollection { get; } + + [Option("mcp.dml-tools", Required = false, HelpText = "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP. Default value is true.")] + public string? McpDmlTools { get; } + + [Option("mcp.custom-tool", Required = false, HelpText = "Enable MCP custom tool for this entity. Only valid for stored procedures. Default value is false.")] + public string? McpCustomTool { get; } } } diff --git a/src/Cli/Commands/UpdateOptions.cs b/src/Cli/Commands/UpdateOptions.cs index fe1664c5bb..050afa2ddb 100644 --- a/src/Cli/Commands/UpdateOptions.cs +++ b/src/Cli/Commands/UpdateOptions.cs @@ -51,7 +51,9 @@ public UpdateOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config) + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null) : base(entity, sourceType, sourceParameters, @@ -75,6 +77,8 @@ public UpdateOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config) { Source = source; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 679ccac668..77ccf88fe1 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -449,6 +449,18 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityMcpOptions? mcpOptions = null; + + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + + if (mcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } // Create new entity. Entity entity = new( @@ -460,7 +472,8 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt Relationships: null, Mappings: null, Cache: cacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description); + Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description, + Mcp: mcpOptions); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) @@ -1638,6 +1651,26 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig EntityActionFields? updatedFields = GetFieldsForOperation(options.FieldsToInclude, options.FieldsToExclude); EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + // Determine if the entity is or will be a stored procedure + bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); + + // Construct and validate MCP options if provided + EntityMcpOptions? updatedMcpOptions = null; + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + if (updatedMcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } + else + { + // Keep existing MCP options if no updates provided + updatedMcpOptions = entity.Mcp; + } + if (!updatedGraphQLDetails.Enabled) { _logger.LogWarning("Disabling GraphQL for this entity will restrict its usage in relationships"); @@ -1874,7 +1907,8 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Relationships: updatedRelationships, Mappings: updatedMappings, Cache: updatedCacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description + Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description, + Mcp: updatedMcpOptions ); IDictionary entities = new Dictionary(initialConfig.Entities.Entities) { diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index 451c330503..48edd4411c 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -892,6 +892,57 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, return cacheOptions with { Enabled = isEnabled, TtlSeconds = ttl, UserProvidedTtlOptions = isCacheTtlUserProvided }; } + /// + /// Constructs the EntityMcpOptions for Add/Update. + /// + /// String value that defines if DML tools are enabled for MCP. + /// String value that defines if custom tool is enabled for MCP. + /// Whether the entity is a stored procedure. + /// EntityMcpOptions if values are provided, null otherwise. + public static EntityMcpOptions? ConstructMcpOptions(string? mcpDmlTools, string? mcpCustomTool, bool isStoredProcedure) + { + if (mcpDmlTools is null && mcpCustomTool is null) + { + return null; + } + + bool? dmlToolsEnabled = null; + bool? customToolEnabled = null; + + // Parse dml-tools option + if (mcpDmlTools is not null) + { + if (!bool.TryParse(mcpDmlTools, out bool dmlValue)) + { + _logger.LogError("Invalid format for --mcp.dml-tools. Accepted values are true/false."); + return null; + } + + dmlToolsEnabled = dmlValue; + } + + // Parse custom-tool option + if (mcpCustomTool is not null) + { + if (!bool.TryParse(mcpCustomTool, out bool customValue)) + { + _logger.LogError("Invalid format for --mcp.custom-tool. Accepted values are true/false."); + return null; + } + + // Validate that custom-tool can only be used with stored procedures + if (customValue && !isStoredProcedure) + { + _logger.LogError("--mcp.custom-tool can only be enabled for stored procedures."); + return null; + } + + customToolEnabled = customValue; + } + + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + /// /// Check if add/update command has Entity provided. Return false otherwise. /// diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs new file mode 100644 index 0000000000..b4ad0e9170 --- /dev/null +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -0,0 +1,123 @@ +// 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; + +/// +/// Factory for creating EntityMcpOptions converters. +/// +internal class EntityMcpOptionsConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(EntityMcpOptions); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new EntityMcpOptionsConverter(); + } + + /// + /// Converter for EntityMcpOptions that handles both boolean and object representations. + /// When boolean: true enables dml-tools and custom-tool remains false (default), false disables dml-tools and custom-tool remains false. + /// When object: can specify individual properties (custom-tool and dml-tools). + /// + private class EntityMcpOptionsConverter : JsonConverter + { + public override EntityMcpOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Handle boolean shorthand: true/false + if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) + { + bool value = reader.GetBoolean(); + // Boolean true means: dml-tools=true, custom-tool=false (default) + // Boolean false means: dml-tools=false, custom-tool=false + // Pass null for customToolEnabled to keep it as default (not user-provided) + return new EntityMcpOptions( + customToolEnabled: null, + dmlToolsEnabled: value + ); + } + + // Handle object representation + if (reader.TokenType == JsonTokenType.StartObject) + { + bool? customToolEnabled = null; + bool? dmlToolsEnabled = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the value + + switch (propertyName) + { + case "custom-tool": + customToolEnabled = reader.GetBoolean(); + break; + case "dml-tools": + dmlToolsEnabled = reader.GetBoolean(); + break; + default: + throw new JsonException($"Unknown property '{propertyName}' in EntityMcpOptions"); + } + } + } + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for EntityMcpOptions"); + } + + public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSerializerOptions options) + { + if (value == null) + { + return; + } + + // Check if we should write as boolean shorthand + // Write as boolean if: only dml-tools is set (or custom-tool is default false) + bool writeAsBoolean = !value.UserProvidedCustomToolEnabled && value.UserProvidedDmlToolsEnabled; + + if (writeAsBoolean) + { + // Write as boolean shorthand + writer.WriteBooleanValue(value.DmlToolEnabled); + } + else if (value.UserProvidedCustomToolEnabled || value.UserProvidedDmlToolsEnabled) + { + // Write as object + writer.WriteStartObject(); + + if (value.UserProvidedCustomToolEnabled) + { + writer.WriteBoolean("custom-tool", value.CustomToolEnabled); + } + + if (value.UserProvidedDmlToolsEnabled) + { + writer.WriteBoolean("dml-tools", value.DmlToolEnabled); + } + + writer.WriteEndObject(); + } + } + } +} diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index c9f247e0f6..1e8c5a6dba 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.HealthCheck; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -39,6 +40,9 @@ public record Entity public EntityCacheOptions? Cache { get; init; } public EntityHealthCheckConfig? Health { get; init; } + [JsonConverter(typeof(EntityMcpOptionsConverterFactory))] + public EntityMcpOptions? Mcp { get; init; } + [JsonIgnore] public bool IsLinkingEntity { get; init; } @@ -54,7 +58,8 @@ public Entity( EntityCacheOptions? Cache = null, bool IsLinkingEntity = false, EntityHealthCheckConfig? Health = null, - string? Description = null) + string? Description = null, + EntityMcpOptions? Mcp = null) { this.Health = Health; this.Source = Source; @@ -67,6 +72,7 @@ public Entity( this.Cache = Cache; this.IsLinkingEntity = IsLinkingEntity; this.Description = Description; + this.Mcp = Mcp; } /// diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs new file mode 100644 index 0000000000..ad928a21ab --- /dev/null +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// Options for Model Context Protocol (MCP) tools at the entity level. + /// + public record EntityMcpOptions + { + /// + /// Indicates whether custom tools are enabled for this entity. + /// Only applicable for stored procedures. + /// + [JsonPropertyName("custom-tool")] + public bool CustomToolEnabled { get; init; } = false; + + /// + /// Indicates whether DML tools are enabled for this entity. + /// Defaults to true when not explicitly provided. + /// + [JsonPropertyName("dml-tools")] + public bool DmlToolEnabled { get; init; } = true; + + /// + /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedCustomToolEnabled { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write the DmlToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedDmlToolsEnabled { get; init; } = false; + + /// + /// Constructor for EntityMcpOptions + /// + /// The custom tool enabled flag. + /// The DML tools enabled flag. + public EntityMcpOptions(bool? customToolEnabled, bool? dmlToolsEnabled) + { + if (customToolEnabled.HasValue) + { + this.CustomToolEnabled = customToolEnabled.Value; + this.UserProvidedCustomToolEnabled = true; + } + + if (dmlToolsEnabled.HasValue) + { + this.DmlToolEnabled = dmlToolsEnabled.Value; + this.UserProvidedDmlToolsEnabled = true; + } + } + } +} diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 0e94c99657..ab8937dbfd 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -314,6 +314,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new EntityMcpOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs new file mode 100644 index 0000000000..5ce34c9355 --- /dev/null +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + /// + /// Tests for entity-level MCP configuration deserialization and validation. + /// Validates that EntityMcpOptions are correctly deserialized from runtime config JSON. + /// + [TestClass] + public class EntityMcpConfigurationTests + { + private const string BASE_CONFIG_TEMPLATE = @"{{ + ""$schema"": ""test-schema"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }}, + ""runtime"": {{ + ""rest"": {{ ""enabled"": true, ""path"": ""/api"" }}, + ""graphql"": {{ ""enabled"": true, ""path"": ""/graphql"" }}, + ""host"": {{ ""mode"": ""development"" }} + }}, + ""entities"": {{ + {0} + }} + }}"; + + /// + /// Helper method to create a config with specified entities JSON + /// + private static string CreateConfig(string entitiesJson) + { + return string.Format(BASE_CONFIG_TEMPLATE, entitiesJson); + } + + /// + /// Helper method to assert entity MCP configuration + /// + private static void AssertEntityMcp(Entity entity, bool? expectedDmlTools, bool? expectedCustomTool, string message = null) + { + if (expectedDmlTools == null && expectedCustomTool == null) + { + Assert.IsNull(entity.Mcp, "MCP options should be null when not specified"); + return; + } + + Assert.IsNotNull(entity.Mcp, message ?? "MCP options should be present"); + + bool actualDmlTools = entity.Mcp?.DmlToolEnabled ?? true; // Default is true + bool actualCustomTool = entity.Mcp?.CustomToolEnabled ?? false; // Default is false + + Assert.AreEqual(expectedDmlTools ?? true, actualDmlTools, + $"DmlToolEnabled should be {expectedDmlTools ?? true}"); + Assert.AreEqual(expectedCustomTool ?? false, actualCustomTool, + $"CustomToolEnabled should be {expectedCustomTool ?? false}"); + } + /// + /// Test that deserializing boolean 'true' shorthand correctly sets dml-tools enabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that deserializing boolean 'false' shorthand correctly sets dml-tools disabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with both properties works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObject_SetsBothProperties() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with only dml-tools works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that entity without MCP configuration has null Mcp property. + /// + [TestMethod] + public void DeserializeConfig_NoMcp_HasNullMcpOptions() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + Assert.IsNull(runtimeConfig.Entities["Book"].Mcp, "MCP options should be null when not specified"); + } + + /// + /// Test that deserializing object format with both properties set to true works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with both properties set to false works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with only custom-tool works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// Test that deserializing config with multiple entities having different MCP settings works. + /// + [TestMethod] + public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + }, + ""Author"": { + ""source"": ""authors"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + }, + ""Publisher"": { + ""source"": ""publishers"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + }, + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + + // Book: mcp = true + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + + // Author: mcp = false + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); + AssertEntityMcp(runtimeConfig.Entities["Author"], expectedDmlTools: false, expectedCustomTool: false); + + // Publisher: no mcp (null) + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); + Assert.IsNull(runtimeConfig.Entities["Publisher"].Mcp, "Mcp should be null when not specified"); + + // GetBook: mcp object + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing invalid MCP value (non-boolean, non-object) fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_InvalidMcpValue_FailsGracefully() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": ""invalid"" + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with invalid MCP value"); + } + + /// + /// Test that deserializing MCP object with unknown property fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithUnknownProperty_FailsGracefully() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true, + ""unknown-property"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with unknown MCP property"); + } + } +} diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs new file mode 100644 index 0000000000..278bc95cfb --- /dev/null +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -0,0 +1,721 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Tests for entity-level DML tool configuration (GitHub issue #3017). + /// Ensures that DML tools respect the entity-level Mcp.DmlToolEnabled property + /// in addition to the runtime-level configuration. + /// + /// Coverage: + /// - Entity with DmlToolEnabled=false (tool disabled at entity level) + /// - Entity with DmlToolEnabled=true (tool enabled at entity level) + /// - Entity with no MCP configuration (defaults to enabled) + /// - Custom tool with CustomToolEnabled=false (runtime validation) + /// + [TestClass] + public class EntityLevelDmlToolConfigurationTests + { + /// + /// Verifies that DML tools respect entity-level DmlToolEnabled=false. + /// When an entity has DmlToolEnabled explicitly set to false, the tool should + /// return a ToolDisabled error even if the runtime-level tool is enabled. + /// + /// The type of tool to test (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// The JSON arguments for the tool. + /// Whether the entity is a stored procedure (uses different config). + [DataTestMethod] + [DataRow("ReadRecords", "{\"entity\": \"Book\"}", false, DisplayName = "ReadRecords respects entity-level DmlToolEnabled=false")] + [DataRow("CreateRecord", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", false, DisplayName = "CreateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("UpdateRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", false, DisplayName = "UpdateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("DeleteRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", false, DisplayName = "DeleteRecord respects entity-level DmlToolEnabled=false")] + [DataRow("ExecuteEntity", "{\"entity\": \"GetBook\"}", true, DisplayName = "ExecuteEntity respects entity-level DmlToolEnabled=false")] + public async Task DmlTool_RespectsEntityLevelDmlToolDisabled(string toolType, string jsonArguments, bool isStoredProcedure) + { + // Arrange + RuntimeConfig config = isStoredProcedure + ? CreateConfigWithDmlToolDisabledStoredProcedure() + : CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + } + + /// + /// Verifies that DML tools work normally when entity-level DmlToolEnabled is not set to false. + /// This test ensures the entity-level check doesn't break the normal flow when either: + /// - DmlToolEnabled=true (explicitly enabled) + /// - entity.Mcp is null (defaults to enabled) + /// + /// The test scenario description. + /// Whether to include MCP config with DmlToolEnabled=true (false means no MCP config). + [DataTestMethod] + [DataRow("DmlToolEnabled=true", true, DisplayName = "ReadRecords works when entity has DmlToolEnabled=true")] + [DataRow("No MCP config", false, DisplayName = "ReadRecords works when entity has no MCP config")] + public async Task ReadRecords_WorksWhenNotDisabledAtEntityLevel(string scenario, bool useMcpConfig) + { + // Arrange + RuntimeConfig config = useMcpConfig + ? CreateConfigWithDmlToolEnabledEntity() + : CreateConfigWithEntityWithoutMcpConfig(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + // Should not be a ToolDisabled error - might be other errors (e.g., database connection) + // but that's OK for this test. We just want to ensure it passes the entity-level check. + if (result.IsError == true) + { + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString(); + Assert.AreNotEqual("ToolDisabled", errorTypeValue, + $"Should not get ToolDisabled error for scenario: {scenario}"); + } + } + } + + /// + /// Verifies the precedence of runtime-level vs entity-level configuration. + /// When runtime-level tool is disabled, entity-level DmlToolEnabled=true should NOT override it. + /// This validates that runtime-level acts as a global gate that takes precedence. + /// + [TestMethod] + public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled() + { + // Arrange - Runtime has readRecords=false, but entity has DmlToolEnabled=true + RuntimeConfig config = CreateConfigWithRuntimeDisabledButEntityEnabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when runtime-level tool is disabled"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + + // Verify the error is due to runtime-level, not entity-level + // (The error message should NOT mention entity-specific disabling) + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("message", out JsonElement errorMessage)) + { + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsFalse(message.Contains("entity"), + "Error should be from runtime-level check, not entity-level check"); + } + } + + /// + /// Verifies that DynamicCustomTool respects entity-level CustomToolEnabled configuration. + /// If CustomToolEnabled becomes false (e.g., after config hot-reload), ExecuteAsync should + /// return a ToolDisabled error. This ensures runtime validation even though tool instances + /// are created at startup. + /// + [TestMethod] + public async Task DynamicCustomTool_RespectsCustomToolDisabled() + { + // Arrange - Create a stored procedure entity with CustomToolEnabled=false + RuntimeConfig config = CreateConfigWithCustomToolDisabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + + // Create the DynamicCustomTool with the entity that has CustomToolEnabled initially true + // (simulating tool created at startup, then config changed) + Entity initialEntity = new( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: true) + ); + + Azure.DataApiBuilder.Mcp.Core.DynamicCustomTool tool = new("GetBook", initialEntity); + + JsonDocument arguments = JsonDocument.Parse("{}"); + + // Act - Execute with config that has CustomToolEnabled=false + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when CustomToolEnabled=false in runtime config"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content, "Custom tool is disabled for entity 'GetBook'"); + } + + #region View Support Tests + + /// + /// Data-driven test to verify all DML tools allow both table and view entities. + /// This is critical for scenarios like vector data type support, where users must: + /// - Create a view that omits unsupported columns (e.g., vector columns) + /// - Perform DML operations against that view + /// + /// The tool type to test. + /// The entity source type (Table or View). + /// The entity name to use. + /// The JSON arguments for the tool. + [DataTestMethod] + [DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")] + [DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")] + [DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")] + [DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")] + [DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")] + [DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")] + [DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")] + [DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")] + public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments) + { + // Arrange + RuntimeConfig config = sourceType == "View" + ? CreateConfigWithViewEntity() + : CreateConfigWithDmlToolEnabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert - Should NOT be a source type blocking error (InvalidEntity) + // Other errors like missing metadata are acceptable since we're testing source type validation + if (result.IsError == true) + { + JsonElement content = ParseResultContent(result); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString() ?? string.Empty; + + // This error type indicates the tool is blocking based on source type + Assert.AreNotEqual("InvalidEntity", errorTypeValue, + $"{sourceType} entities should not be blocked with InvalidEntity"); + } + } + } + + #endregion + + #region Helper Methods + + /// + /// Helper method to parse the JSON content from a CallToolResult without re-executing the tool. + /// + /// The result from executing an MCP tool. + /// The parsed JsonElement from the result's content. + private static JsonElement ParseResultContent(CallToolResult result) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + /// + /// Helper method to execute an MCP tool and return the parsed JsonElement from the result. + /// + /// The MCP tool to execute. + /// The JSON arguments for the tool. + /// The service provider with dependencies. + /// The parsed JsonElement from the tool's response. + private static async Task RunToolAsync(IMcpTool tool, JsonDocument arguments, IServiceProvider serviceProvider) + { + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + return ParseResultContent(result); + } + + /// + /// Helper method to assert that a JsonElement contains a ToolDisabled error. + /// + /// The JSON content to check for error. + /// Optional message fragment that should be present in the error message. + private static void AssertToolDisabledError(JsonElement content, string expectedMessageFragment = null) + { + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + + if (expectedMessageFragment != null) + { + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains(expectedMessageFragment), + $"Expected error message to contain '{expectedMessageFragment}', but got: {message}"); + } + } + + /// + /// Helper method to create an MCP tool instance based on the tool type. + /// + /// The type of tool to create (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// An instance of the requested tool. + private static IMcpTool CreateTool(string toolType) + { + return toolType switch + { + "ReadRecords" => new ReadRecordsTool(), + "CreateRecord" => new CreateRecordTool(), + "UpdateRecord" => new UpdateRecordTool(), + "DeleteRecord" => new DeleteRecordTool(), + "ExecuteEntity" => new ExecuteEntityTool(), + _ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) + }; + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledStoredProcedure() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=true. + /// + private static RuntimeConfig CreateConfigWithDmlToolEnabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has no MCP configuration. + /// + private static RuntimeConfig CreateConfigWithEntityWithoutMcpConfig() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: null + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has CustomToolEnabled=false. + /// Used to test DynamicCustomTool runtime validation. + /// + private static RuntimeConfig CreateConfigWithCustomToolDisabled() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config where runtime-level readRecords is disabled, + /// but entity-level DmlToolEnabled is true. This tests precedence behavior. + /// + private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: false, // Runtime-level DISABLED + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a view entity. + /// This is the key scenario for vector data type support. + /// + private static RuntimeConfig CreateConfigWithViewEntity() + { + Dictionary entities = new() + { + ["BookView"] = new Entity( + Source: new EntitySource( + Object: "dbo.vBooks", + Type: EntitySourceType.View, + Parameters: null, + KeyFields: new[] { "id" } + ), + GraphQL: new("BookView", "BookViews"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a service provider with mocked dependencies for testing MCP tools. + /// Includes metadata provider mocks so tests can reach source type validation. + /// + private static IServiceProvider CreateServiceProvider(RuntimeConfig config) + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + Mock mockHttpContext = new(); + Mock mockRequest = new(); + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); + + Mock mockHttpContextAccessor = new(); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + services.AddSingleton(mockHttpContextAccessor.Object); + + // Add metadata provider mocks so tests can reach source type validation. + // This is required for DmlTool_AllowsTablesAndViews to actually test the source type behavior. + Mock mockSqlMetadataProvider = new(); + Dictionary entityToDatabaseObject = new(); + + // Add database objects for each entity in the config + if (config.Entities != null) + { + foreach (KeyValuePair kvp in config.Entities) + { + string entityName = kvp.Key; + Entity entity = kvp.Value; + EntitySourceType sourceType = entity.Source.Type ?? EntitySourceType.Table; + + DatabaseObject dbObject; + if (sourceType == EntitySourceType.View) + { + dbObject = new DatabaseView("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.View + }; + } + else if (sourceType == EntitySourceType.StoredProcedure) + { + dbObject = new DatabaseStoredProcedure("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.StoredProcedure + }; + } + else + { + dbObject = new DatabaseTable("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.Table + }; + } + + entityToDatabaseObject[entityName] = dbObject; + } + } + + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject); + mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); + + Mock mockMetadataProviderFactory = new(); + mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); + services.AddSingleton(mockMetadataProviderFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + #endregion + } +} diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 907c60f652..89b7dbc3c4 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,6 +69,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.CosmosDataSourceUsed); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint.