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 e9182927ae..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."); } @@ -2802,6 +2802,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. @@ -6226,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 @@ -6242,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. @@ -6259,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) { @@ -6278,7 +6320,85 @@ 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) + { + (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."); + + // 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(); + } + + /// + /// 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) + { + List eventDataLines = new(); + + static string GetJsonPayload(List dataLines) + { + if (dataLines.Count == 0) + { + 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 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); + } + } + + // Handle case where payload doesn't end with empty line + return GetJsonPayload(eventDataLines); } ///