diff --git a/.gitattributes b/.gitattributes index a224f6bcbb..fbe6bd70b1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,6 @@ *.verified.txt text eol=lf working-tree-encoding=UTF-8 *.verified.xml text eol=lf working-tree-encoding=UTF-8 *.verified.json text eol=lf working-tree-encoding=UTF-8 + +# This can't be enabled as there are tests that rely on the CRLF endings in files' +#*.cs text eol=lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab98fbe8cf..a9fd633f27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,7 +95,7 @@ We use `dotnet format` to enforce code conventions. It is run automatically in C #### Enforcing code style with git hooks -You can copy paste the following commands to install a git pre-commit hook. This will cause a commit to fail if you forgot to run `dotnet format`. If you have run on save enabled in your editor this is not necessary. +You can copy paste the following commands to install a git pre-commit hook (creates a pre-commit file in your .git folder, which isn't shown in vs code). This will cause a commit to fail if you forgot to run `dotnet format`. If you have run on save enabled in your editor this is not necessary. ```bash cat > .git/hooks/pre-commit << __EOF__ @@ -112,17 +112,42 @@ if [ "\$(get_files)" = '' ]; then fi get_files | - xargs dotnet format src/Azure.DataApiBuilder.Service.sln \\ - --check \\ - --fix-whitespace --fix-style warn --fix-analyzers warn \\ + xargs dotnet format src/Azure.DataApiBuilder.sln \\ + --verify-no-changes --include \\ || { get_files | - xargs dotnet format src/Azure.DataApiBuilder.Service.sln \\ - --fix-whitespace --fix-style warn --fix-analyzers warn \\ + xargs dotnet format src/Azure.DataApiBuilder.sln \\ --include exit 1 } __EOF__ chmod +x .git/hooks/pre-commit ``` + +The file should look like this + +``` bash +#!/bin/bash +set -euo pipefail + +get_files() { + git diff --cached --name-only --diff-filter=ACMR | \ + grep '\.cs$' +} + +if [ "$(get_files)" = '' ]; then + exit 0 +fi + +get_files | + xargs dotnet format src/Azure.DataApiBuilder.sln \ + --verify-no-changes \ + --include \ + || { + get_files | + xargs dotnet format src/Azure.DataApiBuilder.sln \ + --include + exit 1 +} +``` diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index dd0bebd3cb..c8d4488526 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -41,8 +41,8 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", - "description": "Enable health check endpoint", + "$ref": "#/$defs/boolean-or-string", + "description": "Enable health check endpoint for something", "default": true, "additionalProperties": false }, @@ -186,7 +186,7 @@ "type": "string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling REST requests for all entities." }, "request-body-strict": { @@ -210,7 +210,7 @@ "type": "string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling GraphQL requests for all entities." }, "depth-limit": { @@ -438,7 +438,7 @@ "description": "Application Insights connection string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling Application Insights telemetry.", "default": true } @@ -481,7 +481,7 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling Azure Log Analytics.", "default": false }, @@ -618,7 +618,7 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Enable health check endpoint globally", "default": true, "additionalProperties": false @@ -1179,7 +1179,15 @@ "type": "string" } }, - "required": ["singular"] + "required": [ "singular" ] + } + ] + }, + "boolean-or-string": { + "oneOf":[ + { + "type": [ "boolean", "string" ], + "pattern": "^(?:true|false|1|0|@env\\('.*'\\)|@akv\\('.*'\\))$" } ] }, diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs index 6584e819af..51d8295068 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs @@ -3,7 +3,9 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; +using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Model; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -158,7 +160,26 @@ public async Task RunAsync(CancellationToken cancellationToken) /// private void HandleInitialize(JsonElement? id) { - var result = new + // Get the description from runtime config if available + string? instructions = null; + RuntimeConfigProvider? runtimeConfigProvider = _serviceProvider.GetService(); + if (runtimeConfigProvider != null) + { + try + { + RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); + instructions = runtimeConfig.Runtime?.Mcp?.Description; + } + catch (Exception ex) + { + // Log to stderr for diagnostics and rethrow to avoid masking configuration errors + Console.Error.WriteLine($"[MCP WARNING] Failed to retrieve MCP description from config: {ex.Message}"); + throw; + } + } + + // Create the initialize response + object result = new { protocolVersion = _protocolVersion, capabilities = new @@ -170,7 +191,8 @@ private void HandleInitialize(JsonElement? id) { name = McpProtocolDefaults.MCP_SERVER_NAME, version = McpProtocolDefaults.MCP_SERVER_VERSION - } + }, + instructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null }; WriteResult(id, result); diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 073f349a67..4dad501fda 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -926,6 +926,34 @@ public void TestFailureWhenAddingSetSessionContextToMySQLDatabase() Assert.IsFalse(isSuccess); } + /// + /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results + /// in runtime config update. Takes in updated value for mcp.description and + /// validates whether the runtime config reflects those updated values + /// + [DataTestMethod] + [DataRow("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")] + [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")] + public void TestConfigureDescriptionForMcpSettings(string descriptionValue) + { + // Arrange -> all the setup which includes creating options. + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + // Act: Attempts to update mcp.description value + ConfigureOptions options = new( + runtimeMcpDescription: descriptionValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: Validate the Description is updated + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); + Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description); + Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); + } + /// /// Sets up the mock file system with an initial configuration file. /// This method adds a config file to the mock file system and verifies its existence. diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 11dca2a4eb..c3e0352249 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -38,6 +38,7 @@ public ConfigureOptions( bool? runtimeRestRequestBodyStrict = null, bool? runtimeMcpEnabled = null, string? runtimeMcpPath = null, + string? runtimeMcpDescription = null, bool? runtimeMcpDmlToolsEnabled = null, bool? runtimeMcpDmlToolsDescribeEntitiesEnabled = null, bool? runtimeMcpDmlToolsCreateRecordEnabled = null, @@ -93,6 +94,7 @@ public ConfigureOptions( // Mcp RuntimeMcpEnabled = runtimeMcpEnabled; RuntimeMcpPath = runtimeMcpPath; + RuntimeMcpDescription = runtimeMcpDescription; RuntimeMcpDmlToolsEnabled = runtimeMcpDmlToolsEnabled; RuntimeMcpDmlToolsDescribeEntitiesEnabled = runtimeMcpDmlToolsDescribeEntitiesEnabled; RuntimeMcpDmlToolsCreateRecordEnabled = runtimeMcpDmlToolsCreateRecordEnabled; @@ -180,6 +182,9 @@ public ConfigureOptions( [Option("runtime.mcp.path", Required = false, HelpText = "Customize DAB's MCP endpoint path. Default: '/mcp' Conditions: Prefix path with '/'.")] public string? RuntimeMcpPath { get; } + [Option("runtime.mcp.description", Required = false, HelpText = "Set the MCP server description to be exposed in the initialize response.")] + public string? RuntimeMcpDescription { get; } + [Option("runtime.mcp.dml-tools.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools endpoint. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsEnabled { get; } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 07dad2af8b..679ccac668 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -797,7 +797,15 @@ private static bool TryUpdateConfiguredRuntimeOptions( // MCP: Enabled and Path if (options.RuntimeMcpEnabled != null || - options.RuntimeMcpPath != null) + options.RuntimeMcpPath != null || + options.RuntimeMcpDescription != null || + options.RuntimeMcpDmlToolsEnabled != null || + options.RuntimeMcpDmlToolsDescribeEntitiesEnabled != null || + options.RuntimeMcpDmlToolsCreateRecordEnabled != null || + options.RuntimeMcpDmlToolsReadRecordsEnabled != null || + options.RuntimeMcpDmlToolsUpdateRecordEnabled != null || + options.RuntimeMcpDmlToolsDeleteRecordEnabled != null || + options.RuntimeMcpDmlToolsExecuteEntityEnabled != null) { McpRuntimeOptions updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new(); bool status = TryUpdateConfiguredMcpValues(options, ref updatedMcpOptions); @@ -1053,6 +1061,14 @@ private static bool TryUpdateConfiguredMcpValues( } } + // Runtime.Mcp.Description + updatedValue = options?.RuntimeMcpDescription; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { Description = (string)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Description as '{updatedValue}'", updatedValue); + } + // Handle DML tools configuration bool hasToolUpdates = false; DmlToolsConfig? currentDmlTools = updatedMcpOptions?.DmlTools; diff --git a/src/Config/Converters/BooleanJsonConverter.cs b/src/Config/Converters/BooleanJsonConverter.cs new file mode 100644 index 0000000000..5932a70092 --- /dev/null +++ b/src/Config/Converters/BooleanJsonConverter.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// JSON converter for boolean values that also supports string representations such as +/// "true", "false", "1", and "0". Any environment variable replacement is handled by +/// other converters (for example, the string converter) before the value is parsed here. +/// +public class BoolJsonConverter : JsonConverter +{ + + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + + throw new JsonException("Unexpected null JSON token. Expected a boolean literal or a valid @expression."); + } + + if (reader.TokenType == JsonTokenType.String) + { + + string? tempBoolean = JsonSerializer.Deserialize(ref reader, options); + + bool result = tempBoolean?.ToLower() switch + { + //numeric values have to be checked here as they may come from string replacement + "true" or "1" => true, + "false" or "0" => false, + _ => throw new JsonException($"Invalid boolean value: {tempBoolean}. Specify either true or 1 for true, false or 0 for false"), + }; + + return result; + } + else if (reader.TokenType == JsonTokenType.Number) + { + bool result = reader.GetInt32() switch + { + 1 => true, + 0 => false, + _ => throw new JsonException($"Invalid value for boolean attribute. Specify either 1 or 0."), + }; + return result; + } + else + { + return reader.GetBoolean(); + } + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs index d75cbbef5a..8b3c640725 100644 --- a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -65,12 +65,13 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? bool enabled = true; string? path = null; DmlToolsConfig? dmlTools = null; + string? description = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { - return new McpRuntimeOptions(enabled, path, dmlTools); + return new McpRuntimeOptions(enabled, path, dmlTools, description); } string? propertyName = reader.GetString(); @@ -98,6 +99,14 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? dmlTools = dmlToolsConfigConverter.Read(ref reader, typeToConvert, options); break; + case "description": + if (reader.TokenType is not JsonTokenType.Null) + { + description = reader.DeserializeString(_replacementSettings); + } + + break; + default: throw new JsonException($"Unexpected property {propertyName}"); } @@ -134,6 +143,13 @@ public override void Write(Utf8JsonWriter writer, McpRuntimeOptions value, JsonS dmlToolsOptionsConverter.Write(writer, value.DmlTools, options); } + // Write description if it's provided + if (value is not null && !string.IsNullOrWhiteSpace(value.Description)) + { + writer.WritePropertyName("description"); + JsonSerializer.Serialize(writer, value.Description, options); + } + writer.WriteEndObject(); } } diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index cd1e24f5fd..e17d53fc8f 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -30,11 +30,18 @@ public record McpRuntimeOptions [JsonConverter(typeof(DmlToolsConfigConverter))] public DmlToolsConfig? DmlTools { get; init; } + /// + /// Description of the MCP server to be exposed in the initialize response + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + [JsonConstructor] public McpRuntimeOptions( bool? Enabled = null, string? Path = null, - DmlToolsConfig? DmlTools = null) + DmlToolsConfig? DmlTools = null, + string? Description = null) { this.Enabled = Enabled ?? true; @@ -58,6 +65,8 @@ public McpRuntimeOptions( { this.DmlTools = DmlTools; } + + this.Description = Description; } /// diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index bad5aa8680..0e94c99657 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -323,6 +323,7 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new AKVRetryPolicyOptionsConverterFactory(replacementSettings)); options.Converters.Add(new AzureLogAnalyticsOptionsConverterFactory(replacementSettings)); options.Converters.Add(new AzureLogAnalyticsAuthOptionsConverter(replacementSettings)); + options.Converters.Add(new BoolJsonConverter()); options.Converters.Add(new FileSinkConverter(replacementSettings)); // Add AzureKeyVaultOptionsConverterFactory to ensure AKV config is deserialized properly diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 00729476cd..1294c009da 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -161,8 +161,6 @@ public void GlobalCacheOptionsDeserialization_ValidValues( [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 0 }", DisplayName = "EntityCacheOptions.TtlSeconds set to zero is invalid configuration.")] [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": -1 }", DisplayName = "EntityCacheOptions.TtlSeconds set to negative number is invalid configuration.")] [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 1.1 }", DisplayName = "EntityCacheOptions.TtlSeconds set to decimal is invalid configuration.")] - [DataRow(@",""cache"": { ""enabled"": 1 }", DisplayName = "EntityCacheOptions.Enabled property set to 1 should fail because not a boolean.")] - [DataRow(@",""cache"": { ""enabled"": 0 }", DisplayName = "EntityCacheOptions.Enabled property set to 0 should fail because not a boolean.")] [DataRow(@",""cache"": 1", DisplayName = "EntityCacheOptions property set to 1 should fail because it's not a JSON object.")] [DataRow(@",""cache"": 0", DisplayName = "EntityCacheOptions property set to 0 should fail because it's not a JSON object.")] [DataRow(@",""cache"": true", DisplayName = "EntityCacheOptions property set to true should fail because it's not a JSON object.")] diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index bd0ce7d1ff..d42af33259 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -664,6 +664,46 @@ type Moon { ""entities"":{ } }"; + public const string CONFIG_FILE_WITH_BOOLEAN_AS_ENV = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"", + ""health"": { + ""enabled"": + } + }, + ""runtime"": { + ""health"": { + ""enabled"": + }, + ""rest"": { + ""enabled"": , + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": , + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""authentication"": { + ""provider"": ""AppService"" + } + }, + ""telemetry"": { + ""application-insights"":{ + ""enabled"": , + ""connection-string"":""sample-ai-connection-string"" + } + + } + + }, + ""entities"":{ } + }"; + [TestCleanup] public void CleanupAfterEachTest() { @@ -1820,8 +1860,44 @@ public void TestBasicConfigSchemaWithNoOptionalFieldsIsValid(string jsonData) JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.IsTrue(result.IsValid); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + + Assert.IsTrue(result.IsValid); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [DataTestMethod] + [DataRow("true", DisplayName = "Validates variable boolean schema for true value")] + [DataRow("false", DisplayName = "Validates variable boolean schema for false value.")] + [DataRow("\"true\"", DisplayName = "Validates variable boolean schema for true as string.")] + [DataRow("\"false\"", DisplayName = "Validates variable boolean schema for false as string.")] + [DataRow("\"1\"", DisplayName = "Validates variable boolean schema for 1 as string.")] + [DataRow("\"0\"", DisplayName = "Validates variable boolean schema for 0as string.")] + [DataRow("\"@env('SAMPLE')\"", DisplayName = "Validates variable boolean schema for environment variables.")] + [DataRow("\"@akv('SAMPLE')\"", DisplayName = "Validates variable boolean schema for keyvaul variables.")] + public void TestBasicConfigSchemaWithFlexibleBoolean(string Value) + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + string jsonData = CONFIG_FILE_WITH_BOOLEAN_AS_ENV.Replace("", Value); + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); + + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors), "Validation Erros null of empty"); + + Assert.IsTrue(result.IsValid, "Result should be valid"); schemaValidatorLogger.Verify( x => x.Log( LogLevel.Information, @@ -3350,7 +3426,7 @@ public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtr HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); string requestBody = @"{ ""title"": ""Harry Potter and the Order of Phoenix"", - ""publisher_id"": 1234"; + ""publisher_id"": 1234 "; if (includeExtraneousFieldInRequestBody) { diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs new file mode 100644 index 0000000000..eefa1f08f4 --- /dev/null +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + /// + /// Tests for McpRuntimeOptions serialization and deserialization, + /// including edge cases for the description field. + /// + [TestClass] + public class McpRuntimeOptionsSerializationTests + { + /// + /// Validates that McpRuntimeOptions with a description can be serialized to JSON + /// and deserialized back to the same object. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithDescription() + { + // Arrange + string description = "This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user."; + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: description + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsTrue(json.Contains("\"description\""), "JSON should contain description field"); + Assert.IsTrue(json.Contains(description), "JSON should contain description value"); + Assert.AreEqual(description, deserializedConfig.Runtime.Mcp.Description, "Description should match"); + } + + /// + /// Validates that McpRuntimeOptions without a description is serialized correctly + /// and the description field is omitted from JSON. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithoutDescription() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: null + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config without MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null"); + Assert.IsFalse(json.Contains("\"description\""), "JSON should not contain description field when null"); + } + + /// + /// Validates that McpRuntimeOptions with an empty string description is serialized correctly + /// and the description field is omitted from JSON. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithEmptyDescription() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: "" + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with empty MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsTrue(string.IsNullOrEmpty(deserializedConfig.Runtime.Mcp.Description), "Description should be empty"); + Assert.IsFalse(json.Contains("\"description\""), "JSON should not contain description field when empty"); + } + + /// + /// Validates that McpRuntimeOptions with a very long description is serialized and deserialized correctly. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithLongDescription() + { + // Arrange + string longDescription = new('A', 5000); // 5000 character description + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: longDescription + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with long MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.AreEqual(longDescription, deserializedConfig.Runtime.Mcp.Description, "Long description should match"); + Assert.AreEqual(5000, deserializedConfig.Runtime.Mcp.Description?.Length, "Description length should be 5000"); + } + + /// + /// Validates that McpRuntimeOptions with special characters in description is serialized and deserialized correctly. + /// + [DataTestMethod] + [DataRow("Description with \"quotes\" and 'apostrophes'", DisplayName = "Description with quotes")] + [DataRow("Description with\nnewlines\nand\ttabs", DisplayName = "Description with newlines and tabs")] + [DataRow("Description with special chars: <>&@#$%^*()[]{}|\\", DisplayName = "Description with special characters")] + [DataRow("Description with unicode: 你好世界 🚀 café", DisplayName = "Description with unicode")] + public void TestMcpRuntimeOptionsSerializationWithSpecialCharacters(string description) + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: description + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, $"Failed to deserialize config with special character description: {description}"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.AreEqual(description, deserializedConfig.Runtime.Mcp.Description, "Description with special characters should match exactly"); + } + + /// + /// Validates that existing MCP configuration without description field can be deserialized successfully. + /// This ensures backward compatibility. + /// + [TestMethod] + public void TestBackwardCompatibilityDeserializationWithoutDescriptionField() + { + // Arrange - JSON config without description field + string configJson = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=test;Database=test;"" + }, + ""runtime"": { + ""mcp"": { + ""enabled"": true, + ""path"": ""/mcp"" + } + }, + ""entities"": {} + }"; + + // Act + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config without description field"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null when not present in JSON"); + } + + /// + /// Creates a minimal RuntimeConfig with the specified MCP options for testing. + /// + private static RuntimeConfig CreateMinimalConfigWithMcp(McpRuntimeOptions mcpOptions) + { + DataSource dataSource = new( + DatabaseType: DatabaseType.MSSQL, + ConnectionString: "Server=test;Database=test;", + Options: null + ); + + RuntimeOptions runtimeOptions = new( + Rest: null, + GraphQL: null, + Host: null, + Mcp: mcpOptions + ); + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: dataSource, + Runtime: runtimeOptions, + Entities: new RuntimeEntities(new Dictionary()) + ); + } + } +} diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index 3f56b85901..05ea5edff9 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -14,7 +14,9 @@ using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Azure.DataApiBuilder.Service.Tests.UnitTests { @@ -213,6 +215,140 @@ private static string GetExpectedPropertyValue(string envVarName, bool replaceEn } } + /// + /// Test method to validate that environment variable replacement works correctly + /// for the telemetry.application-insights.enabled property when set through config + /// or through environment variables + /// + [TestMethod] + [DataRow(true, DisplayName = "ApplicationInsights.Enabled set to true (literal bool)")] + [DataRow(false, DisplayName = "ApplicationInsights.Enabled set to false (literal bool)")] + public void TestTelemetryApplicationInsightsEnabled(bool expected) + { + TestTelemetryApplicationInsightsEnabledInternal(expected.ToString().ToLower(), expected); + } + + [TestMethod] + [DataRow("true", true, DisplayName = "ApplicationInsights.Enabled from string 'true'")] + [DataRow("false", false, DisplayName = "ApplicationInsights.Enabled from string 'false'")] + [DataRow("1", true, DisplayName = "ApplicationInsights.Enabled from string '1'")] + [DataRow("0", false, DisplayName = "ApplicationInsights.Enabled from string '0'")] + public void TestTelemetryApplicationInsightsEnabledFromString(string configSetting, bool expected) + { + + TestTelemetryApplicationInsightsEnabledInternal($"\"{configSetting}\"", expected); + } + + [TestMethod] + [DataRow("true", true, DisplayName = "ApplicationInsights.Enabled from environment 'true'")] + [DataRow("false", false, DisplayName = "ApplicationInsights.Enabled from environment 'false'")] + [DataRow("1", true, DisplayName = "ApplicationInsights.Enabled from environment '1'")] + [DataRow("0", false, DisplayName = "ApplicationInsights.Enabled from environment '0'")] + public void TestTelemetryApplicationInsightsEnabledFromEnvironment(string configSetting, bool expected) + { + // Arrange + const string envVarName = "APP_INSIGHTS_ENABLED"; + string envVarValue = configSetting; + // Set up the environment variable + Environment.SetEnvironmentVariable(envVarName, envVarValue); + + try + { + TestTelemetryApplicationInsightsEnabledInternal("\"@env('APP_INSIGHTS_ENABLED')\"", expected); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(envVarName, null); + } + + } + public static void TestTelemetryApplicationInsightsEnabledInternal(string configValue, bool expected) + { + const string AppInsightsConnectionString = "InstrumentationKey=test-key"; + + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""telemetry"": { + ""application-insights"": { + ""enabled"": " + configValue + @", + ""connection-string"": """ + AppInsightsConnectionString + @""" + } + } + }, + ""entities"": { } + }"; + + // Act + bool IsParsed = RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig runtimeConfig, + replacementSettings: new DeserializationVariableReplacementSettings( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false)); + + // Assert + Assert.IsTrue(IsParsed); + Assert.AreEqual(AppInsightsConnectionString, runtimeConfig.Runtime.Telemetry.ApplicationInsights.ConnectionString, "Connection string should be preserved"); + Assert.AreEqual(expected, runtimeConfig.Runtime.Telemetry.ApplicationInsights.Enabled, "ApplicationInsights enabled value should match expected value"); + } + + /// + /// + /// + /// Value to set in the config to cause error + /// Error message + [TestMethod] + [DataRow("somenonboolean", "Invalid boolean value: somenonboolean. Specify either true or 1 for true, false or 0 for false", DisplayName = "ApplicationInsights.Enabled invalid value should error")] + public void TestTelemetryApplicationInsightsEnabledShouldError(string configValue, string message) + { + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""telemetry"": { + ""application-insights"": { + ""enabled"": """ + configValue + @""", + ""connection-string"": ""InstrumentationKey=test-key"" + } + } + }, + ""entities"": { } + }"; + + // Arrange + Mock mockLogger = new(); + + // Act + bool isParsed = RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig runtimeConfig, + replacementSettings: new DeserializationVariableReplacementSettings( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false), + logger: mockLogger.Object); + + // Assert + Assert.IsFalse(isParsed); + Assert.IsNull(runtimeConfig); + + Assert.AreEqual(1, mockLogger.Invocations.Count, "Should raise 1 exception"); + Assert.AreEqual(5, mockLogger.Invocations[0].Arguments.Count, "Log should have 4 arguments"); + JsonException ConfigException = mockLogger.Invocations[0].Arguments[3] as JsonException; + Assert.IsInstanceOfType(ConfigException, typeof(JsonException), "Should have raised a Json Exception"); + Assert.AreEqual(message, ConfigException.Message); + } + /// /// Method to validate that comments are skipped in config file (and are ignored during deserialization). ///