diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs new file mode 100644 index 0000000000..b39ba80cea --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Factory for creating custom MCP tools from stored procedure entity configurations. + /// Scans runtime configuration and generates dynamic tools for entities marked with custom-tool enabled. + /// + public class CustomMcpToolFactory + { + /// + /// Creates custom MCP tools from entities configured with "mcp": { "custom-tool": true }. + /// + /// The runtime configuration containing entity definitions. + /// Optional logger for diagnostic information. + /// Enumerable of custom tools generated from configuration. + public static IEnumerable CreateCustomTools(RuntimeConfig config, ILogger? logger = null) + { + if (config?.Entities == null) + { + logger?.LogWarning("No entities found in runtime configuration for custom tool generation."); + return Enumerable.Empty(); + } + + List customTools = new(); + + foreach ((string entityName, Entity entity) in config.Entities) + { + // Filter: Only stored procedures with custom-tool enabled + if (entity.Source.Type == EntitySourceType.StoredProcedure && + entity.Mcp?.CustomToolEnabled == true) + { + try + { + DynamicCustomTool tool = new(entityName, entity); + + logger?.LogInformation( + "Created custom MCP tool '{ToolName}' for stored procedure entity '{EntityName}'", + tool.GetToolMetadata().Name, + entityName); + + customTools.Add(tool); + } + catch (Exception ex) + { + logger?.LogError( + ex, + "Failed to create custom tool for entity '{EntityName}'. Skipping.", + entityName); + } + } + } + + logger?.LogInformation("Custom MCP tool generation complete. Created {Count} custom tools.", customTools.Count); + return customTools; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs new file mode 100644 index 0000000000..6ac3512206 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -0,0 +1,375 @@ +// 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); + } + + // 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() + { + var schema = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary() + }; + + if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) + { + var properties = (Dictionary)schema["properties"]; + + foreach (var 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}" + }; + } + } + else + { + schema["properties"] = new Dictionary(); + } + + 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"] = "Execution successful" + }; + + if (parameters?.Count > 0) + { + responseData["parameters"] = parameters; + } + + // Handle different result types + if (queryResult is OkObjectResult okResult && okResult.Value != null) + { + 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 + { + 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", "Unauthorized", logger); + } + else + { + 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/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index 01f6015786..bc87602da9 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -38,6 +38,9 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Auto-discover and register all MCP tools RegisterAllMcpTools(services); + // Register custom tools from configuration + RegisterCustomTools(services, runtimeConfig); + // Configure MCP server services.ConfigureMcpServer(); @@ -54,12 +57,25 @@ private static void RegisterAllMcpTools(IServiceCollection services) IEnumerable toolTypes = mcpAssembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && - typeof(IMcpTool).IsAssignableFrom(t)); + typeof(IMcpTool).IsAssignableFrom(t) && + t != typeof(DynamicCustomTool)); // Exclude DynamicCustomTool from auto-registration foreach (Type toolType in toolTypes) { services.AddSingleton(typeof(IMcpTool), toolType); } } + + /// + /// Registers custom MCP tools generated from stored procedure entity configurations. + /// + private static void RegisterCustomTools(IServiceCollection services, RuntimeConfig config) + { + // Create custom tools and register each as a singleton + foreach (IMcpTool customTool in CustomMcpToolFactory.CreateCustomTools(config)) + { + services.AddSingleton(customTool); + } + } } } diff --git a/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs b/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs new file mode 100644 index 0000000000..db7d8ed28c --- /dev/null +++ b/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Minimal unit tests for CustomMcpToolFactory covering filtering and creation logic. + /// Comprehensive tests will be added in subsequent PRs. + /// + [TestClass] + public class CustomMcpToolFactoryTests + { + /// + /// Test that CreateCustomTools returns empty collection when config is null. + /// + [TestMethod] + public void CreateCustomTools_ReturnsEmptyCollection_WhenConfigIsNull() + { + // Act + var tools = CustomMcpToolFactory.CreateCustomTools(null!); + + // Assert + Assert.IsNotNull(tools); + Assert.AreEqual(0, tools.Count()); + } + + /// + /// Test that CreateCustomTools returns empty collection when no entities exist. + /// + [TestMethod] + public void CreateCustomTools_ReturnsEmptyCollection_WhenNoEntities() + { + // Arrange + var config = CreateEmptyConfig(); + + // Act + var tools = CustomMcpToolFactory.CreateCustomTools(config); + + // Assert + Assert.IsNotNull(tools); + Assert.AreEqual(0, tools.Count()); + } + + /// + /// Test that CreateCustomTools filters entities correctly for custom tools. + /// Should only include stored procedures with custom-tool enabled. + /// + [TestMethod] + public void CreateCustomTools_FiltersEntitiesCorrectly() + { + // Arrange + var config = CreateConfigWithMixedEntities(); + + // Act + var tools = CustomMcpToolFactory.CreateCustomTools(config); + + // Assert + Assert.IsNotNull(tools); + // Should only include GetBook (SP with custom-tool enabled) + Assert.AreEqual(1, tools.Count()); + Assert.AreEqual("get_book", tools.First().GetToolMetadata().Name); + } + + /// + /// Test that CreateCustomTools continues processing when one entity fails. + /// + [TestMethod] + public void CreateCustomTools_ContinuesOnFailure() + { + // Arrange + var config = CreateConfigWithInvalidEntity(); + + // Act + var tools = CustomMcpToolFactory.CreateCustomTools(config); + + // Assert + Assert.IsNotNull(tools); + // Should skip invalid entities and continue + Assert.AreEqual(1, tools.Count()); + } + + /// + /// Test that CreateCustomTools generates correct metadata for tools. + /// + [TestMethod] + public void CreateCustomTools_GeneratesCorrectMetadata() + { + // Arrange + var config = CreateConfigWithDescribedEntity(); + + // Act + var tools = CustomMcpToolFactory.CreateCustomTools(config); + + // Assert + Assert.AreEqual(1, tools.Count()); + var metadata = tools.First().GetToolMetadata(); + Assert.AreEqual("get_user", metadata.Name); + Assert.AreEqual("Gets user by ID", metadata.Description); + } + + #region Helper Methods + + private static RuntimeConfig CreateEmptyConfig() + { + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(new Dictionary()) + ); + } + + private static RuntimeConfig CreateConfigWithMixedEntities() + { + var entities = new Dictionary + { + // Table entity - should be filtered out + ["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 + ), + // SP without custom-tool enabled - should be filtered out + ["CountBooks"] = new Entity( + Source: new("count_books", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("CountBooks", "CountBooks"), + 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: null) + ), + // SP with custom-tool enabled - should be included + ["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: null) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + private static RuntimeConfig CreateConfigWithInvalidEntity() + { + var entities = new Dictionary + { + // Valid SP + ["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: null) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + private static RuntimeConfig CreateConfigWithDescribedEntity() + { + var entities = new Dictionary + { + ["GetUser"] = new Entity( + Source: new("get_user", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetUser", "GetUser"), + 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, + Description: "Gets user by ID", + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: null) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + #endregion + } +} diff --git a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs new file mode 100644 index 0000000000..4c2961c16c --- /dev/null +++ b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Linq; +using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Minimal unit tests for DynamicCustomTool covering critical functionality. + /// Comprehensive tests will be added in subsequent PRs. + /// + [TestClass] + public class DynamicCustomToolTests + { + /// + /// Test that DynamicCustomTool correctly converts PascalCase entity names to snake_case tool names. + /// + [TestMethod] + [DataRow("GetUserProfile", "get_user_profile")] + [DataRow("GetBook", "get_book")] + [DataRow("InsertBookRecord", "insert_book_record")] + [DataRow("CountBooks", "count_books")] + [DataRow("lowercase", "lowercase")] + [DataRow("UPPERCASE", "uppercase")] + public void GetToolMetadata_ConvertsEntityNameToSnakeCase(string entityName, string expectedToolName) + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new(entityName, entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + Assert.AreEqual(expectedToolName, metadata.Name); + } + + /// + /// Test that tool metadata includes entity description when provided. + /// + [TestMethod] + public void GetToolMetadata_UsesEntityDescription_WhenProvided() + { + // Arrange + string description = "Retrieves a book by ID"; + Entity entity = CreateTestStoredProcedureEntity(description: description); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + Assert.AreEqual(description, metadata.Description); + } + + /// + /// Test that tool metadata generates default description when not provided. + /// + [TestMethod] + public void GetToolMetadata_GeneratesDefaultDescription_WhenNotProvided() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + Assert.IsTrue(metadata.Description?.Contains("get_book") ?? false); + Assert.IsTrue(metadata.Description?.Contains("stored procedure") ?? false); + } + + /// + /// Test that constructor throws ArgumentNullException when entity name is null. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_ThrowsArgumentNullException_WhenEntityNameIsNull() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + + // Act + _ = new DynamicCustomTool(null!, entity); + } + + /// + /// Test that constructor throws ArgumentNullException when entity is null. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_ThrowsArgumentNullException_WhenEntityIsNull() + { + // Act + _ = new DynamicCustomTool("TestEntity", null!); + } + + /// + /// Test that constructor throws ArgumentException when entity is not a stored procedure. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Constructor_ThrowsArgumentException_WhenEntityIsNotStoredProcedure() + { + // Arrange - Create table entity + Entity tableEntity = new( + 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 + ); + + // Act + _ = new DynamicCustomTool("Book", tableEntity); + } + + /// + /// Test that input schema is generated with empty properties when no parameters. + /// + [TestMethod] + public void GetToolMetadata_GeneratesEmptySchema_WhenNoParameters() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new("GetBooks", entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + Assert.IsNotNull(metadata.InputSchema); + var schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out var props)); + Assert.AreEqual(JsonValueKind.Object, props.ValueKind); + } + + /// + /// Test that input schema includes parameter definitions with descriptions. + /// + [TestMethod] + public void GetToolMetadata_GeneratesSchemaWithParameters_WhenParametersProvided() + { + // Arrange + var parameters = new[] + { + new ParameterMetadata { Name = "id", Description = "The book ID" }, + new ParameterMetadata { Name = "title", Description = "The book title" } + }; + Entity entity = CreateTestStoredProcedureEntity(parameters: parameters); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + var schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out var props)); + Assert.IsTrue(props.TryGetProperty("id", out var idParam)); + Assert.IsTrue(idParam.TryGetProperty("description", out var idDesc)); + Assert.AreEqual("The book ID", idDesc.GetString()); + Assert.IsTrue(props.TryGetProperty("title", out var titleParam)); + Assert.IsTrue(titleParam.TryGetProperty("description", out var titleDesc)); + Assert.AreEqual("The book title", titleDesc.GetString()); + } + + /// + /// Test that parameter schema uses default description when not provided. + /// + [TestMethod] + public void GetToolMetadata_UsesDefaultParameterDescription_WhenNotProvided() + { + // Arrange + var parameters = new[] + { + new ParameterMetadata { Name = "userId" } + }; + Entity entity = CreateTestStoredProcedureEntity(parameters: parameters); + DynamicCustomTool tool = new("GetUser", entity); + + // Act + var metadata = tool.GetToolMetadata(); + + // Assert + var schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out var props)); + Assert.IsTrue(props.TryGetProperty("userId", out var userIdParam)); + Assert.IsTrue(userIdParam.TryGetProperty("description", out var desc)); + Assert.IsTrue(desc.GetString()!.Contains("userId")); + } + + /// + /// Helper method to create a test stored procedure entity. + /// + private static Entity CreateTestStoredProcedureEntity( + string? description = null, + ParameterMetadata[]? parameters = null) + { + return new Entity( + Source: new( + Object: "test_procedure", + Type: EntitySourceType.StoredProcedure, + Parameters: parameters?.ToList(), + KeyFields: null + ), + GraphQL: new(Singular: "TestProcedure", Plural: "TestProcedures"), + 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, + Cache: null, + IsLinkingEntity: false, + Health: null, + Description: description, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: null) + ); + } + } +}