From bffdefe5e1b0decb4edae920c0aff11a2ae9be00 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Thu, 9 Apr 2026 13:24:08 -0700 Subject: [PATCH 1/2] Added changes to set McpServerOptions.Instructions in HTTPS/SSE mode. --- .../Core/McpServerConfiguration.cs | 3 +- .../Core/McpServiceCollectionExtensions.cs | 4 +- .../Configuration/ConfigurationTests.cs | 115 ++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index 2b48c37a83..387ea7427f 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -19,7 +19,7 @@ internal static class McpServerConfiguration /// /// Configures the MCP server with tool capabilities. /// - internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services) + internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services, string? instructions) { services.AddMcpServer() .WithListToolsHandler((RequestContext request, CancellationToken ct) => @@ -93,6 +93,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se options.ServerInfo = new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION }; options.Capabilities ??= new(); options.Capabilities.Tools ??= new(); + options.ServerInstructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null; }); return services; diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index bc87602da9..c88cae148d 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -41,8 +41,8 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Register custom tools from configuration RegisterCustomTools(services, runtimeConfig); - // Configure MCP server - services.ConfigureMcpServer(); + // Configure MCP server and propagate runtime description to MCP initialize instructions. + services.ConfigureMcpServer(runtimeConfig.Runtime?.Mcp?.Description); return services; } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index a8925f3ad6..3deb8d5cb7 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2804,6 +2804,44 @@ public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEn } } + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task TestMcpInitializeIncludesInstructionsFromRuntimeDescription() + { + const string MCP_INSTRUCTIONS = "Use SQL tools to query the database."; + const string CUSTOM_CONFIG = "custom-config-mcp-instructions.json"; + + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: true, Description: MCP_INSTRUCTIONS); + + SqlConnectionStringBuilder connectionStringBuilder = new(GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)) + { + TrustServerCertificate = true + }; + + DataSource dataSource = new(DatabaseType.MSSQL, + connectionStringBuilder.ConnectionString, Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + JsonElement initializeResponse = await GetMcpInitializeResponse(client, configuration.Runtime.Mcp); + JsonElement result = initializeResponse.GetProperty("result"); + + Assert.AreEqual(MCP_INSTRUCTIONS, result.GetProperty("instructions").GetString(), "MCP initialize response should include instructions from runtime.mcp.description."); + } + /// /// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response. /// In this test, Anonymous role is configured with only create permission. @@ -6284,6 +6322,83 @@ public static async Task GetMcpResponse(HttpClient httpClient, M return responseCode; } + /// + /// Executes MCP initialize over HTTP and returns the parsed JSON response. + /// + public static async Task GetMcpInitializeResponse(HttpClient httpClient, McpRuntimeOptions mcp) + { + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + string responseBody = string.Empty; + + while (retryCount < RETRY_COUNT) + { + object payload = new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "dab-test", version = "1.0.0" } + } + }; + + HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) + { + Content = JsonContent.Create(payload) + }; + mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); + + HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + responseCode = mcpResponse.StatusCode; + responseBody = await mcpResponse.Content.ReadAsStringAsync(); + + if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } + + break; + } + + Assert.AreEqual(HttpStatusCode.OK, responseCode, "MCP initialize should return HTTP 200."); + Assert.IsFalse(string.IsNullOrWhiteSpace(responseBody), "MCP initialize response body should not be empty."); + + // Depending on transport/content negotiation, initialize can return plain JSON + // or SSE-formatted text where JSON payload is carried in a data: line. + string payloadToParse = responseBody.TrimStart().StartsWith('{') + ? responseBody + : ExtractJsonFromSsePayload(responseBody); + + Assert.IsFalse(string.IsNullOrWhiteSpace(payloadToParse), "MCP initialize response did not contain a JSON payload."); + + using JsonDocument responseDocument = JsonDocument.Parse(payloadToParse); + return responseDocument.RootElement.Clone(); + } + + private static string ExtractJsonFromSsePayload(string ssePayload) + { + foreach (string line in ssePayload.Split('\n')) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + string data = trimmed.Substring("data:".Length).Trim(); + if (!string.IsNullOrWhiteSpace(data) && data.StartsWith('{')) + { + return data; + } + } + } + + return string.Empty; + } + /// /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. /// From c41cbabf7566d8dcc9cc3d1f286cbe1469d131de Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Thu, 16 Apr 2026 18:58:46 -0700 Subject: [PATCH 2/2] Addressed comments. --- .../Configuration/ConfigurationTests.cs | 117 +++++++++--------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index ec171d39a3..b6607391b7 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2775,7 +2775,7 @@ public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEn Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result."); // MCP request - HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + (HttpStatusCode mcpResponseCode, _) = await GetMcpResponse(client, configuration.Runtime.Mcp); Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result."); } @@ -6264,13 +6264,13 @@ private static async Task GetGraphQLResponsePostConfigHydration( return responseCode; } - /// - /// Executing MCP POST requests against the engine until a non-503 error is received. - /// - /// Client used for request execution. - /// ServiceUnavailable if service is not successfully hydrated with config, - /// else the response code from the MCP request - public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) + /// + /// Executing MCP POST requests against the engine until a non-503 error is received. + /// + /// Client used for request execution. + /// MCP runtime options containing path configuration. + /// A tuple containing the HTTP status code and response body. + public static async Task<(HttpStatusCode StatusCode, string ResponseBody)> GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) { // Retry request RETRY_COUNT times in exponential increments to allow // required services time to instantiate and hydrate permissions because @@ -6280,6 +6280,8 @@ public static async Task GetMcpResponse(HttpClient httpClient, M // but it is highly unlikely to be the case. int retryCount = 0; HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + string responseBody = string.Empty; + while (retryCount < RETRY_COUNT) { // Minimal MCP request (initialize) - valid JSON-RPC request. @@ -6297,14 +6299,16 @@ public static async Task GetMcpResponse(HttpClient httpClient, M clientInfo = new { name = "dab-test", version = "1.0.0" } } }; - HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) + + using HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) { Content = JsonContent.Create(payload) }; mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); - HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + using HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); responseCode = mcpResponse.StatusCode; + responseBody = await mcpResponse.Content.ReadAsStringAsync(); if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) { @@ -6316,52 +6320,16 @@ public static async Task GetMcpResponse(HttpClient httpClient, M break; } - return responseCode; + return (responseCode, responseBody); } /// /// Executes MCP initialize over HTTP and returns the parsed JSON response. + /// Reuses the core request/retry logic from GetMcpResponse. /// public static async Task GetMcpInitializeResponse(HttpClient httpClient, McpRuntimeOptions mcp) { - int retryCount = 0; - HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - string responseBody = string.Empty; - - while (retryCount < RETRY_COUNT) - { - object payload = new - { - jsonrpc = "2.0", - id = 1, - method = "initialize", - @params = new - { - protocolVersion = "2025-03-26", - capabilities = new { }, - clientInfo = new { name = "dab-test", version = "1.0.0" } - } - }; - - HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) - { - Content = JsonContent.Create(payload) - }; - mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); - - HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); - responseCode = mcpResponse.StatusCode; - responseBody = await mcpResponse.Content.ReadAsStringAsync(); - - if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) - { - retryCount++; - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); - continue; - } - - break; - } + (HttpStatusCode responseCode, string responseBody) = await GetMcpResponse(httpClient, mcp); Assert.AreEqual(HttpStatusCode.OK, responseCode, "MCP initialize should return HTTP 200."); Assert.IsFalse(string.IsNullOrWhiteSpace(responseBody), "MCP initialize response body should not be empty."); @@ -6378,22 +6346,59 @@ public static async Task GetMcpInitializeResponse(HttpClient httpCl return responseDocument.RootElement.Clone(); } + /// + /// Extracts JSON payload from SSE-formatted text. + /// SSE events can split JSON across multiple data: lines which should be concatenated. + /// private static string ExtractJsonFromSsePayload(string ssePayload) { - foreach (string line in ssePayload.Split('\n')) + List eventDataLines = new(); + + static string GetJsonPayload(List dataLines) { - string trimmed = line.Trim(); - if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + if (dataLines.Count == 0) { - string data = trimmed.Substring("data:".Length).Trim(); - if (!string.IsNullOrWhiteSpace(data) && data.StartsWith('{')) + return string.Empty; + } + + string combinedPayload = string.Join("\n", dataLines); + return !string.IsNullOrWhiteSpace(combinedPayload) && combinedPayload.TrimStart().StartsWith('{') + ? combinedPayload + : string.Empty; + } + + foreach (string rawLine in ssePayload.Split('\n')) + { + string line = rawLine.TrimEnd('\r'); + + // Empty line signals end of an SSE event + if (string.IsNullOrWhiteSpace(line)) + { + string jsonPayload = GetJsonPayload(eventDataLines); + if (!string.IsNullOrEmpty(jsonPayload)) { - return data; + return jsonPayload; } + + eventDataLines.Clear(); + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + string data = line.Substring("data:".Length); + // SSE spec: if data starts with a space, strip one leading space + if (data.StartsWith(' ')) + { + data = data.Substring(1); + } + + eventDataLines.Add(data); } } - return string.Empty; + // Handle case where payload doesn't end with empty line + return GetJsonPayload(eventDataLines); } ///