Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal static class McpServerConfiguration
/// <summary>
/// Configures the MCP server with tool capabilities.
/// </summary>
internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services)
internal static IServiceCollection ConfigureMcpServer(this IServiceCollection services, string? instructions)
{
services.AddMcpServer()
.WithListToolsHandler((RequestContext<ListToolsRequestParams> request, CancellationToken ct) =>
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
142 changes: 131 additions & 11 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand All @@ -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.");
Comment thread
anushakolan marked this conversation as resolved.
}

/// <summary>
/// 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.
Expand Down Expand Up @@ -6226,13 +6264,13 @@ private static async Task<HttpStatusCode> GetGraphQLResponsePostConfigHydration(
return responseCode;
}

/// <summary>
/// Executing MCP POST requests against the engine until a non-503 error is received.
/// </summary>
/// <param name="httpClient">Client used for request execution.</param>
/// <returns>ServiceUnavailable if service is not successfully hydrated with config,
/// else the response code from the MCP request</returns>
public static async Task<HttpStatusCode> GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp)
/// <summary>
/// Executing MCP POST requests against the engine until a non-503 error is received.
/// </summary>
/// <param name="httpClient">Client used for request execution.</param>
/// <param name="mcp">MCP runtime options containing path configuration.</param>
/// <returns>A tuple containing the HTTP status code and response body.</returns>
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
Expand All @@ -6242,6 +6280,8 @@ public static async Task<HttpStatusCode> 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.
Expand All @@ -6259,14 +6299,16 @@ public static async Task<HttpStatusCode> 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)
{
Expand All @@ -6278,7 +6320,85 @@ public static async Task<HttpStatusCode> GetMcpResponse(HttpClient httpClient, M
break;
}

return responseCode;
return (responseCode, responseBody);
}

/// <summary>
/// Executes MCP initialize over HTTP and returns the parsed JSON response.
/// Reuses the core request/retry logic from GetMcpResponse.
/// </summary>
public static async Task<JsonElement> 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();
}

/// <summary>
/// Extracts JSON payload from SSE-formatted text.
/// SSE events can split JSON across multiple data: lines which should be concatenated.
/// </summary>
private static string ExtractJsonFromSsePayload(string ssePayload)
{
List<string> eventDataLines = new();

static string GetJsonPayload(List<string> 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'))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide an example of an expected ssePayload?

why would there be multiple data: in the payload?
As of now, I see this being the format. If this is accurate expectation, can you please extend the description to provide this e.g.?
data: { }\r\ndata: { }\r\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);
}
Comment thread
anushakolan marked this conversation as resolved.

/// <summary>
Expand Down