diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs index 79ccf39356..7bff5145f7 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; @@ -161,25 +163,46 @@ private void HandleInitialize(JsonElement? id) // Extract the actual id value from the request object? requestId = id.HasValue ? GetIdValue(id.Value) : null; + // 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 + var result = new + { + protocolVersion = _protocolVersion, + capabilities = new + { + tools = new { listChanged = true }, + logging = new { } + }, + serverInfo = new + { + name = "Data API Builder", + version = "1.0.0" + }, + instructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null + }; + var response = new { jsonrpc = "2.0", id = requestId, - result = new - { - protocolVersion = _protocolVersion, - capabilities = new - { - tools = new { listChanged = true }, - logging = new { } - }, - serverInfo = new - { - name = "Data API Builder", - version = "1.0.0" - } - } + result }; string json = JsonSerializer.Serialize(response); diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 073f349a67..4e404c931b 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -926,6 +926,63 @@ 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 TestUpdateDescriptionForMcpSettings(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); + } + + /// + /// Tests that the MCP description can be added to a config that doesn't already have one + /// + [TestMethod] + public void TestAddDescriptionToMcpSettings() + { + // Arrange + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + // Initial config should not have a description + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? config)); + Assert.IsNull(config.Runtime?.Mcp?.Description); + + // Act: Add description + string descriptionValue = "This is a test description for MCP server."; + ConfigureOptions options = new( + runtimeMcpDescription: descriptionValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: Validate the Description is added + 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 60cb12c3f8..cf36e1a91d 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 1d673c11e3..29f8157814 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/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/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs new file mode 100644 index 0000000000..c16bececa4 --- /dev/null +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; +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.AreEqual(description, deserializedConfig.Runtime.Mcp.Description, "Description should match"); + Assert.IsTrue(json.Contains("\"description\""), "JSON should contain description field"); + Assert.IsTrue(json.Contains(description), "JSON should contain description value"); + } + + /// + /// 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 string('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()) + ); + } + } +}