diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 52780bef2d..9d874478da 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -82,6 +82,7 @@
+
diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs
new file mode 100644
index 0000000000..9956243955
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs
@@ -0,0 +1,15 @@
+namespace ServiceControl.AcceptanceTesting.Mcp;
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true)]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(McpListToolsResponse))]
+[JsonSerializable(typeof(McpCallToolResponse))]
+[JsonSerializable(typeof(McpInitializeResponse))]
+public partial class McpAcceptanceJsonContext : JsonSerializerContext;
diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs
new file mode 100644
index 0000000000..e21b18ca8d
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs
@@ -0,0 +1,194 @@
+namespace ServiceControl.AcceptanceTesting.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+public static class McpAcceptanceTestSupport
+{
+ const string RequestedProtocolVersion = "2025-11-25";
+
+ public static async Task InitializeMcpSession(HttpClient httpClient)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = JsonContent.Create(new
+ {
+ jsonrpc = "2.0",
+ id = 1,
+ method = "initialize",
+ @params = new
+ {
+ protocolVersion = RequestedProtocolVersion,
+ capabilities = new { },
+ clientInfo = new { name = "test-client", version = "1.0" }
+ }
+ })
+ };
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
+ request.Headers.Add("MCP-Protocol-Version", RequestedProtocolVersion);
+ return await httpClient.SendAsync(request);
+ }
+
+ public static async Task InitializeAndGetSessionInfo(HttpClient httpClient)
+ {
+ var response = await InitializeMcpSession(httpClient);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ var initializeResponse = JsonSerializer.Deserialize(await ReadMcpResponseJson(response), McpAcceptanceJsonContext.Default.McpInitializeResponse)!;
+ var protocolVersion = initializeResponse.Result.ProtocolVersion;
+
+ if (!response.Headers.TryGetValues("mcp-session-id", out var values))
+ {
+ return null;
+ }
+
+ var sessionId = values.FirstOrDefault();
+ if (sessionId == null)
+ {
+ return null;
+ }
+
+ var initializedResponse = await SendInitializedNotification(httpClient, sessionId, protocolVersion);
+ if (!initializedResponse.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ return new McpSessionInfo(sessionId, protocolVersion);
+ }
+
+ public static async Task SendMcpRequest(HttpClient httpClient, McpSessionInfo sessionInfo, string method, object @params)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = JsonContent.Create(new
+ {
+ jsonrpc = "2.0",
+ id = 2,
+ method,
+ @params
+ })
+ };
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
+ request.Headers.Add("mcp-session-id", sessionInfo.SessionId);
+ request.Headers.Add("MCP-Protocol-Version", sessionInfo.ProtocolVersion);
+ return await httpClient.SendAsync(request);
+ }
+
+ public static async Task ReadMcpResponseJson(HttpResponseMessage response)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ var contentType = response.Content.Headers.ContentType?.MediaType;
+
+ if (contentType == "text/event-stream")
+ {
+ foreach (var line in body.Split('\n'))
+ {
+ if (line.StartsWith("data: "))
+ {
+ return line.Substring("data: ".Length);
+ }
+ }
+ }
+
+ return body;
+ }
+
+ public static McpListToolsResponse DeserializeListToolsResponse(string toolsJson) =>
+ JsonSerializer.Deserialize(toolsJson, McpAcceptanceJsonContext.Default.McpListToolsResponse)!;
+
+ public static McpCallToolResponse DeserializeCallToolResponse(string toolResult) =>
+ JsonSerializer.Deserialize(toolResult, McpAcceptanceJsonContext.Default.McpCallToolResponse)!;
+
+ public static string FormatToolsForApproval(List sortedTools) =>
+ JsonSerializer.Serialize(sortedTools, McpAcceptanceJsonContext.Default.ListJsonElement);
+
+ public static void AssertToolsHaveOutputSchema(IEnumerable tools)
+ {
+ foreach (var tool in tools)
+ {
+ Assert.That(tool.TryGetProperty("outputSchema", out var outputSchema), Is.True, $"Tool '{tool.GetProperty("name").GetString()}' should expose outputSchema.");
+ Assert.That(outputSchema.ValueKind, Is.EqualTo(JsonValueKind.Object), $"Tool '{tool.GetProperty("name").GetString()}' should expose object outputSchema.");
+ }
+ }
+
+ public static void AssertStructuredToolResponse(string rawResponse, JsonElement structuredContent, IReadOnlyList content, Action assertStructuredContent)
+ {
+ Assert.That(structuredContent.ValueKind, Is.EqualTo(JsonValueKind.Object), rawResponse);
+ assertStructuredContent(structuredContent);
+
+ Assert.That(content, Has.Count.GreaterThanOrEqualTo(1), rawResponse);
+ Assert.That(content[0].Type, Is.EqualTo("text"), rawResponse);
+ Assert.That(content[0].Text, Is.Not.Null.And.Not.Empty, rawResponse);
+
+ using var textPayload = JsonDocument.Parse(content[0].Text);
+ Assert.That(JsonElement.DeepEquals(structuredContent, textPayload.RootElement), Is.True, $"text content should serialize the structured payload. Raw response: {rawResponse}");
+ }
+
+ static async Task SendInitializedNotification(HttpClient httpClient, string sessionId, string protocolVersion)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
+ {
+ Content = JsonContent.Create(new
+ {
+ jsonrpc = "2.0",
+ method = "notifications/initialized"
+ })
+ };
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
+ request.Headers.Add("mcp-session-id", sessionId);
+ request.Headers.Add("MCP-Protocol-Version", protocolVersion);
+ return await httpClient.SendAsync(request);
+ }
+}
+
+public record McpSessionInfo(string SessionId, string ProtocolVersion);
+
+public class McpListToolsResponse
+{
+ public McpListToolsResult Result { get; set; }
+}
+
+public class McpListToolsResult
+{
+ public List
-
\ No newline at end of file
+
diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt
new file mode 100644
index 0000000000..c86d7ae23f
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt
@@ -0,0 +1,1678 @@
+[
+ {
+ "name": "archive_failed_message",
+ "description": "Use this tool to dismiss a single failed message that does not need to be retried. This operation changes system state. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "failedMessageId": {
+ "description": "The failed message ID from a previous failed-message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "failedMessageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "archive_failed_messages",
+ "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. This operation changes system state. It may affect many messages. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "messageIds": {
+ "description": "The failed message IDs from previous failed-message query results.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "messageIds"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "archive_failure_group",
+ "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. This operation changes system state. It may affect many messages. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a failure group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "The failure group ID from previous GetFailureGroups results.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "groupId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_errors_summary",
+ "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "unresolved": {
+ "type": "integer"
+ },
+ "archived": {
+ "type": "integer"
+ },
+ "resolved": {
+ "type": "integer"
+ },
+ "retryIssued": {
+ "type": "integer"
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_failed_message_by_id",
+ "description": "Get detailed information about a specific failed message. Use this when you already know the failed message ID and need to inspect its contents or failure details. Use GetFailedMessages or GetFailureGroups to locate relevant messages before calling this tool. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "failedMessageId": {
+ "description": "The failed message ID from a previous failed-message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "failedMessageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "string"
+ },
+ "processingAttempts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "messageMetadata": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "failureDetails": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "addressOfFailingEndpoint": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "timeOfFailure": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "exception": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "exceptionType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stackTrace": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "attemptedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "messageId": {
+ "type": "string"
+ },
+ "body": {
+ "type": "string"
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "failureGroups": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "uniqueMessageId": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Unresolved",
+ "Resolved",
+ "RetryIssued",
+ "Archived"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_failed_message_last_attempt",
+ "description": "Retrieve the last processing attempt for a failed message. Use this to understand the most recent failure behavior, including exception details and processing context. Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "failedMessageId": {
+ "description": "The failed message ID from a previous failed-message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "failedMessageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "string"
+ },
+ "messageType": {
+ "type": "string"
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "exception": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "exceptionType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stackTrace": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "messageId": {
+ "type": "string"
+ },
+ "numberOfProcessingAttempts": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Unresolved",
+ "Resolved",
+ "RetryIssued",
+ "Archived"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "queueAddress": {
+ "type": "string"
+ },
+ "timeOfFailure": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "lastModified": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "edited": {
+ "type": "boolean"
+ },
+ "editOf": {
+ "type": "string"
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_failed_messages",
+ "description": "Retrieve failed messages for investigation. Use this when exploring recent failures or narrowing down failures by queue, status, or time range. Prefer GetFailureGroups when starting root-cause analysis across many failures. Use GetFailedMessageById when inspecting a specific failed message. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "description": "Filter failed messages by status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit this filter to include all statuses.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "modified": {
+ "description": "Restricts failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "queueAddress": {
+ "description": "Filter failed messages to a specific queue address, for example \u0027Sales@machine\u0027. Omit this filter to include all queues.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, message_type, or time_of_failure",
+ "type": "string",
+ "default": "time_of_failure"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ }
+ }
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "exception": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "exceptionType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stackTrace": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "numberOfProcessingAttempts": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Unresolved",
+ "Resolved",
+ "RetryIssued",
+ "Archived"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "queueAddress": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "timeOfFailure": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "lastModified": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "edited": {
+ "type": "boolean"
+ },
+ "editOf": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_failed_messages_by_endpoint",
+ "description": "Retrieve failed messages for a specific endpoint. Use this when investigating failures in a named endpoint such as Billing or Sales. Prefer GetFailureGroups when you need root-cause analysis across many failures. Use GetFailedMessageLastAttempt after this when you need the most recent failure details for a specific message. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The endpoint name that owns the failed messages. Use values obtained from endpoint-aware failed-message results.",
+ "type": "string"
+ },
+ "status": {
+ "description": "Filter failed messages by status: unresolved, resolved, archived, or retryissued. Omit this filter to include all statuses for the endpoint.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "modified": {
+ "description": "Restricts endpoint failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, message_type, or time_of_failure",
+ "type": "string",
+ "default": "time_of_failure"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "exception": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "exceptionType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stackTrace": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "numberOfProcessingAttempts": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Unresolved",
+ "Resolved",
+ "RetryIssued",
+ "Archived"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "queueAddress": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "timeOfFailure": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "lastModified": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "edited": {
+ "type": "boolean"
+ },
+ "editOf": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_failure_groups",
+ "description": "Retrieve failure groups, where failed messages are grouped by exception type and stack trace. Use this as the first step when analyzing large numbers of failures to identify dominant root causes. Prefer GetFailedMessages when you need individual message details. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "classifier": {
+ "description": "How to group failures. The default \u0027Exception Type and Stack Trace\u0027 is almost always what you want. Use \u0027Message Type\u0027 to group by the NServiceBus message type instead.",
+ "type": "string",
+ "default": "Exception Type and Stack Trace"
+ },
+ "classifierFilter": {
+ "description": "Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ }
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "count": {
+ "type": "integer"
+ },
+ "operationMessagesCompletedCount": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "comment": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "first": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "last": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "operationStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "operationFailed": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ },
+ "operationProgress": {
+ "type": "number"
+ },
+ "operationRemainingCount": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "operationStartTime": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "operationCompletionTime": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "needUserAcknowledgement": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_retry_history",
+ "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "historicOperations": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "requestId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "retryType": {
+ "type": "string",
+ "enum": [
+ "Unknown",
+ "SingleMessage",
+ "FailureGroup",
+ "MultipleMessages",
+ "AllForEndpoint",
+ "All",
+ "ByQueueAddress"
+ ]
+ },
+ "startTime": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "completionTime": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "originator": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "failed": {
+ "type": "boolean"
+ },
+ "numberOfMessagesProcessed": {
+ "type": "integer"
+ }
+ }
+ }
+ },
+ "unacknowledgedOperations": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "requestId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "retryType": {
+ "type": "string",
+ "enum": [
+ "Unknown",
+ "SingleMessage",
+ "FailureGroup",
+ "MultipleMessages",
+ "AllForEndpoint",
+ "All",
+ "ByQueueAddress"
+ ]
+ },
+ "startTime": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "completionTime": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "last": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "originator": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "classifier": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "failed": {
+ "type": "boolean"
+ },
+ "numberOfMessagesProcessed": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_all_failed_messages",
+ "description": "Retry all currently failed messages across all queues. Use only when the user explicitly requests a broad retry operation. Prefer narrower retry tools such as RetryFailureGroup or RetryFailedMessages when possible. This operation changes system state. It may affect many messages. It affects all unresolved failed messages across the instance and may affect a large number of messages.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_all_failed_messages_by_endpoint",
+ "description": "Retry all failed messages for a specific endpoint. Use this when the user explicitly wants an endpoint-scoped retry after an endpoint-specific issue is fixed. Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. This operation changes system state. It may affect many messages. Use the endpoint name from failed-message results.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The endpoint name whose failed messages should be retried. Use values obtained from failed-message results.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_failed_message",
+ "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. This operation changes system state. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "failedMessageId": {
+ "description": "The failed message ID from a previous failed-message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "failedMessageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_failed_messages",
+ "description": "Retry a selected set of failed messages by their IDs. Use this when the user explicitly wants to retry specific known messages. Prefer RetryFailureGroup when retrying all messages with the same root cause. This operation changes system state. It may affect many messages. Use values obtained from failed-message investigation tools.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "messageIds": {
+ "description": "The failed message IDs from previous failed-message query results.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "messageIds"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_failed_messages_by_queue",
+ "description": "Retry all unresolved failed messages from a specific queue. Use this when the user explicitly wants a queue-scoped retry after a queue or consumer issue is fixed. Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. This operation changes system state. It may affect many messages. Use the queue address from failed-message results.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "queueAddress": {
+ "description": "Queue address whose unresolved failed messages should be retried. Use values obtained from failed-message results.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "queueAddress"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "retry_failure_group",
+ "description": "Retry all failed messages in a failure group that share the same root cause. Use this when multiple failures are caused by the same issue and can be retried together. Prefer RetryFailedMessages for more granular control. This operation changes system state. It may affect many messages. Use the failure group ID from GetFailureGroups. Returns InProgress if a retry is already running for this group.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "The failure group ID from previous GetFailureGroups results.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "groupId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "unarchive_failed_message",
+ "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. This operation changes system state. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "failedMessageId": {
+ "description": "The failed message ID to restore from the archived state.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "failedMessageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "unarchive_failed_messages",
+ "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. This operation changes system state. It may affect many messages. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "messageIds": {
+ "description": "The failed message IDs to restore from the archived state.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "messageIds"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "unarchive_failure_group",
+ "description": "Use this tool to restore an entire archived failure group back to the unresolved list. This operation changes system state. It may affect many messages. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a failure group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "The failure group ID from previous GetFailureGroups results.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "groupId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "Accepted",
+ "InProgress",
+ "ValidationError"
+ ]
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": false,
+ "openWorldHint": false,
+ "readOnlyHint": false
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ }
+]
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj
index f8033e858f..1923947295 100644
--- a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj
+++ b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs
new file mode 100644
index 0000000000..11f42abba2
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs
@@ -0,0 +1,128 @@
+namespace ServiceControl.AcceptanceTests.Mcp;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using NServiceBus.AcceptanceTesting;
+using NUnit.Framework;
+using Particular.Approvals;
+using ServiceControl.AcceptanceTesting.Mcp;
+
+[TestFixture]
+class When_mcp_server_is_enabled : AcceptanceTest
+{
+ [SetUp]
+ public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true;
+
+ [Test]
+ public async Task Should_expose_mcp_endpoint()
+ {
+ await Define()
+ .Done(async _ =>
+ {
+ var response = await InitializeMcpSession();
+ return response.StatusCode == HttpStatusCode.OK;
+ })
+ .Run();
+ }
+
+ [Test]
+ public async Task Should_list_primary_instance_tools()
+ {
+ string toolsJson = null;
+
+ await Define()
+ .Done(async _ =>
+ {
+ var session = await InitializeAndGetSessionInfo();
+ if (session == null)
+ {
+ return false;
+ }
+
+ var response = await SendMcpRequest(session, "tools/list", new { });
+ if (response == null)
+ {
+ return false;
+ }
+
+ toolsJson = await ReadMcpResponseJson(response);
+ return response.StatusCode == HttpStatusCode.OK;
+ })
+ .Run();
+
+ Assert.That(toolsJson, Is.Not.Null);
+ var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(toolsJson);
+ var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList();
+ AssertPrimaryTools(sortedTools);
+ McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools);
+ var formattedTools = McpAcceptanceTestSupport.FormatToolsForApproval(sortedTools);
+ Approver.Verify(formattedTools);
+ }
+
+ [Test]
+ public async Task Should_call_get_errors_summary_tool()
+ {
+ string toolResult = null;
+
+ await Define()
+ .Done(async _ =>
+ {
+ var session = await InitializeAndGetSessionInfo();
+ if (session == null)
+ {
+ return false;
+ }
+
+ var response = await SendMcpRequest(session, "tools/call", new
+ {
+ name = "get_errors_summary",
+ arguments = new { }
+ });
+
+ if (response == null || response.StatusCode != HttpStatusCode.OK)
+ {
+ return false;
+ }
+
+ toolResult = await ReadMcpResponseJson(response);
+ return true;
+ })
+ .Run();
+
+ Assert.That(toolResult, Is.Not.Null);
+ var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult);
+ McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent =>
+ {
+ Assert.That(structuredContent.GetProperty("unresolved").GetInt32(), Is.GreaterThanOrEqualTo(0));
+ Assert.That(structuredContent.GetProperty("archived").GetInt32(), Is.GreaterThanOrEqualTo(0));
+ Assert.That(structuredContent.GetProperty("resolved").GetInt32(), Is.GreaterThanOrEqualTo(0));
+ Assert.That(structuredContent.GetProperty("retryIssued").GetInt32(), Is.GreaterThanOrEqualTo(0));
+ });
+ }
+
+ static void AssertPrimaryTools(IReadOnlyCollection tools)
+ {
+ Assert.That(tools, Has.Count.EqualTo(19));
+
+ var names = tools.Select(tool => tool.GetProperty("name").GetString()).ToArray();
+
+ Assert.That(names, Does.Contain("get_errors_summary"));
+ Assert.That(names, Does.Contain("get_failed_messages"));
+ Assert.That(names, Does.Contain("get_failure_groups"));
+ Assert.That(names, Does.Contain("retry_failed_messages"));
+ Assert.That(names, Does.Contain("archive_failed_messages"));
+ }
+
+ Task InitializeMcpSession() => McpAcceptanceTestSupport.InitializeMcpSession(HttpClient);
+
+ Task InitializeAndGetSessionInfo() => McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient);
+
+ Task SendMcpRequest(McpSessionInfo sessionInfo, string method, object @params) => McpAcceptanceTestSupport.SendMcpRequest(HttpClient, sessionInfo, method, @params);
+
+ static Task ReadMcpResponseJson(HttpResponseMessage response) => McpAcceptanceTestSupport.ReadMcpResponseJson(response);
+}
diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index 657a84244d..b6b3b8048a 100644
--- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -125,7 +125,7 @@ async Task InitializeServiceControl(ScenarioContext context)
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControl(settings, configuration);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
- hostBuilder.AddServiceControlApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlApi(settings);
hostBuilder.AddServiceControlTesting(settings);
@@ -135,7 +135,7 @@ async Task InitializeServiceControl(ScenarioContext context)
host.UseTestRemoteIp();
host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
- host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
await host.StartAsync();
DomainEvents = host.Services.GetRequiredService();
// Bring this back and look into the base address of the client
diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt
new file mode 100644
index 0000000000..727a791909
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt
@@ -0,0 +1,1333 @@
+[
+ {
+ "name": "get_audit_message_body",
+ "description": "Retrieve the body content of a specific audit message. Use this when you need to inspect message payload or data for debugging. Typically used after locating a message via search or browsing tools. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "messageId": {
+ "description": "The audit message ID from a previous audit message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "messageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "contentType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "contentLength": {
+ "type": "integer"
+ },
+ "body": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages",
+ "description": "Retrieve audit messages with paging and sorting. Use this to browse recent message activity or explore message flow over time. Prefer SearchAuditMessages when looking for specific keywords or content. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "includeSystemMessages": {
+ "description": "Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.",
+ "type": "boolean",
+ "default": false
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ }
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages_by_conversation",
+ "description": "Retrieve all audit messages belonging to a conversation. Use this to trace the full flow of a message or business process across multiple endpoints. Prefer this tool when you already have a conversation ID. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "conversationId": {
+ "description": "The conversation ID from a previous audit message query result.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ }
+ },
+ "required": [
+ "conversationId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages_by_endpoint",
+ "description": "Retrieve audit messages processed by a specific endpoint. Use this to understand activity and behavior of a single endpoint. Prefer GetAuditMessagesByConversation when tracing a specific message flow. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.",
+ "type": "string"
+ },
+ "keyword": {
+ "description": "Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "includeSystemMessages": {
+ "description": "Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.",
+ "type": "boolean",
+ "default": false
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_endpoint_audit_counts",
+ "description": "Retrieve daily audit-message counts for a specific endpoint. Use this when checking throughput or activity trends for one endpoint. Prefer GetKnownEndpoints when you do not already know the endpoint name. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "utcDate": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "count": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_known_endpoints",
+ "description": "List all known endpoints that have sent or received audit messages. Use this as a starting point to discover available endpoints before exploring their activity. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "endpointDetails": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "hostDisplayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "search_audit_messages",
+ "description": "Search audit messages by keyword across message content and metadata. Use this when trying to locate messages related to a specific business identifier or text. Prefer GetAuditMessages for general browsing or timeline exploration. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "description": "The free-text search query to match against audit message body content, headers, and metadata.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ }
+]
\ No newline at end of file
diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj
index 223e117c20..b47764e74c 100644
--- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj
+++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt
new file mode 100644
index 0000000000..727a791909
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt
@@ -0,0 +1,1333 @@
+[
+ {
+ "name": "get_audit_message_body",
+ "description": "Retrieve the body content of a specific audit message. Use this when you need to inspect message payload or data for debugging. Typically used after locating a message via search or browsing tools. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "messageId": {
+ "description": "The audit message ID from a previous audit message query result.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "messageId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "contentType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "contentLength": {
+ "type": "integer"
+ },
+ "body": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "error": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages",
+ "description": "Retrieve audit messages with paging and sorting. Use this to browse recent message activity or explore message flow over time. Prefer SearchAuditMessages when looking for specific keywords or content. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "includeSystemMessages": {
+ "description": "Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.",
+ "type": "boolean",
+ "default": false
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ }
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages_by_conversation",
+ "description": "Retrieve all audit messages belonging to a conversation. Use this to trace the full flow of a message or business process across multiple endpoints. Prefer this tool when you already have a conversation ID. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "conversationId": {
+ "description": "The conversation ID from a previous audit message query result.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ }
+ },
+ "required": [
+ "conversationId"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_audit_messages_by_endpoint",
+ "description": "Retrieve audit messages processed by a specific endpoint. Use this to understand activity and behavior of a single endpoint. Prefer GetAuditMessagesByConversation when tracing a specific message flow. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.",
+ "type": "string"
+ },
+ "keyword": {
+ "description": "Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "includeSystemMessages": {
+ "description": "Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.",
+ "type": "boolean",
+ "default": false
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_endpoint_audit_counts",
+ "description": "Retrieve daily audit-message counts for a specific endpoint. Use this when checking throughput or activity trends for one endpoint. Prefer GetKnownEndpoints when you do not already know the endpoint name. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "endpointName": {
+ "description": "The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "endpointName"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "utcDate": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "count": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "get_known_endpoints",
+ "description": "List all known endpoints that have sent or received audit messages. Use this as a starting point to discover available endpoints before exploring their activity. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "endpointDetails": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "hostDisplayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ },
+ {
+ "name": "search_audit_messages",
+ "description": "Search audit messages by keyword across message content and metadata. Use this when trying to locate messages related to a specific business identifier or text. Prefer GetAuditMessages for general browsing or timeline exploration. Read-only.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "description": "The free-text search query to match against audit message body content, headers, and metadata.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number, 1-based",
+ "type": "integer",
+ "default": 1
+ },
+ "perPage": {
+ "description": "Results per page",
+ "type": "integer",
+ "default": 50
+ },
+ "sort": {
+ "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time",
+ "type": "string",
+ "default": "time_sent"
+ },
+ "direction": {
+ "description": "Sort direction: asc or desc",
+ "type": "string",
+ "default": "desc"
+ },
+ "timeSentFrom": {
+ "description": "Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ },
+ "timeSentTo": {
+ "description": "Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "totalCount": {
+ "type": "integer"
+ },
+ "results": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "messageType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sendingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "receivingEndpoint": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "hostId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "host": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "timeSent": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "processedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "criticalTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "processingTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "deliveryTime": {
+ "$comment": "Represents a System.TimeSpan value.",
+ "type": "string",
+ "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
+ },
+ "isSystemMessage": {
+ "type": "boolean"
+ },
+ "conversationId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "headers": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "value"
+ ]
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "Successful",
+ "ResolvedSuccessfully"
+ ]
+ },
+ "messageIntent": {
+ "type": "string",
+ "enum": [
+ "Send",
+ "Publish",
+ "Subscribe",
+ "Unsubscribe",
+ "Reply"
+ ]
+ },
+ "bodyUrl": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "bodySize": {
+ "type": "integer"
+ },
+ "invokedSagas": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ }
+ },
+ "originatesFromSaga": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "changeStatus": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaType": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sagaId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ },
+ "instanceId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": false,
+ "readOnlyHint": true
+ },
+ "execution": {
+ "taskSupport": "optional"
+ }
+ }
+]
\ No newline at end of file
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs
new file mode 100644
index 0000000000..31a8efcc04
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs
@@ -0,0 +1,177 @@
+namespace ServiceControl.Audit.AcceptanceTests.Mcp;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.EndpointTemplates;
+using Audit.Auditing.MessagesView;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.AcceptanceTesting.Customization;
+using NServiceBus.Settings;
+using NUnit.Framework;
+using Particular.Approvals;
+using ServiceControl.AcceptanceTesting.Mcp;
+
+class When_mcp_server_is_enabled : AcceptanceTest
+{
+ [SetUp]
+ public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true;
+
+ [Test]
+ public async Task Should_expose_mcp_endpoint()
+ {
+ await Define()
+ .Done(async _ =>
+ {
+ var response = await InitializeMcpSession();
+ return response.StatusCode == HttpStatusCode.OK;
+ })
+ .Run();
+ }
+
+ [Test]
+ public async Task Should_list_audit_message_tools()
+ {
+ string toolsJson = null;
+
+ await Define()
+ .Done(async _ =>
+ {
+ var session = await InitializeAndGetSessionInfo();
+ if (session == null)
+ {
+ return false;
+ }
+
+ var response = await SendMcpRequest(session, "tools/list", new { });
+ if (response == null)
+ {
+ return false;
+ }
+
+ toolsJson = await ReadMcpResponseJson(response);
+ return response.StatusCode == HttpStatusCode.OK;
+ })
+ .Run();
+
+ Assert.That(toolsJson, Is.Not.Null);
+ var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(toolsJson);
+ var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList();
+ AssertAuditTools(sortedTools);
+ McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools);
+ var formattedTools = McpAcceptanceTestSupport.FormatToolsForApproval(sortedTools);
+ Approver.Verify(formattedTools);
+ }
+
+ [Test]
+ public async Task Should_call_get_audit_messages_tool()
+ {
+ string toolResult = null;
+
+ var context = await Define()
+ .WithEndpoint(b => b.When((bus, c) => bus.Send(new MyMessage())))
+ .WithEndpoint()
+ .Done(async c =>
+ {
+ if (c.MessageId == null)
+ {
+ return false;
+ }
+
+ // Wait for the message to be ingested
+ if (!await this.TryGetMany("/api/messages?include_system_messages=false&sort=id", m => m.MessageId == c.MessageId))
+ {
+ return false;
+ }
+
+ var session = await InitializeAndGetSessionInfo();
+ if (session == null)
+ {
+ return false;
+ }
+
+ var response = await SendMcpRequest(session, "tools/call", new
+ {
+ name = "get_audit_messages",
+ arguments = new { includeSystemMessages = false, page = 1, perPage = 50 }
+ });
+
+ if (response == null || response.StatusCode != HttpStatusCode.OK)
+ {
+ return false;
+ }
+
+ toolResult = await ReadMcpResponseJson(response);
+ return true;
+ })
+ .Run();
+
+ Assert.That(toolResult, Is.Not.Null);
+ var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult);
+ McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent =>
+ {
+ Assert.That(structuredContent.GetProperty("totalCount").GetInt32(), Is.GreaterThanOrEqualTo(1));
+ Assert.That(structuredContent.GetProperty("results").ValueKind, Is.EqualTo(JsonValueKind.Array));
+ Assert.That(structuredContent.GetProperty("results").GetArrayLength(), Is.GreaterThanOrEqualTo(1));
+ });
+ }
+
+ static void AssertAuditTools(IReadOnlyCollection tools)
+ {
+ Assert.That(tools, Has.Count.EqualTo(7));
+
+ var names = tools.Select(tool => tool.GetProperty("name").GetString()).ToArray();
+
+ Assert.That(names, Does.Contain("get_audit_messages"));
+ Assert.That(names, Does.Contain("search_audit_messages"));
+ Assert.That(names, Does.Contain("get_audit_message_body"));
+ Assert.That(names, Does.Contain("get_known_endpoints"));
+ Assert.That(names, Does.Contain("get_endpoint_audit_counts"));
+ }
+
+ Task InitializeMcpSession() => McpAcceptanceTestSupport.InitializeMcpSession(HttpClient);
+
+ Task InitializeAndGetSessionInfo() => McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient);
+
+ Task SendMcpRequest(McpSessionInfo sessionInfo, string method, object @params) => McpAcceptanceTestSupport.SendMcpRequest(HttpClient, sessionInfo, method, @params);
+
+ static Task ReadMcpResponseJson(HttpResponseMessage response) => McpAcceptanceTestSupport.ReadMcpResponseJson(response);
+
+ public class Sender : EndpointConfigurationBuilder
+ {
+ public Sender() =>
+ EndpointSetup(c =>
+ {
+ var routing = c.ConfigureRouting();
+ routing.RouteToEndpoint(typeof(MyMessage), typeof(Receiver));
+ });
+ }
+
+ public class Receiver : EndpointConfigurationBuilder
+ {
+ public Receiver() => EndpointSetup();
+
+ public class MyMessageHandler(MyContext testContext, IReadOnlySettings settings) : IHandleMessages
+ {
+ public Task Handle(MyMessage message, IMessageHandlerContext context)
+ {
+ testContext.EndpointNameOfReceivingEndpoint = settings.EndpointName();
+ testContext.MessageId = context.MessageId;
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ public class MyMessage : ICommand;
+
+ public class MyContext : ScenarioContext
+ {
+ public string MessageId { get; set; }
+ public string EndpointNameOfReceivingEndpoint { get; set; }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj
index 2bbf59bc45..96b63d8264 100644
--- a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj
+++ b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj
@@ -20,6 +20,7 @@
+
\ No newline at end of file
diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index efcd99c0f6..c197a2e54a 100644
--- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -133,7 +133,7 @@ async Task InitializeServiceControl(ScenarioContext context)
return criticalErrorContext.Stop(cancellationToken);
}, settings, configuration);
- hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlAuditApi(settings);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAuditTesting(settings);
@@ -144,7 +144,7 @@ async Task InitializeServiceControl(ScenarioContext context)
host.UseTestRemoteIp();
host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
- host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
await host.StartAsync();
ServiceProvider = host.Services;
InstanceTestServer = host.GetTestServer();
diff --git a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs
index 3632772f1f..6dd3d7f231 100644
--- a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs
+++ b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs
@@ -66,10 +66,14 @@ public async Task>> QueryMessages(string keyword
}
public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken)
+ => await QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ public async Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken)
{
var messages = GetMessageIdsMatchingQuery(keyword);
var matched = messageViews.Where(w => w.ReceivingEndpoint.Name == endpoint && messages.Contains(w.MessageId) &&
+ (!w.IsSystemMessage || includeSystemMessages) &&
(timeSentRange == null || !timeSentRange.From.HasValue || w.TimeSent >= timeSentRange.From.Value) &&
(timeSentRange == null || !timeSentRange.To.HasValue || w.TimeSent <= timeSentRange.To.Value))
.ToList();
@@ -79,6 +83,7 @@ public async Task>> QueryMessagesByReceivingEndp
public async Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken)
{
var matched = messageViews.Where(w => w.ReceivingEndpoint.Name == endpointName &&
+ (!w.IsSystemMessage || includeSystemMessages) &&
(timeSentRange == null || !timeSentRange.From.HasValue || w.TimeSent >= timeSentRange.From.Value) &&
(timeSentRange == null || !timeSentRange.To.HasValue || w.TimeSent <= timeSentRange.To.Value))
.ToList();
@@ -270,4 +275,4 @@ object TryGet(Dictionary metadata, string key)
List processedMessages;
List sagaHistories;
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
index 8a465fb029..a84b0e008e 100644
--- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
+++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs
@@ -62,11 +62,15 @@ public async Task>> QueryMessages(string searchP
}
public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken)
+ => await QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ public async Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken)
{
using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken);
var results = await session.Query(GetIndexName(isFullTextSearchEnabled))
.Statistics(out var stats)
.Search(x => x.Query, keyword)
+ .IncludeSystemMessagesWhere(includeSystemMessages)
.Where(m => m.ReceivingEndpointName == endpoint)
.FilterBySentTimeRange(timeSentRange)
.Sort(sortInfo)
@@ -198,4 +202,4 @@ public async Task>> QueryAuditCounts(string endpoi
bool isFullTextSearchEnabled = databaseConfiguration.EnableFullTextSearch;
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
index 52f360ae46..0f5d3e8018 100644
--- a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
+++ b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
@@ -8,6 +8,7 @@
using NServiceBus;
using NUnit.Framework;
using ServiceControl.Audit.Infrastructure;
+ using ServiceControl.Audit.Monitoring;
[TestFixture]
class AuditTests : PersistenceTestFixture
@@ -77,6 +78,23 @@ await IngestProcessedMessagesAudits(
Assert.That(queryResult.Results, Has.Count.EqualTo(2));
}
+
+ [Test]
+ public async Task QueryMessagesByReceivingEndpoint_excludes_system_messages_unless_requested()
+ {
+ await IngestProcessedMessagesAudits(
+ MakeMessage(messageId: "business", processingEndpoint: "Sales", isSystemMessage: false),
+ MakeMessage(messageId: "system", processingEndpoint: "Sales", isSystemMessage: true)
+ );
+
+ var excluded = await DataStore.QueryMessagesByReceivingEndpoint(false, "Sales", new PagingInfo(), new SortInfo("message_id", "asc"), cancellationToken: TestContext.CurrentContext.CancellationToken);
+ var included = await DataStore.QueryMessagesByReceivingEndpoint(true, "Sales", new PagingInfo(), new SortInfo("message_id", "asc"), cancellationToken: TestContext.CurrentContext.CancellationToken);
+
+ Assert.That(excluded.Results, Has.Count.EqualTo(1));
+ Assert.That(excluded.Results[0].MessageId, Is.EqualTo("business"));
+ Assert.That(included.Results, Has.Count.EqualTo(2));
+ }
+
[Test]
public async Task Can_roundtrip_message_body()
{
@@ -234,7 +252,8 @@ ProcessedMessage MakeMessage(
string conversationId = null,
string processingEndpoint = null,
DateTime? processingStarted = null,
- string messageType = null
+ string messageType = null,
+ bool isSystemMessage = false
)
{
messageId ??= Guid.NewGuid().ToString();
@@ -249,10 +268,11 @@ ProcessedMessage MakeMessage(
{ "CriticalTime", TimeSpan.FromSeconds(5) },
{ "ProcessingTime", TimeSpan.FromSeconds(1) },
{ "DeliveryTime", TimeSpan.FromSeconds(4) },
- { "IsSystemMessage", false },
+ { "IsSystemMessage", isSystemMessage },
{ "MessageType", messageType },
{ "IsRetried", false },
{ "ConversationId", conversationId },
+ { "ReceivingEndpoint", new EndpointDetails { Name = processingEndpoint } },
//{ "ContentLength", 10}
};
@@ -283,4 +303,4 @@ async Task IngestProcessedMessagesAudits(params ProcessedMessage[] processedMess
const int MAX_BODY_SIZE = 20536;
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs b/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs
index bc7258ffc3..4dbffa341d 100644
--- a/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs
+++ b/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs
@@ -16,10 +16,11 @@ public interface IAuditDataStore
Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken);
Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default);
Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default);
+ Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default);
Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default);
Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default);
Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken);
Task GetMessageBody(string messageId, CancellationToken cancellationToken);
Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 83897faeba..fa983cf7d6 100644
--- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -58,5 +58,6 @@
"ServiceControlQueueAddress": "Particular.ServiceControl",
"TimeToRestartAuditIngestionAfterFailure": "00:01:00",
"EnableFullTextSearchOnBodies": true,
+ "EnableMcpServer": false,
"ShutdownTimeout": "00:00:05"
}
\ No newline at end of file
diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs
new file mode 100644
index 0000000000..ee5c35317f
--- /dev/null
+++ b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs
@@ -0,0 +1,216 @@
+#nullable enable
+
+namespace ServiceControl.Audit.UnitTests.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Audit.Auditing;
+using Audit.Auditing.MessagesView;
+using Audit.Infrastructure;
+using Audit.Mcp;
+using Audit.Monitoring;
+using Audit.Persistence;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.SagaAudit;
+
+[TestFixture]
+class AuditMessageMcpToolsTests
+{
+ StubAuditDataStore store = null!;
+ AuditMessageTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ store = new StubAuditDataStore();
+ tools = new AuditMessageTools(store, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task GetAuditMessages_returns_messages()
+ {
+ store.MessagesResult = new QueryResult>(
+ [new() { MessageId = "msg-1", MessageType = "MyNamespace.MyMessage" }],
+ new QueryStatsInfo("etag", 1));
+
+ var result = await tools.GetAuditMessages();
+
+ Assert.That(result, Is.TypeOf>());
+ Assert.That(result.TotalCount, Is.EqualTo(1));
+ Assert.That(result.Results, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task GetAuditMessages_passes_paging_and_sort_parameters()
+ {
+ await tools.GetAuditMessages(page: 2, perPage: 25, sort: "processed_at", direction: "asc");
+
+ Assert.That(store.LastGetMessagesArgs, Is.Not.Null);
+ Assert.That(store.LastGetMessagesArgs!.Value.PagingInfo.Page, Is.EqualTo(2));
+ Assert.That(store.LastGetMessagesArgs!.Value.PagingInfo.PageSize, Is.EqualTo(25));
+ Assert.That(store.LastGetMessagesArgs!.Value.SortInfo.Sort, Is.EqualTo("processed_at"));
+ Assert.That(store.LastGetMessagesArgs!.Value.SortInfo.Direction, Is.EqualTo("asc"));
+ }
+
+ [Test]
+ public async Task SearchAuditMessages_passes_query()
+ {
+ await tools.SearchAuditMessages("OrderPlaced");
+
+ Assert.That(store.LastQueryMessagesSearchParam, Is.EqualTo("OrderPlaced"));
+ }
+
+ [Test]
+ public async Task GetAuditMessagesByEndpoint_queries_by_endpoint()
+ {
+ await tools.GetAuditMessagesByEndpoint("Sales");
+
+ Assert.That(store.LastQueryByEndpointName, Is.EqualTo("Sales"));
+ Assert.That(store.LastQueryByEndpointKeyword, Is.Null);
+ }
+
+ [Test]
+ public async Task GetAuditMessagesByEndpoint_with_keyword_uses_keyword_query()
+ {
+ await tools.GetAuditMessagesByEndpoint("Sales", keyword: "OrderPlaced");
+
+ Assert.That(store.LastQueryByEndpointAndKeywordEndpoint, Is.EqualTo("Sales"));
+ Assert.That(store.LastQueryByEndpointAndKeywordKeyword, Is.EqualTo("OrderPlaced"));
+ }
+
+ [Test]
+ public async Task GetAuditMessagesByEndpoint_with_keyword_passes_includeSystemMessages()
+ {
+ await tools.GetAuditMessagesByEndpoint("Sales", keyword: "OrderPlaced", includeSystemMessages: true);
+
+ Assert.That(store.LastQueryByEndpointAndKeywordIncludeSystemMessages, Is.True);
+ }
+
+ [Test]
+ public async Task GetAuditMessagesByConversation_queries_by_conversation_id()
+ {
+ await tools.GetAuditMessagesByConversation("conv-123");
+
+ Assert.That(store.LastConversationId, Is.EqualTo("conv-123"));
+ }
+
+ [Test]
+ public async Task GetAuditMessageBody_returns_body_content()
+ {
+ store.MessageBodyResult = MessageBodyView.FromString("{\"orderId\": 123}", "application/json", 16, "etag");
+
+ var result = await tools.GetAuditMessageBody("msg-1");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.ContentType, Is.EqualTo("application/json"));
+ Assert.That(result.Body, Is.EqualTo("{\"orderId\": 123}"));
+ }
+
+ [Test]
+ public async Task GetAuditMessageBody_returns_error_when_not_found()
+ {
+ store.MessageBodyResult = MessageBodyView.NotFound();
+
+ var result = await tools.GetAuditMessageBody("msg-missing");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Error, Does.Contain("not found"));
+ }
+
+ [Test]
+ public async Task GetAuditMessageBody_returns_error_when_no_content()
+ {
+ store.MessageBodyResult = MessageBodyView.NoContent();
+
+ var result = await tools.GetAuditMessageBody("msg-empty");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Error, Does.Contain("no body content"));
+ }
+
+ [TestCase(nameof(AuditMessageTools.GetAuditMessages))]
+ [TestCase(nameof(AuditMessageTools.SearchAuditMessages))]
+ [TestCase(nameof(AuditMessageTools.GetAuditMessagesByEndpoint))]
+ [TestCase(nameof(AuditMessageTools.GetAuditMessagesByConversation))]
+ [TestCase(nameof(AuditMessageTools.GetAuditMessageBody))]
+ public void Structured_tools_use_structured_content(string methodName)
+ {
+ var method = typeof(AuditMessageTools).GetMethod(methodName)!;
+ var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!;
+
+ Assert.That(attribute.UseStructuredContent, Is.True);
+ }
+
+ class StubAuditDataStore : IAuditDataStore
+ {
+ static readonly QueryResult> EmptyMessagesResult = new([], QueryStatsInfo.Zero);
+ static readonly QueryResult> EmptyEndpointsResult = new([], QueryStatsInfo.Zero);
+ static readonly QueryResult> EmptyAuditCountsResult = new([], QueryStatsInfo.Zero);
+
+ public QueryResult>? MessagesResult { get; set; }
+ public MessageBodyView MessageBodyResult { get; set; } = MessageBodyView.NotFound();
+
+ // Captured arguments
+ public (bool IncludeSystemMessages, PagingInfo PagingInfo, SortInfo SortInfo, DateTimeRange? TimeSentRange)? LastGetMessagesArgs { get; private set; }
+ public string? LastQueryMessagesSearchParam { get; private set; }
+ public string? LastQueryByEndpointName { get; private set; }
+ public string? LastQueryByEndpointKeyword { get; private set; }
+ public string? LastQueryByEndpointAndKeywordEndpoint { get; private set; }
+ public string? LastQueryByEndpointAndKeywordKeyword { get; private set; }
+ public bool? LastQueryByEndpointAndKeywordIncludeSystemMessages { get; private set; }
+ public string? LastConversationId { get; private set; }
+
+ public Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ {
+ LastGetMessagesArgs = (includeSystemMessages, pagingInfo, sortInfo, timeSentRange);
+ return Task.FromResult(MessagesResult ?? EmptyMessagesResult);
+ }
+
+ public Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ {
+ LastQueryMessagesSearchParam = searchParam;
+ return Task.FromResult(MessagesResult ?? EmptyMessagesResult);
+ }
+
+ public Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ {
+ LastQueryByEndpointName = endpointName;
+ LastQueryByEndpointKeyword = null;
+ return Task.FromResult(MessagesResult ?? EmptyMessagesResult);
+ }
+
+ public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ public Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ {
+ LastQueryByEndpointAndKeywordEndpoint = endpoint;
+ LastQueryByEndpointAndKeywordKeyword = keyword;
+ LastQueryByEndpointAndKeywordIncludeSystemMessages = includeSystemMessages;
+ return Task.FromResult(MessagesResult ?? EmptyMessagesResult);
+ }
+
+ public Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken)
+ {
+ LastConversationId = conversationId;
+ return Task.FromResult(MessagesResult ?? EmptyMessagesResult);
+ }
+
+ public Task GetMessageBody(string messageId, CancellationToken cancellationToken)
+ => Task.FromResult(MessageBodyResult);
+
+ public Task>> QueryKnownEndpoints(CancellationToken cancellationToken)
+ => Task.FromResult(EmptyEndpointsResult);
+
+ public Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken)
+ => Task.FromResult(QueryResult.Empty());
+
+ public Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken)
+ => Task.FromResult(EmptyAuditCountsResult);
+ }
+}
diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs
new file mode 100644
index 0000000000..299d3c10ae
--- /dev/null
+++ b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs
@@ -0,0 +1,112 @@
+#nullable enable
+
+namespace ServiceControl.Audit.UnitTests.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Audit.Auditing;
+using Audit.Auditing.MessagesView;
+using Audit.Infrastructure;
+using Audit.Mcp;
+using Audit.Monitoring;
+using Audit.Persistence;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.SagaAudit;
+
+
+[TestFixture]
+class EndpointMcpToolsTests
+{
+ StubAuditDataStore store = null!;
+ EndpointTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ store = new StubAuditDataStore();
+ tools = new EndpointTools(store, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task GetKnownEndpoints_returns_endpoints()
+ {
+ store.KnownEndpointsResult = new QueryResult>(
+ [new() { EndpointDetails = new EndpointDetails { Name = "Sales", Host = "server1" } }],
+ new QueryStatsInfo("etag", 1));
+
+ var result = await tools.GetKnownEndpoints();
+
+ Assert.That(result, Is.TypeOf>());
+ Assert.That(result.TotalCount, Is.EqualTo(1));
+ Assert.That(result.Results, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task GetEndpointAuditCounts_returns_counts()
+ {
+ store.AuditCountsResult = new QueryResult>(
+ [new() { UtcDate = DateTime.UtcNow.Date, Count = 42 }],
+ new QueryStatsInfo("etag", 1));
+
+ var result = await tools.GetEndpointAuditCounts("Sales");
+
+ Assert.That(result, Is.TypeOf>());
+ Assert.That(result.TotalCount, Is.EqualTo(1));
+ Assert.That(store.LastAuditCountsEndpointName, Is.EqualTo("Sales"));
+ }
+
+ [TestCase(nameof(EndpointTools.GetKnownEndpoints))]
+ [TestCase(nameof(EndpointTools.GetEndpointAuditCounts))]
+ public void Structured_tools_use_structured_content(string methodName)
+ {
+ var method = typeof(EndpointTools).GetMethod(methodName)!;
+ var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!;
+
+ Assert.That(attribute.UseStructuredContent, Is.True);
+ }
+
+ class StubAuditDataStore : IAuditDataStore
+ {
+ public QueryResult>? KnownEndpointsResult { get; set; }
+ public QueryResult>? AuditCountsResult { get; set; }
+ public string? LastAuditCountsEndpointName { get; private set; }
+
+ public Task>> QueryKnownEndpoints(CancellationToken cancellationToken)
+ => Task.FromResult(KnownEndpointsResult ?? new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken)
+ {
+ LastAuditCountsEndpointName = endpointName;
+ return Task.FromResult(AuditCountsResult ?? new QueryResult>([], QueryStatsInfo.Zero));
+ }
+
+ public Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken)
+ => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero));
+
+ public Task GetMessageBody(string messageId, CancellationToken cancellationToken)
+ => Task.FromResult(MessageBodyView.NotFound());
+
+ public Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken)
+ => Task.FromResult(QueryResult.Empty());
+ }
+}
diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs
new file mode 100644
index 0000000000..e24b8b4668
--- /dev/null
+++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs
@@ -0,0 +1,80 @@
+#nullable enable
+
+namespace ServiceControl.Audit.UnitTests.Mcp;
+
+using System;
+using System.Linq;
+using System.Reflection;
+using Audit.Mcp;
+using NUnit.Framework;
+using DescriptionAttribute = System.ComponentModel.DescriptionAttribute;
+
+[TestFixture]
+class McpMetadataDescriptionsTests
+{
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody))]
+ [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints))]
+ [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts))]
+ public void Read_only_audit_tools_end_with_read_only_sentence(Type toolType, string methodName)
+ {
+ var description = GetMethodDescription(toolType, methodName);
+
+ Assert.That(description, Does.EndWith("Read-only."));
+ }
+
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody), "messageId", "audit message ID")]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation), "conversationId", "conversation ID")]
+ [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts), "endpointName", "NServiceBus endpoint name")]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint), "endpointName", "endpoint name")]
+ public void Key_audit_tool_parameters_identify_the_entity_type(Type toolType, string methodName, string parameterName, string expectedPhrase)
+ {
+ var description = GetParameterDescription(toolType, methodName, parameterName);
+
+ Assert.That(description, Does.Contain(expectedPhrase));
+ }
+
+ [Test]
+ public void Audit_tools_distinguish_browse_search_trace_and_payload_scenarios()
+ {
+ var browse = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages));
+ var search = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages));
+ var conversation = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation));
+ var endpoint = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint));
+ var body = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody));
+ var knownEndpoints = GetMethodDescription(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(browse, Does.Contain("browse recent message activity").And.Contain("SearchAuditMessages"));
+
+ Assert.That(search, Does.Contain("specific business identifier or text").And.Contain("GetAuditMessages"));
+
+ Assert.That(conversation, Does.Contain("conversation").And.Contain("multiple endpoints"));
+
+ Assert.That(endpoint, Does.Contain("single endpoint").And.Contain("GetAuditMessagesByConversation"));
+
+ Assert.That(body, Does.Contain("message payload").And.Contain("search or browsing tools"));
+
+ Assert.That(knownEndpoints, Does.Contain("starting point").And.Contain("available endpoints"));
+ });
+ }
+
+ static MethodInfo GetMethod(Type toolType, string methodName)
+ => toolType.GetMethod(methodName)!;
+
+ static string GetMethodDescription(Type toolType, string methodName)
+ => GetMethod(toolType, methodName)
+ .GetCustomAttribute()!
+ .Description;
+
+ static string GetParameterDescription(Type toolType, string methodName, string parameterName)
+ => GetMethod(toolType, methodName)
+ .GetParameters()
+ .Single(p => p.Name == parameterName)
+ .GetCustomAttribute()!
+ .Description;
+}
diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs
new file mode 100644
index 0000000000..99299646a4
--- /dev/null
+++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs
@@ -0,0 +1,37 @@
+#nullable enable
+
+namespace ServiceControl.Audit.UnitTests.Mcp;
+
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+using Audit.Mcp;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+
+[TestFixture]
+class McpStructuredOutputReadinessTests
+{
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation))]
+ [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody))]
+ [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints))]
+ [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts))]
+ public void Migrated_audit_mcp_tools_opt_into_structured_content_and_do_not_return_task_of_string(Type toolType, string methodName)
+ {
+ var method = GetMethod(toolType, methodName);
+ var attribute = method.GetCustomAttribute();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(attribute, Is.Not.Null, $"Expected {toolType.Name}.{methodName} to have an {nameof(McpServerToolAttribute)}.");
+ Assert.That(attribute!.UseStructuredContent, Is.True);
+ Assert.That(method.ReturnType, Is.Not.EqualTo(typeof(Task)));
+ });
+ }
+
+ static MethodInfo GetMethod(Type toolType, string methodName)
+ => toolType.GetMethod(methodName)!;
+}
diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config
index a3f5781c51..8adfc4075d 100644
--- a/src/ServiceControl.Audit/App.config
+++ b/src/ServiceControl.Audit/App.config
@@ -8,6 +8,8 @@ These settings are only here so that we can debug ServiceControl while developin
+
+
diff --git a/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs b/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs
index 004fd541e4..ea5d898744 100644
--- a/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs
+++ b/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs
@@ -8,10 +8,10 @@ static class EventSourceCreator
[SupportedOSPlatform("windows")]
public static void Create()
{
- if (!EventLog.SourceExists(SourceName))
- {
- EventLog.CreateEventSource(SourceName, null);
- }
+ //if (!EventLog.SourceExists(SourceName))
+ //{
+ // EventLog.CreateEventSource(SourceName, null);
+ //}
}
public const string SourceName = "ServiceControl.Audit";
diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
index 22e2fff776..7ddfcf46cd 100644
--- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
@@ -25,10 +25,10 @@ public override async Task Execute(HostArguments args, Settings settings)
//Do nothing. The transports in NSB 8 are designed to handle broker outages. Audit ingestion will be paused when broker is unavailable.
return Task.CompletedTask;
}, settings, endpointConfiguration);
- hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlAuditApi(settings);
var app = hostBuilder.Build();
- app.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ app.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
app.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
await app.RunAsync(settings.RootUrl);
diff --git a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
index 3203bd349e..22ec971d12 100644
--- a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
@@ -54,6 +54,7 @@ public Settings(string transportType = null, string persisterType = null, Loggin
ServiceControlQueueAddress = SettingsReader.Read(SettingsRootNamespace, "ServiceControlQueueAddress");
TimeToRestartAuditIngestionAfterFailure = GetTimeToRestartAuditIngestionAfterFailure();
EnableFullTextSearchOnBodies = SettingsReader.Read(SettingsRootNamespace, "EnableFullTextSearchOnBodies", true);
+ EnableMcpServer = SettingsReader.Read(SettingsRootNamespace, "EnableMcpServer", false);
ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, "ShutdownTimeout", ShutdownTimeout);
AssemblyLoadContextResolver = static assemblyPath => new PluginAssemblyLoadContext(assemblyPath);
@@ -187,6 +188,8 @@ public int MaxBodySizeToStore
public bool EnableFullTextSearchOnBodies { get; set; }
+ public bool EnableMcpServer { get; set; }
+
// The default value is set to the maximum allowed time by the most
// restrictive hosting platform, which is Linux containers. Linux
// containers allow for a maximum of 10 seconds. We set it to 5 to
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
index 638041d4b1..34d45acbb5 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
@@ -4,13 +4,25 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+ using ModelContextProtocol.AspNetCore;
using ServiceControl.Infrastructure;
static class HostApplicationBuilderExtensions
{
- public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder, CorsSettings corsSettings)
+ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder, Settings.Settings settings)
{
- builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
+ if (settings.EnableMcpServer)
+ {
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ builder.Services
+ .AddMcpServer()
+ .WithHttpTransport()
+ .WithToolsFromAssembly();
+ }
+
+ builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(settings.CorsSettings)));
// We're not explicitly adding Gzip here because it's already in the default list of supported compressors
builder.Services.AddResponseCompression();
@@ -27,4 +39,4 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde
controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults());
}
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs
new file mode 100644
index 0000000000..392925f7f5
--- /dev/null
+++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs
@@ -0,0 +1,210 @@
+#nullable enable
+
+namespace ServiceControl.Audit.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Auditing.MessagesView;
+using Infrastructure;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using Persistence;
+using ServiceControl.Infrastructure.Mcp;
+
+[McpServerToolType, Description(
+ "Read-only tools for exploring audit messages.\n\n" +
+ "Agent guidance:\n" +
+ "1. For broad requests like 'show recent messages', start with GetAuditMessages using defaults.\n" +
+ "2. For requests containing a concrete text term, identifier, or phrase, use SearchAuditMessages.\n" +
+ "3. Keep page=1 unless the user asks for more results.\n" +
+ "4. Keep perPage modest, such as 20 to 50, unless the user asks for a larger batch.\n" +
+ "5. Use time filters when the user mentions a date or time window like 'today' or 'last hour'.\n" +
+ "6. Only change sorting when the user explicitly asks for it."
+)]
+public class AuditMessageTools(IAuditDataStore store, ILogger logger)
+{
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve audit messages with paging and sorting. " +
+ "Use this to browse recent message activity or explore message flow over time. " +
+ "Prefer SearchAuditMessages when looking for specific keywords or content. " +
+ "Read-only."
+ )]
+ public async Task> GetAuditMessages(
+ [Description("Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.")] bool includeSystemMessages = false,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc")] string direction = "desc",
+ [Description("Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null,
+ [Description("Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetAuditMessages invoked (page={Page}, includeSystemMessages={IncludeSystem})", page, includeSystemMessages);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+ var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo);
+
+ var results = await store.GetMessages(includeSystemMessages, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ logger.LogInformation("MCP GetAuditMessages returned {Count} results", results.QueryStats.TotalCount);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Search audit messages by keyword across message content and metadata. " +
+ "Use this when trying to locate messages related to a specific business identifier or text. " +
+ "Prefer GetAuditMessages for general browsing or timeline exploration. " +
+ "Read-only."
+ )]
+ public async Task> SearchAuditMessages(
+ [Description("The free-text search query to match against audit message body content, headers, and metadata.")] string query,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc")] string direction = "desc",
+ [Description("Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null,
+ [Description("Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP SearchAuditMessages invoked (query={Query}, page={Page})", query, page);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+ var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo);
+
+ var results = await store.QueryMessages(query, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ logger.LogInformation("MCP SearchAuditMessages returned {Count} results", results.QueryStats.TotalCount);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve audit messages processed by a specific endpoint. " +
+ "Use this to understand activity and behavior of a single endpoint. " +
+ "Prefer GetAuditMessagesByConversation when tracing a specific message flow. " +
+ "Read-only."
+ )]
+ public async Task> GetAuditMessagesByEndpoint(
+ [Description("The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.")] string endpointName,
+ [Description("Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.")] string? keyword = null,
+ [Description("Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.")] bool includeSystemMessages = false,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc")] string direction = "desc",
+ [Description("Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null,
+ [Description("Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetAuditMessagesByEndpoint invoked (endpoint={EndpointName}, keyword={Keyword}, page={Page})", endpointName, keyword, page);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+ var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo);
+
+ var results = keyword != null
+ ? await store.QueryMessagesByReceivingEndpointAndKeyword(includeSystemMessages, endpointName, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken)
+ : await store.QueryMessagesByReceivingEndpoint(includeSystemMessages, endpointName, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ logger.LogInformation("MCP GetAuditMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve all audit messages belonging to a conversation. " +
+ "Use this to trace the full flow of a message or business process across multiple endpoints. " +
+ "Prefer this tool when you already have a conversation ID. " +
+ "Read-only."
+ )]
+ public async Task> GetAuditMessagesByConversation(
+ [Description("The conversation ID from a previous audit message query result.")] string conversationId,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc")] string direction = "desc",
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetAuditMessagesByConversation invoked (conversationId={ConversationId}, page={Page})", conversationId, page);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.QueryMessagesByConversationId(conversationId, pagingInfo, sortInfo, cancellationToken);
+
+ logger.LogInformation("MCP GetAuditMessagesByConversation returned {Count} results", results.QueryStats.TotalCount);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve the body content of a specific audit message. " +
+ "Use this when you need to inspect message payload or data for debugging. " +
+ "Typically used after locating a message via search or browsing tools. " +
+ "Read-only."
+ )]
+ public async Task GetAuditMessageBody(
+ [Description("The audit message ID from a previous audit message query result.")] string messageId,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetAuditMessageBody invoked (messageId={MessageId})", messageId);
+
+ var result = await store.GetMessageBody(messageId, cancellationToken);
+
+ if (!result.Found)
+ {
+ logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' not found", messageId);
+ return new McpAuditMessageBodyResult
+ {
+ Error = $"Message '{messageId}' not found."
+ };
+ }
+
+ if (!result.HasContent)
+ {
+ logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' has no body content", messageId);
+ return new McpAuditMessageBodyResult
+ {
+ Error = $"Message '{messageId}' has no body content."
+ };
+ }
+
+ if (result.StringContent != null)
+ {
+ return new McpAuditMessageBodyResult
+ {
+ ContentType = result.ContentType,
+ ContentLength = result.ContentLength,
+ Body = result.StringContent
+ };
+ }
+
+ return new McpAuditMessageBodyResult
+ {
+ ContentType = result.ContentType,
+ ContentLength = result.ContentLength,
+ Body = "(stream content - not available as text)"
+ };
+ }
+}
diff --git a/src/ServiceControl.Audit/Mcp/EndpointTools.cs b/src/ServiceControl.Audit/Mcp/EndpointTools.cs
new file mode 100644
index 0000000000..50d6d7da60
--- /dev/null
+++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs
@@ -0,0 +1,66 @@
+#nullable enable
+
+namespace ServiceControl.Audit.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Auditing;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using Monitoring;
+using Persistence;
+using ServiceControl.Infrastructure.Mcp;
+
+[McpServerToolType, Description(
+ "Read-only tools for discovering and inspecting NServiceBus endpoints.\n\n" +
+ "Agent guidance:\n" +
+ "1. Use GetKnownEndpoints to discover endpoint names before calling endpoint-specific tools.\n" +
+ "2. Use GetEndpointAuditCounts to spot throughput trends, traffic spikes, or drops in activity."
+)]
+public class EndpointTools(IAuditDataStore store, ILogger logger)
+{
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "List all known endpoints that have sent or received audit messages. " +
+ "Use this as a starting point to discover available endpoints before exploring their activity. " +
+ "Read-only."
+ )]
+ public async Task> GetKnownEndpoints(CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetKnownEndpoints invoked");
+
+ var results = await store.QueryKnownEndpoints(cancellationToken);
+
+ logger.LogInformation("MCP GetKnownEndpoints returned {Count} endpoints", results.QueryStats.TotalCount);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve daily audit-message counts for a specific endpoint. " +
+ "Use this when checking throughput or activity trends for one endpoint. " +
+ "Prefer GetKnownEndpoints when you do not already know the endpoint name. " +
+ "Read-only."
+ )]
+ public async Task> GetEndpointAuditCounts(
+ [Description("The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.")] string endpointName,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("MCP GetEndpointAuditCounts invoked (endpoint={EndpointName})", endpointName);
+
+ var results = await store.QueryAuditCounts(endpointName, cancellationToken);
+
+ logger.LogInformation("MCP GetEndpointAuditCounts returned {Count} entries for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+}
diff --git a/src/ServiceControl.Audit/ServiceControl.Audit.csproj b/src/ServiceControl.Audit/ServiceControl.Audit.csproj
index 1752bf81bd..b7394443c1 100644
--- a/src/ServiceControl.Audit/ServiceControl.Audit.csproj
+++ b/src/ServiceControl.Audit/ServiceControl.Audit.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs
index 76785dd77d..e8edece77f 100644
--- a/src/ServiceControl.Audit/WebApplicationExtensions.cs
+++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs
@@ -8,7 +8,7 @@ namespace ServiceControl.Audit;
public static class WebApplicationExtensions
{
- public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
+ public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, bool enableMcpServer)
{
app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
app.UseServiceControlHttps(httpsSettings);
@@ -17,5 +17,10 @@ public static void UseServiceControlAudit(this WebApplication app, ForwardedHead
app.UseHttpLogging();
app.UseCors();
app.MapControllers();
+
+ if (enableMcpServer)
+ {
+ app.MapMcp("/mcp");
+ }
}
}
\ No newline at end of file
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs
new file mode 100644
index 0000000000..b651381fdd
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs
@@ -0,0 +1,12 @@
+#nullable enable
+
+namespace ServiceControl.Infrastructure.Mcp;
+
+public class McpArchiveOperationResult : McpOperationResult
+{
+ public static McpArchiveOperationResult Accepted(string message) => Accepted(message);
+
+ public static McpArchiveOperationResult InProgress(string message) => InProgress(message);
+
+ public static McpArchiveOperationResult ValidationError(string error) => ValidationError(error);
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs
new file mode 100644
index 0000000000..dca4439762
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs
@@ -0,0 +1,11 @@
+#nullable enable
+
+namespace ServiceControl.Infrastructure.Mcp;
+
+public class McpAuditMessageBodyResult
+{
+ public string? ContentType { get; init; }
+ public int ContentLength { get; init; }
+ public string? Body { get; init; }
+ public string? Error { get; init; }
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs
new file mode 100644
index 0000000000..2bc5aac95b
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs
@@ -0,0 +1,9 @@
+namespace ServiceControl.Infrastructure.Mcp;
+
+using System.Collections.Generic;
+
+public class McpCollectionResult
+{
+ public int TotalCount { get; init; }
+ public IReadOnlyCollection Results { get; init; } = [];
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs
new file mode 100644
index 0000000000..7730515e39
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs
@@ -0,0 +1,6 @@
+namespace ServiceControl.Infrastructure.Mcp;
+
+public class McpErrorResult
+{
+ public string Error { get; init; } = string.Empty;
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs
new file mode 100644
index 0000000000..48dcf4b1fa
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs
@@ -0,0 +1,31 @@
+#nullable enable
+
+namespace ServiceControl.Infrastructure.Mcp;
+
+using System.Collections.Generic;
+
+public class McpErrorsSummaryResult
+{
+ public long Unresolved { get; init; }
+ public long Archived { get; init; }
+ public long Resolved { get; init; }
+ public long RetryIssued { get; init; }
+
+ public static McpErrorsSummaryResult From(IDictionary summary)
+ {
+ return new McpErrorsSummaryResult
+ {
+ Unresolved = GetCount(summary, "unresolved"),
+ Archived = GetCount(summary, "archived"),
+ Resolved = GetCount(summary, "resolved"),
+ RetryIssued = GetCount(summary, "retryissued")
+ };
+ }
+
+ static long GetCount(IDictionary summary, string key)
+ {
+ return summary.TryGetValue(key, out var value)
+ ? System.Convert.ToInt64(value)
+ : 0L;
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs
new file mode 100644
index 0000000000..1f977e1aac
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs
@@ -0,0 +1,38 @@
+#nullable enable
+
+namespace ServiceControl.Infrastructure.Mcp;
+
+using System.Text.Json.Serialization;
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum McpOperationStatus
+{
+ Accepted,
+ InProgress,
+ ValidationError
+}
+
+public class McpOperationResult
+{
+ public McpOperationStatus Status { get; init; }
+ public string? Message { get; init; }
+ public string? Error { get; init; }
+
+ protected static T Accepted(string message) where T : McpOperationResult, new() => new()
+ {
+ Status = McpOperationStatus.Accepted,
+ Message = message
+ };
+
+ protected static T InProgress(string message) where T : McpOperationResult, new() => new()
+ {
+ Status = McpOperationStatus.InProgress,
+ Message = message
+ };
+
+ protected static T ValidationError(string error) where T : McpOperationResult, new() => new()
+ {
+ Status = McpOperationStatus.ValidationError,
+ Error = error
+ };
+}
diff --git a/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs
new file mode 100644
index 0000000000..9d1336bf4e
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs
@@ -0,0 +1,12 @@
+#nullable enable
+
+namespace ServiceControl.Infrastructure.Mcp;
+
+public class McpRetryOperationResult : McpOperationResult
+{
+ public static McpRetryOperationResult Accepted(string message) => Accepted(message);
+
+ public static McpRetryOperationResult InProgress(string message) => InProgress(message);
+
+ public static McpRetryOperationResult ValidationError(string error) => ValidationError(error);
+}
diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
index df59b8fdf9..cca2bc7e8b 100644
--- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
+++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
@@ -285,33 +285,26 @@ SortInfo sortInfo
public async Task> ErrorsSummary()
{
using var session = await sessionProvider.OpenSession();
- var facetResults = await session.Query()
- .AggregateBy(new List
- {
- new Facet
- {
- FieldName = "Name",
- DisplayFieldName = "Endpoints"
- },
- new Facet
- {
- FieldName = "Host",
- DisplayFieldName = "Hosts"
- },
- new Facet
- {
- FieldName = "MessageType",
- DisplayFieldName = "Message types"
- }
- }).ExecuteAsync();
-
- var results = facetResults
- .ToDictionary(
- x => x.Key,
- x => (object)x.Value
- );
- return results;
+ var unresolvedCount = await session.Query()
+ .CountAsync(message => message.Status == FailedMessageStatus.Unresolved);
+
+ var archivedCount = await session.Query()
+ .CountAsync(message => message.Status == FailedMessageStatus.Archived);
+
+ var resolvedCount = await session.Query()
+ .CountAsync(message => message.Status == FailedMessageStatus.Resolved);
+
+ var retryIssuedCount = await session.Query()
+ .CountAsync(message => message.Status == FailedMessageStatus.RetryIssued);
+
+ return new Dictionary
+ {
+ ["unresolved"] = unresolvedCount,
+ ["archived"] = archivedCount,
+ ["resolved"] = resolvedCount,
+ ["retryissued"] = retryIssuedCount
+ };
}
public Task ErrorBy(string failedMessageId) => ErrorByDocumentId(FailedMessageIdGenerator.MakeDocumentId(failedMessageId));
diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs
index 5ef3d88d91..e390ebf915 100644
--- a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs
+++ b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs
@@ -53,6 +53,48 @@ public async Task ErrorGet()
Assert.That(result.Results, Is.Not.Empty);
}
+ [Test]
+ public async Task ErrorsSummary_returns_counts_by_status()
+ {
+ using (var session = DocumentStore.OpenAsyncSession())
+ {
+ await session.StoreAsync(FailedMessageBuilder.Default(m =>
+ {
+ m.Id = "3";
+ m.UniqueMessageId = "c";
+ m.Status = FailedMessageStatus.Archived;
+ }));
+
+ await session.StoreAsync(FailedMessageBuilder.Default(m =>
+ {
+ m.Id = "4";
+ m.UniqueMessageId = "d";
+ m.Status = FailedMessageStatus.Resolved;
+ }));
+
+ await session.StoreAsync(FailedMessageBuilder.Default(m =>
+ {
+ m.Id = "5";
+ m.UniqueMessageId = "e";
+ m.Status = FailedMessageStatus.RetryIssued;
+ }));
+
+ await session.SaveChangesAsync();
+ }
+
+ CompleteDatabaseOperation();
+
+ var result = await store.ErrorsSummary();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result["unresolved"], Is.EqualTo(2));
+ Assert.That(result["archived"], Is.EqualTo(1));
+ Assert.That(result["resolved"], Is.EqualTo(1));
+ Assert.That(result["retryissued"], Is.EqualTo(1));
+ });
+ }
+
[SetUp]
public async Task GetStore()
{
@@ -94,4 +136,4 @@ async Task GenerateAndSaveFailedMessage()
await session.SaveChangesAsync();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 6873e229b3..5de2540e03 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -37,6 +37,7 @@
},
"NotificationsFilter": null,
"AllowMessageEditing": false,
+ "EnableMcpServer": false,
"EnableIntegratedServicePulse": false,
"ServicePulseSettings": null,
"MessageFilter": null,
diff --git a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs
new file mode 100644
index 0000000000..3764dfe24f
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs
@@ -0,0 +1,151 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using NServiceBus.Testing;
+using NUnit.Framework;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.Mcp;
+using ServiceControl.Persistence;
+using ServiceControl.Persistence.Recoverability;
+using ServiceControl.Recoverability;
+
+[TestFixture]
+class ArchiveMcpToolsTests
+{
+ TestableMessageSession messageSession = null!;
+ StubArchiveMessages archiver = null!;
+ ArchiveTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ messageSession = new TestableMessageSession();
+ archiver = new StubArchiveMessages();
+ tools = new ArchiveTools(messageSession, archiver, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task ArchiveFailedMessage_returns_accepted()
+ {
+ var result = await tools.ArchiveFailedMessage("msg-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Archive requested for message 'msg-1'."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task ArchiveFailedMessages_returns_accepted()
+ {
+ var result = await tools.ArchiveFailedMessages(["msg-1", "msg-2"]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Archive requested for 2 messages."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(2));
+ }
+
+ [Test]
+ public async Task ArchiveFailedMessages_rejects_empty_ids()
+ {
+ var result = await tools.ArchiveFailedMessages(["msg-1", ""]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError));
+ Assert.That(result.Message, Is.Null);
+ Assert.That(result.Error, Does.Contain("non-empty"));
+ }
+
+ [Test]
+ public async Task ArchiveFailureGroup_returns_accepted()
+ {
+ var result = await tools.ArchiveFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Archive requested for all messages in failure group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task ArchiveFailureGroup_returns_in_progress_when_already_running()
+ {
+ archiver.OperationInProgress = true;
+
+ var result = await tools.ArchiveFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress));
+ Assert.That(result.Message, Is.EqualTo("An archive operation is already in progress for group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task UnarchiveFailedMessage_returns_accepted()
+ {
+ var result = await tools.UnarchiveFailedMessage("msg-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Unarchive requested for message 'msg-1'."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task UnarchiveFailedMessages_returns_accepted()
+ {
+ var result = await tools.UnarchiveFailedMessages(["msg-1", "msg-2"]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Unarchive requested for 2 messages."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task UnarchiveFailedMessages_rejects_empty_ids()
+ {
+ var result = await tools.UnarchiveFailedMessages(["msg-1", ""]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError));
+ Assert.That(result.Message, Is.Null);
+ Assert.That(result.Error, Does.Contain("non-empty"));
+ }
+
+ [Test]
+ public async Task UnarchiveFailureGroup_returns_accepted()
+ {
+ var result = await tools.UnarchiveFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Unarchive requested for all messages in failure group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task UnarchiveFailureGroup_returns_in_progress_when_already_running()
+ {
+ archiver.OperationInProgress = true;
+
+ var result = await tools.UnarchiveFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress));
+ Assert.That(result.Message, Is.EqualTo("An unarchive operation is already in progress for group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ class StubArchiveMessages : IArchiveMessages
+ {
+ public bool OperationInProgress { get; set; }
+
+ public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => OperationInProgress;
+ public bool IsArchiveInProgressFor(string groupId) => OperationInProgress;
+ public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask;
+ public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask;
+ public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask;
+ public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask;
+ public void DismissArchiveOperation(string groupId, ArchiveType archiveType) { }
+ public IEnumerable GetArchivalOperations() => [];
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs
new file mode 100644
index 0000000000..6142a00be4
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs
@@ -0,0 +1,314 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+using ServiceControl.Contracts.Operations;
+using ServiceControl.CompositeViews.Messages;
+using ServiceControl.EventLog;
+using ServiceControl.Infrastructure;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.MessageFailures;
+using ServiceControl.MessageFailures.Api;
+using ServiceControl.Mcp;
+using ServiceControl.Operations;
+using ServiceControl.Persistence;
+using ServiceControl.Persistence.Infrastructure;
+using ServiceControl.Recoverability;
+
+[TestFixture]
+class FailedMessageMcpToolsTests
+{
+ StubErrorMessageDataStore store = null!;
+ FailedMessageTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ store = new StubErrorMessageDataStore();
+ tools = new FailedMessageTools(store, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task GetFailedMessages_returns_messages()
+ {
+ store.ErrorGetResult = new QueryResult>(
+ [new() { Id = "msg-1", MessageType = "MyNamespace.MyMessage", Status = FailedMessageStatus.Unresolved }],
+ new QueryStatsInfo("etag", 1, false));
+
+ var result = await tools.GetFailedMessages();
+
+ Assert.That(result, Is.TypeOf>());
+
+ Assert.That(result.TotalCount, Is.EqualTo(1));
+ Assert.That(result.Results, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task GetFailedMessages_passes_paging_and_sort_parameters()
+ {
+ await tools.GetFailedMessages(page: 3, perPage: 10, sort: "time_sent", direction: "asc");
+
+ Assert.That(store.LastErrorGetArgs, Is.Not.Null);
+ Assert.That(store.LastErrorGetArgs!.Value.PagingInfo.Page, Is.EqualTo(3));
+ Assert.That(store.LastErrorGetArgs!.Value.PagingInfo.PageSize, Is.EqualTo(10));
+ Assert.That(store.LastErrorGetArgs!.Value.SortInfo.Sort, Is.EqualTo("time_sent"));
+ Assert.That(store.LastErrorGetArgs!.Value.SortInfo.Direction, Is.EqualTo("asc"));
+ }
+
+ [Test]
+ public async Task GetFailedMessages_passes_filter_parameters()
+ {
+ await tools.GetFailedMessages(status: "unresolved", modified: "2026-01-01", queueAddress: "Sales");
+
+ Assert.That(store.LastErrorGetArgs!.Value.Status, Is.EqualTo("unresolved"));
+ Assert.That(store.LastErrorGetArgs!.Value.Modified, Is.EqualTo("2026-01-01"));
+ Assert.That(store.LastErrorGetArgs!.Value.QueueAddress, Is.EqualTo("Sales"));
+ }
+
+ [Test]
+ public async Task GetFailedMessageById_returns_message()
+ {
+ store.ErrorByResult = new FailedMessage
+ {
+ Id = "msg-1",
+ UniqueMessageId = "unique-1",
+ Status = FailedMessageStatus.Unresolved,
+ ProcessingAttempts =
+ [
+ new FailedMessage.ProcessingAttempt
+ {
+ MessageId = "message-1",
+ Body = "body",
+ AttemptedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
+ FailureDetails = new FailureDetails
+ {
+ AddressOfFailingEndpoint = "Sales",
+ Exception = new ExceptionDetails { ExceptionType = "System.Exception", Message = "boom" },
+ TimeOfFailure = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)
+ },
+ Headers = new Dictionary { ["NServiceBus.MessageId"] = "message-1" },
+ MessageMetadata = new Dictionary
+ {
+ ["Retries"] = 3,
+ ["Context"] = new { RetryCount = 3, Note = (string?)null }
+ }
+ }
+ ],
+ FailureGroups =
+ [
+ new FailedMessage.FailureGroup
+ {
+ Id = "group-1",
+ Title = "Unhandled exception",
+ Type = "Exception"
+ }
+ ]
+ };
+
+ var result = await tools.GetFailedMessageById("msg-1");
+
+ Assert.That(result, Is.TypeOf());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.Error, Is.Null);
+ Assert.That(result.UniqueMessageId, Is.EqualTo("unique-1"));
+ Assert.That(result.ProcessingAttempts, Has.Count.EqualTo(1));
+ Assert.That(result.ProcessingAttempts[0].MessageId, Is.EqualTo("message-1"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata, Has.Count.EqualTo(2));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Key, Is.EqualTo("Retries"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Value, Is.EqualTo("3"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Type, Is.EqualTo("integer"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Key, Is.EqualTo("Context"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Value, Is.EqualTo("{\"retryCount\":3}"));
+ Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Type, Is.EqualTo("json"));
+ Assert.That(result.FailureGroups, Has.Count.EqualTo(1));
+ Assert.That(result.FailureGroups[0].Id, Is.EqualTo("group-1"));
+ });
+ }
+
+ [Test]
+ public async Task GetFailedMessageById_returns_error_when_not_found()
+ {
+ store.ErrorByResult = null;
+
+ var result = await tools.GetFailedMessageById("msg-missing");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Error, Does.Contain("not found"));
+ }
+
+ [Test]
+ public async Task GetFailedMessageLastAttempt_returns_view()
+ {
+ store.ErrorLastByResult = new FailedMessageView
+ {
+ Id = "msg-1",
+ MessageType = "MyMessage",
+ Status = FailedMessageStatus.Unresolved
+ };
+
+ var result = await tools.GetFailedMessageLastAttempt("msg-1");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Error, Is.Null);
+ Assert.That(result.MessageType, Is.EqualTo("MyMessage"));
+ }
+
+ [Test]
+ public async Task GetFailedMessageLastAttempt_returns_error_when_not_found()
+ {
+ store.ErrorLastByResult = null;
+
+ var result = await tools.GetFailedMessageLastAttempt("msg-missing");
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Error, Does.Contain("not found"));
+ }
+
+ [Test]
+ public void GetFailedMessageById_returns_top_level_mcp_contract()
+ {
+ var method = typeof(FailedMessageTools).GetMethod(nameof(FailedMessageTools.GetFailedMessageById))!;
+
+ Assert.That(method.ReturnType, Is.EqualTo(typeof(Task)));
+ }
+
+ [Test]
+ public void GetFailedMessageLastAttempt_returns_top_level_mcp_contract()
+ {
+ var method = typeof(FailedMessageTools).GetMethod(nameof(FailedMessageTools.GetFailedMessageLastAttempt))!;
+
+ Assert.That(method.ReturnType, Is.EqualTo(typeof(Task)));
+ }
+
+ [Test]
+ public void Failed_message_detail_contract_uses_mcp_specific_nested_dtos()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(GetGenericListArgument(typeof(McpFailedMessageResult), nameof(McpFailedMessageResult.ProcessingAttempts)), Is.EqualTo(typeof(McpFailedProcessingAttemptResult)));
+ Assert.That(GetGenericListArgument(typeof(McpFailedMessageResult), nameof(McpFailedMessageResult.FailureGroups)), Is.EqualTo(typeof(McpFailedFailureGroupResult)));
+ Assert.That(GetGenericListArgument(typeof(McpFailedProcessingAttemptResult), nameof(McpFailedProcessingAttemptResult.MessageMetadata)), Is.EqualTo(typeof(McpMessageMetadataEntryResult)));
+ });
+ }
+
+ [Test]
+ public async Task GetErrorsSummary_returns_summary()
+ {
+ store.ErrorsSummaryResult = new Dictionary
+ {
+ { "unresolved", 5 },
+ { "archived", 3 }
+ };
+
+ var result = await tools.GetErrorsSummary();
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.Unresolved, Is.EqualTo(5L));
+ Assert.That(result.Archived, Is.EqualTo(3L));
+ Assert.That(result.Resolved, Is.EqualTo(0L));
+ Assert.That(result.RetryIssued, Is.EqualTo(0L));
+ }
+
+ [Test]
+ public async Task GetFailedMessagesByEndpoint_returns_messages()
+ {
+ store.ErrorsByEndpointResult = new QueryResult>(
+ [new() { Id = "msg-1", MessageType = "MyMessage" }],
+ new QueryStatsInfo("etag", 1, false));
+
+ var result = await tools.GetFailedMessagesByEndpoint("Sales");
+
+ Assert.That(result, Is.TypeOf>());
+ Assert.That(result.TotalCount, Is.EqualTo(1));
+ Assert.That(store.LastErrorsByEndpointName, Is.EqualTo("Sales"));
+ }
+
+ [TestCase(nameof(FailedMessageTools.GetFailedMessages))]
+ [TestCase(nameof(FailedMessageTools.GetFailedMessageById))]
+ [TestCase(nameof(FailedMessageTools.GetFailedMessageLastAttempt))]
+ [TestCase(nameof(FailedMessageTools.GetErrorsSummary))]
+ [TestCase(nameof(FailedMessageTools.GetFailedMessagesByEndpoint))]
+ public void Structured_tools_use_structured_content(string methodName)
+ {
+ var method = typeof(FailedMessageTools).GetMethod(methodName)!;
+ var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!;
+
+ Assert.That(attribute.UseStructuredContent, Is.True);
+ }
+
+ class StubErrorMessageDataStore : IErrorMessageDataStore
+ {
+ static readonly QueryResult> EmptyResult = new([], QueryStatsInfo.Zero);
+
+ public QueryResult>? ErrorGetResult { get; set; }
+ public QueryResult>? ErrorsByEndpointResult { get; set; }
+ public FailedMessage? ErrorByResult { get; set; }
+ public FailedMessageView? ErrorLastByResult { get; set; }
+ public IDictionary? ErrorsSummaryResult { get; set; }
+
+ public (string? Status, string? Modified, string? QueueAddress, PagingInfo PagingInfo, SortInfo SortInfo)? LastErrorGetArgs { get; private set; }
+ public string? LastErrorsByEndpointName { get; private set; }
+
+ public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo)
+ {
+ LastErrorGetArgs = (status, modified, queueAddress, pagingInfo, sortInfo);
+ return Task.FromResult(ErrorGetResult ?? EmptyResult);
+ }
+
+ public Task ErrorBy(string failedMessageId) => Task.FromResult(ErrorByResult)!;
+
+ public Task ErrorLastBy(string failedMessageId) => Task.FromResult(ErrorLastByResult)!;
+
+ public Task> ErrorsSummary() => Task.FromResult(ErrorsSummaryResult ?? new Dictionary());
+
+ public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo)
+ {
+ LastErrorsByEndpointName = endpointName;
+ return Task.FromResult(ErrorsByEndpointResult ?? EmptyResult);
+ }
+
+ // Unused interface members
+ public Task ErrorsHead(string status, string modified, string queueAddress) => throw new NotImplementedException();
+ public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException();
+ public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException();
+ public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) => throw new NotImplementedException();
+ public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException();
+ public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException();
+ public Task FailedMessageMarkAsArchived(string failedMessageId) => throw new NotImplementedException();
+ public Task FailedMessagesFetch(Guid[] ids) => throw new NotImplementedException();
+ public Task StoreFailedErrorImport(FailedErrorImport failure) => throw new NotImplementedException();
+ public Task CreateEditFailedMessageManager() => throw new NotImplementedException();
+ public Task> GetFailureGroupView(string groupId, string status, string modified) => throw new NotImplementedException();
+ public Task> GetFailureGroupsByClassifier(string classifier) => throw new NotImplementedException();
+ public Task EditComment(string groupId, string comment) => throw new NotImplementedException();
+ public Task DeleteComment(string groupId) => throw new NotImplementedException();
+ public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) => throw new NotImplementedException();
+ public Task GetGroupErrorsCount(string groupId, string status, string modified) => throw new NotImplementedException();
+ public Task>> GetGroup(string groupId, string status, string modified) => throw new NotImplementedException();
+ public Task MarkMessageAsResolved(string failedMessageId) => throw new NotImplementedException();
+ public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, Func processCallback) => throw new NotImplementedException();
+ public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => throw new NotImplementedException();
+ public Task UnArchiveMessages(IEnumerable failedMessageIds) => throw new NotImplementedException();
+ public Task RevertRetry(string messageUniqueId) => throw new NotImplementedException();
+ public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) => throw new NotImplementedException();
+ public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) => throw new NotImplementedException();
+ public Task FetchFromFailedMessage(string uniqueMessageId) => throw new NotImplementedException();
+ public Task StoreEventLogItem(EventLogItem logItem) => throw new NotImplementedException();
+ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => throw new NotImplementedException();
+ public Task CreateNotificationsManager() => throw new NotImplementedException();
+ }
+
+ static Type GetGenericListArgument(Type declaringType, string propertyName) =>
+ declaringType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public)!.PropertyType.GetGenericArguments().Single();
+}
diff --git a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs
new file mode 100644
index 0000000000..764162376e
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs
@@ -0,0 +1,116 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+using ServiceControl.Mcp;
+using ServiceControl.Persistence;
+using ServiceControl.Persistence.Recoverability;
+using ServiceControl.Recoverability;
+using ServiceControl.UnitTests.Operations;
+
+[TestFixture]
+class FailureGroupMcpToolsTests
+{
+ StubGroupsDataStore groupsStore = null!;
+ StubRetryHistoryDataStore retryStore = null!;
+ FailureGroupTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ groupsStore = new StubGroupsDataStore();
+ retryStore = new StubRetryHistoryDataStore();
+ var domainEvents = new FakeDomainEvents();
+ var retryingManager = new RetryingManager(domainEvents, NullLogger.Instance);
+ var archiver = new StubArchiveMessages();
+ var fetcher = new GroupFetcher(groupsStore, retryStore, retryingManager, archiver);
+ tools = new FailureGroupTools(fetcher, retryStore, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task GetFailureGroups_returns_groups()
+ {
+ groupsStore.FailureGroups =
+ [
+ new FailureGroupView { Id = "group-1", Title = "NullReferenceException", Type = "Exception Type and Stack Trace", Count = 5, First = DateTime.UtcNow.AddHours(-1), Last = DateTime.UtcNow }
+ ];
+
+ var result = await tools.GetFailureGroups();
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result, Has.Length.EqualTo(1));
+ Assert.That(result[0].Id, Is.EqualTo("group-1"));
+ Assert.That(result[0].Count, Is.EqualTo(5));
+ }
+
+ [Test]
+ public async Task GetFailureGroups_passes_classifier()
+ {
+ await tools.GetFailureGroups(classifier: "Message Type");
+
+ Assert.That(groupsStore.LastClassifier, Is.EqualTo("Message Type"));
+ }
+
+ [Test]
+ public async Task GetRetryHistory_returns_history()
+ {
+ retryStore.RetryHistoryResult = RetryHistory.CreateNew();
+
+ var result = await tools.GetRetryHistory();
+
+ Assert.That(result, Is.TypeOf());
+ Assert.That(result.HistoricOperations, Is.Empty);
+ Assert.That(result.UnacknowledgedOperations, Is.Empty);
+ }
+
+ [TestCase(nameof(FailureGroupTools.GetFailureGroups))]
+ [TestCase(nameof(FailureGroupTools.GetRetryHistory))]
+ public void Structured_tools_use_structured_content(string methodName)
+ {
+ var method = typeof(FailureGroupTools).GetMethod(methodName)!;
+ var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!;
+
+ Assert.That(attribute.UseStructuredContent, Is.True);
+ }
+
+ class StubGroupsDataStore : IGroupsDataStore
+ {
+ public IList FailureGroups { get; set; } = [];
+ public string? LastClassifier { get; private set; }
+
+ public Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter)
+ {
+ LastClassifier = classifier;
+ return Task.FromResult(FailureGroups);
+ }
+
+ public Task GetCurrentForwardingBatch() => Task.FromResult(null!);
+ }
+
+ class StubRetryHistoryDataStore : IRetryHistoryDataStore
+ {
+ public RetryHistory? RetryHistoryResult { get; set; }
+
+ public Task GetRetryHistory() => Task.FromResult(RetryHistoryResult ?? RetryHistory.CreateNew());
+ public Task AcknowledgeRetryGroup(string groupId) => Task.FromResult(true);
+ public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime, string originator, string classifier, bool messageFailed, int numberOfMessagesProcessed, DateTime lastProcessed, int retryHistoryDepth) => Task.CompletedTask;
+ }
+
+ class StubArchiveMessages : IArchiveMessages
+ {
+ public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false;
+ public bool IsArchiveInProgressFor(string groupId) => false;
+ public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask;
+ public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask;
+ public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask;
+ public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask;
+ public void DismissArchiveOperation(string groupId, ArchiveType archiveType) { }
+ public IEnumerable GetArchivalOperations() => [];
+ }
+}
diff --git a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs
new file mode 100644
index 0000000000..f2f91b76b4
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs
@@ -0,0 +1,167 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System;
+using System.Linq;
+using System.Reflection;
+using NUnit.Framework;
+using ServiceControl.Mcp;
+using DescriptionAttribute = System.ComponentModel.DescriptionAttribute;
+
+[TestFixture]
+class McpMetadataDescriptionsTests
+{
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetErrorsSummary))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint))]
+ [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups))]
+ [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetRetryHistory))]
+ public void Read_only_primary_tools_end_with_read_only_sentence(Type toolType, string methodName)
+ {
+ var description = GetMethodDescription(toolType, methodName);
+
+ Assert.That(description, Does.EndWith("Read-only."));
+ }
+
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))]
+ public void Mutating_tools_explicitly_warn_that_they_change_system_state(Type toolType, string methodName)
+ {
+ var description = GetMethodDescription(toolType, methodName);
+
+ Assert.That(description, Does.Contain("changes system state"));
+ }
+
+ [Test]
+ public void Retry_all_failed_messages_warns_that_it_affects_all_unresolved_failed_messages()
+ {
+ var description = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages));
+
+ Assert.That(description, Does.Contain("all unresolved failed messages across the instance"));
+ }
+
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))]
+ public void Bulk_mutating_tools_warn_that_they_may_affect_many_messages(Type toolType, string methodName)
+ {
+ var description = GetMethodDescription(toolType, methodName);
+
+ Assert.That(description, Does.Contain("may affect many messages"));
+ }
+
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById), "failedMessageId", "failed message ID")]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt), "failedMessageId", "failed message ID")]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage), "failedMessageId", "failed message ID")]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages), "messageIds", "failed message IDs")]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint), "endpointName", "endpoint name")]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint), "endpointName", "endpoint name")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup), "groupId", "failure group ID")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup), "groupId", "failure group ID")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage), "failedMessageId", "failed message ID")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages), "messageIds", "failed message IDs")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage), "failedMessageId", "failed message ID")]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages), "messageIds", "failed message IDs")]
+ public void Key_error_tool_parameters_identify_the_entity_type(Type toolType, string methodName, string parameterName, string expectedPhrase)
+ {
+ var description = GetParameterDescription(toolType, methodName, parameterName);
+
+ Assert.That(description, Does.Contain(expectedPhrase));
+ }
+
+ [Test]
+ public void Get_failed_messages_guides_agents_toward_groups_first_and_details_second()
+ {
+ var description = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(description, Does.Contain("Retrieve failed messages"));
+ Assert.That(description, Does.Contain("root-cause analysis"));
+ Assert.That(description, Does.Contain("GetFailureGroups"));
+ Assert.That(description, Does.Contain("GetFailedMessageById"));
+ });
+ }
+
+ [Test]
+ public void Get_failure_groups_is_positioned_as_root_cause_starting_point()
+ {
+ var description = GetMethodDescription(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(description, Does.Contain("Retrieve failure groups"));
+ Assert.That(description, Does.Contain("first step"));
+ Assert.That(description, Does.Contain("root cause"));
+ Assert.That(description, Does.Contain("GetFailedMessages"));
+ });
+ }
+
+ [Test]
+ public void Failed_message_detail_tools_reference_the_expected_workflow()
+ {
+ var byId = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById));
+ var lastAttempt = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(byId, Does.Contain("failed message ID"));
+ Assert.That(byId, Does.Contain("GetFailedMessages").Or.Contain("GetFailureGroups"));
+
+ Assert.That(lastAttempt, Does.Contain("last processing attempt").Or.Contain("most recent failure"));
+ Assert.That(lastAttempt, Does.Contain("GetFailedMessages").Or.Contain("GetFailedMessageById"));
+ });
+ }
+
+ [Test]
+ public void Retry_tools_describe_targeted_group_and_broad_scenarios()
+ {
+ var retryByIds = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages));
+ var retryGroup = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup));
+ var retryAll = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(retryByIds, Does.Contain("specific").And.Contain("RetryFailureGroup"));
+
+ Assert.That(retryGroup, Does.Contain("root cause").And.Contain("RetryFailedMessages"));
+
+ Assert.That(retryAll, Does.Contain("explicitly requests").And.Contain("narrower retry tools"));
+ Assert.That(retryAll, Does.Contain("large number of messages"));
+ });
+ }
+
+ static MethodInfo GetMethod(Type toolType, string methodName)
+ => toolType.GetMethod(methodName)!;
+
+ static string GetMethodDescription(Type toolType, string methodName)
+ => GetMethod(toolType, methodName)
+ .GetCustomAttribute()!
+ .Description;
+
+ static string GetParameterDescription(Type toolType, string methodName, string parameterName)
+ => GetMethod(toolType, methodName)
+ .GetParameters()
+ .Single(p => p.Name == parameterName)
+ .GetCustomAttribute()!
+ .Description;
+}
diff --git a/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs b/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs
new file mode 100644
index 0000000000..322d6ea9fc
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs
@@ -0,0 +1,49 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using NUnit.Framework;
+using ServiceControl.Mcp;
+
+[TestFixture]
+class McpStructuredOutputReadinessTests
+{
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetErrorsSummary))]
+ [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint))]
+ [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups))]
+ [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetRetryHistory))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))]
+ [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))]
+ [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))]
+ public void Migrated_primary_mcp_tools_opt_into_structured_content_and_do_not_return_task_of_string(Type toolType, string methodName)
+ {
+ var method = GetMethod(toolType, methodName);
+ var attribute = method.GetCustomAttribute();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(attribute, Is.Not.Null, $"Expected {toolType.Name}.{methodName} to have an {nameof(McpServerToolAttribute)}.");
+ Assert.That(attribute!.UseStructuredContent, Is.True);
+ Assert.That(method.ReturnType, Is.Not.EqualTo(typeof(Task)));
+ });
+ }
+
+ static MethodInfo GetMethod(Type toolType, string methodName)
+ => toolType.GetMethod(methodName)!;
+}
diff --git a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs
new file mode 100644
index 0000000000..ee7dafb28e
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs
@@ -0,0 +1,116 @@
+#nullable enable
+
+namespace ServiceControl.UnitTests.Mcp;
+
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using NServiceBus.Testing;
+using NUnit.Framework;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.Mcp;
+using ServiceControl.Persistence;
+using ServiceControl.Recoverability;
+using ServiceControl.UnitTests.Operations;
+
+[TestFixture]
+class RetryMcpToolsTests
+{
+ TestableMessageSession messageSession = null!;
+ RetryingManager retryingManager = null!;
+ RetryTools tools = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ messageSession = new TestableMessageSession();
+ retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance);
+ tools = new RetryTools(messageSession, retryingManager, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task RetryFailedMessage_returns_accepted()
+ {
+ var result = await tools.RetryFailedMessage("msg-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for message 'msg-1'."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task RetryFailedMessages_returns_accepted()
+ {
+ var result = await tools.RetryFailedMessages(["msg-1", "msg-2"]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for 2 messages."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task RetryFailedMessages_rejects_empty_ids()
+ {
+ var result = await tools.RetryFailedMessages(["msg-1", ""]);
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError));
+ Assert.That(result.Message, Is.Null);
+ Assert.That(result.Error, Does.Contain("non-empty"));
+ }
+
+ [Test]
+ public async Task RetryFailedMessagesByQueue_returns_accepted()
+ {
+ var result = await tools.RetryFailedMessagesByQueue("Sales@machine");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages in queue 'Sales@machine'."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task RetryAllFailedMessages_returns_accepted()
+ {
+ var result = await tools.RetryAllFailedMessages();
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages."));
+ Assert.That(result.Error, Is.Null);
+ Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1));
+ }
+
+ [Test]
+ public async Task RetryAllFailedMessagesByEndpoint_returns_accepted()
+ {
+ var result = await tools.RetryAllFailedMessagesByEndpoint("Sales");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages in endpoint 'Sales'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task RetryFailureGroup_returns_accepted()
+ {
+ var result = await tools.RetryFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted));
+ Assert.That(result.Message, Is.EqualTo("Retry requested for all messages in failure group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+
+ [Test]
+ public async Task RetryFailureGroup_returns_in_progress_when_already_running()
+ {
+ await retryingManager.Wait("group-1", RetryType.FailureGroup, System.DateTime.UtcNow);
+ await retryingManager.Preparing("group-1", RetryType.FailureGroup, 1);
+
+ var result = await tools.RetryFailureGroup("group-1");
+
+ Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress));
+ Assert.That(result.Message, Is.EqualTo("A retry operation is already in progress for group 'group-1'."));
+ Assert.That(result.Error, Is.Null);
+ }
+}
diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config
index d6271805e5..698755c9a7 100644
--- a/src/ServiceControl/App.config
+++ b/src/ServiceControl/App.config
@@ -5,6 +5,8 @@ These settings are only here so that we can debug ServiceControl while developin
-->
+
+
diff --git a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs
index 105f756daf..932e301047 100644
--- a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs
+++ b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs
@@ -26,7 +26,7 @@ public override async Task Execute(HostArguments args, Settings settings)
var hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.AddServiceControl(settings, endpointConfiguration);
- hostBuilder.AddServiceControlApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlApi(settings);
using var app = hostBuilder.Build();
await app.StartAsync();
diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs
index ebc08958cf..9778db2cc0 100644
--- a/src/ServiceControl/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs
@@ -27,10 +27,10 @@ public override async Task Execute(HostArguments args, Settings settings)
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControl(settings, endpointConfiguration);
- hostBuilder.AddServiceControlApi(settings.CorsSettings);
+ hostBuilder.AddServiceControlApi(settings);
var app = hostBuilder.Build();
- app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
if (settings.EnableIntegratedServicePulse)
{
app.UseServicePulse(settings.ServicePulseSettings);
diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs
index d71b9dca66..24e7082863 100644
--- a/src/ServiceControl/Infrastructure/Settings/Settings.cs
+++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs
@@ -81,6 +81,7 @@ public Settings(
DisableExternalIntegrationsPublishing = SettingsReader.Read(SettingsRootNamespace, "DisableExternalIntegrationsPublishing", false);
TrackInstancesInitialValue = SettingsReader.Read(SettingsRootNamespace, "TrackInstancesInitialValue", true);
ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, "ShutdownTimeout", ShutdownTimeout);
+ EnableMcpServer = SettingsReader.Read(SettingsRootNamespace, "EnableMcpServer", false);
AssemblyLoadContextResolver = static assemblyPath => new PluginAssemblyLoadContext(assemblyPath);
}
@@ -113,6 +114,8 @@ public Settings(
public bool AllowMessageEditing { get; set; }
+ public bool EnableMcpServer { get; set; }
+
public bool EnableIntegratedServicePulse { get; set; }
public ServicePulseSettings ServicePulseSettings { get; set; }
diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
index 298885ae0f..557c2daefc 100644
--- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
@@ -9,10 +9,11 @@
using Microsoft.Extensions.Hosting;
using Particular.LicensingComponent.WebApi;
using Particular.ServiceControl;
+ using ServiceBus.Management.Infrastructure.Settings;
static class HostApplicationBuilderExtensions
{
- public static void AddServiceControlApi(this IHostApplicationBuilder builder, CorsSettings corsSettings)
+ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Settings settings)
{
// This registers concrete classes that implement IApi. Currently it is hard to find out to what
// component those APIs should belong to so we leave it here for now.
@@ -20,7 +21,20 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co
builder.AddServiceControlApis();
- builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
+ if (settings.EnableMcpServer)
+ {
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ builder.Services
+ .AddMcpServer()
+ .WithHttpTransport()
+ .WithToolsFromAssembly();
+ }
+
+ builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(settings.CorsSettings)));
// We're not explicitly adding Gzip here because it's already in the default list of supported compressors
builder.Services.AddResponseCompression();
@@ -56,4 +70,4 @@ static void RegisterApiTypes(this IServiceCollection serviceCollection, Assembly
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs
new file mode 100644
index 0000000000..52691b3252
--- /dev/null
+++ b/src/ServiceControl/Mcp/ArchiveTools.cs
@@ -0,0 +1,157 @@
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using MessageFailures.InternalMessages;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using NServiceBus;
+using Persistence.Recoverability;
+using ServiceControl.Recoverability;
+using ServiceControl.Infrastructure.Mcp;
+
+[McpServerToolType, Description(
+ "Tools for archiving and unarchiving failed messages.\n\n" +
+ "Agent guidance:\n" +
+ "1. Every tool in this group changes system state by archiving or restoring failed messages.\n" +
+ "2. Archiving dismisses a failed message — it moves out of the unresolved list and no longer counts as an active problem.\n" +
+ "3. Unarchiving restores a previously archived message back to the unresolved list so it can be retried.\n" +
+ "4. Prefer ArchiveFailureGroup or UnarchiveFailureGroup when acting on an entire failure group — it is more efficient than archiving messages individually.\n" +
+ "5. Use ArchiveFailedMessages or UnarchiveFailedMessages when you have a specific set of message IDs.\n" +
+ "6. All operations are asynchronous — they return Accepted immediately and complete in the background."
+)]
+public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver, ILogger logger)
+{
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to dismiss a single failed message that does not need to be retried. " +
+ "This operation changes system state. " +
+ "Good for questions like: 'archive this message', 'dismiss this failure', or 'I do not need to retry this one'. " +
+ "Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. " +
+ "This is an asynchronous operation — the message will be archived shortly after the request is accepted. " +
+ "If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead."
+ )]
+ public async Task ArchiveFailedMessage(
+ [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId)
+ {
+ logger.LogInformation("MCP ArchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId);
+
+ await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId);
+ return McpArchiveOperationResult.Accepted($"Archive requested for message '{failedMessageId}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to dismiss multiple failed messages at once that do not need to be retried. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Good for questions like: 'archive these messages', 'dismiss these failures', or 'archive messages msg-1, msg-2, msg-3'. " +
+ "Prefer ArchiveFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to archive."
+ )]
+ public async Task ArchiveFailedMessages(
+ [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds)
+ {
+ logger.LogInformation("MCP ArchiveFailedMessages invoked (count={Count})", messageIds.Length);
+
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ logger.LogWarning("MCP ArchiveFailedMessages: rejected due to empty message IDs");
+ return McpArchiveOperationResult.ValidationError("All message IDs must be non-empty strings.");
+ }
+
+ foreach (var id in messageIds)
+ {
+ await messageSession.SendLocal(m => m.FailedMessageId = id);
+ }
+ return McpArchiveOperationResult.Accepted($"Archive requested for {messageIds.Length} messages.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to dismiss an entire failure group — all messages that failed with the same exception type and stack trace. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Good for questions like: 'archive this failure group', 'dismiss all NullReferenceException failures', or 'archive the whole group'. " +
+ "This is the most efficient way to archive many related failures at once. " +
+ "You need a failure group ID, which you can get from GetFailureGroups. " +
+ "Returns InProgress if an archive operation is already running for this group."
+ )]
+ public async Task ArchiveFailureGroup(
+ [Description("The failure group ID from previous GetFailureGroups results.")] string groupId)
+ {
+ logger.LogInformation("MCP ArchiveFailureGroup invoked (groupId={GroupId})", groupId);
+
+ if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup))
+ {
+ logger.LogInformation("MCP ArchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId);
+ return McpArchiveOperationResult.InProgress($"An archive operation is already in progress for group '{groupId}'.");
+ }
+
+ await archiver.StartArchiving(groupId, ArchiveType.FailureGroup);
+ await messageSession.SendLocal(m => m.GroupId = groupId);
+
+ return McpArchiveOperationResult.Accepted($"Archive requested for all messages in failure group '{groupId}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. " +
+ "This operation changes system state. " +
+ "Good for questions like: 'unarchive this message', 'restore this failure', or 'I need to retry this archived message'. " +
+ "Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. " +
+ "If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead."
+ )]
+ public async Task UnarchiveFailedMessage(
+ [Description("The failed message ID to restore from the archived state.")] string failedMessageId)
+ {
+ logger.LogInformation("MCP UnarchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId);
+
+ await messageSession.SendLocal(m => m.FailedMessageIds = [failedMessageId]);
+ return McpArchiveOperationResult.Accepted($"Unarchive requested for message '{failedMessageId}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to restore multiple previously archived failed messages back to the unresolved list. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Good for questions like: 'unarchive these messages', 'restore these failures', or 'unarchive messages msg-1, msg-2, msg-3'. " +
+ "Prefer UnarchiveFailureGroup when restoring an entire group — use this tool when you have a specific set of message IDs."
+ )]
+ public async Task UnarchiveFailedMessages(
+ [Description("The failed message IDs to restore from the archived state.")] string[] messageIds)
+ {
+ logger.LogInformation("MCP UnarchiveFailedMessages invoked (count={Count})", messageIds.Length);
+
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ logger.LogWarning("MCP UnarchiveFailedMessages: rejected due to empty message IDs");
+ return McpArchiveOperationResult.ValidationError("All message IDs must be non-empty strings.");
+ }
+
+ await messageSession.SendLocal(m => m.FailedMessageIds = messageIds);
+ return McpArchiveOperationResult.Accepted($"Unarchive requested for {messageIds.Length} messages.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to restore an entire archived failure group back to the unresolved list. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Good for questions like: 'unarchive this failure group', 'restore all archived NullReferenceException failures', or 'unarchive the whole group'. " +
+ "All messages that were archived together under this group will become available for retry again. " +
+ "You need a failure group ID, which you can get from GetFailureGroups. " +
+ "Returns InProgress if an unarchive operation is already running for this group."
+ )]
+ public async Task UnarchiveFailureGroup(
+ [Description("The failure group ID from previous GetFailureGroups results.")] string groupId)
+ {
+ logger.LogInformation("MCP UnarchiveFailureGroup invoked (groupId={GroupId})", groupId);
+
+ if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup))
+ {
+ logger.LogInformation("MCP UnarchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId);
+ return McpArchiveOperationResult.InProgress($"An unarchive operation is already in progress for group '{groupId}'.");
+ }
+
+ await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup);
+ await messageSession.SendLocal(m => m.GroupId = groupId);
+
+ return McpArchiveOperationResult.Accepted($"Unarchive requested for all messages in failure group '{groupId}'.");
+ }
+}
diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs
new file mode 100644
index 0000000000..4327ef5e07
--- /dev/null
+++ b/src/ServiceControl/Mcp/FailedMessageTools.cs
@@ -0,0 +1,155 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using MessageFailures.Api;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using Persistence;
+using Persistence.Infrastructure;
+using ServiceControl.Infrastructure.Mcp;
+
+[McpServerToolType, Description(
+ "Read-only tools for investigating failed messages.\n\n" +
+ "Agent guidance:\n" +
+ "1. Start with GetErrorsSummary to get a quick health check of failure counts by status.\n" +
+ "2. Use GetFailureGroups (from FailureGroupTools) to see failures grouped by root cause before drilling into individual messages.\n" +
+ "3. Use GetFailedMessages for broad listing, or GetFailedMessagesByEndpoint when you already know the endpoint.\n" +
+ "4. Use GetFailedMessageById for full details including all processing attempts, or GetFailedMessageLastAttempt for just the most recent failure.\n" +
+ "5. Keep page=1 unless the user asks for more results.\n" +
+ "6. Only change sorting when the user explicitly asks for it."
+)]
+public class FailedMessageTools(IErrorMessageDataStore store, ILogger logger)
+{
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve failed messages for investigation. " +
+ "Use this when exploring recent failures or narrowing down failures by queue, status, or time range. " +
+ "Prefer GetFailureGroups when starting root-cause analysis across many failures. " +
+ "Use GetFailedMessageById when inspecting a specific failed message. " +
+ "Read-only."
+ )]
+ public async Task> GetFailedMessages(
+ [Description("Filter failed messages by status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit this filter to include all statuses.")] string? status = null,
+ [Description("Restricts failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null,
+ [Description("Filter failed messages to a specific queue address, for example 'Sales@machine'. Omit this filter to include all queues.")] string? queueAddress = null,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure",
+ [Description("Sort direction: asc or desc")] string direction = "desc")
+ {
+ logger.LogInformation("MCP GetFailedMessages invoked (status={Status}, queueAddress={QueueAddress}, page={Page})", status, queueAddress, page);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.ErrorGet(status, modified, queueAddress, pagingInfo, sortInfo);
+
+ logger.LogInformation("MCP GetFailedMessages returned {Count} results", results.QueryStats.TotalCount);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Get detailed information about a specific failed message. " +
+ "Use this when you already know the failed message ID and need to inspect its contents or failure details. " +
+ "Use GetFailedMessages or GetFailureGroups to locate relevant messages before calling this tool. " +
+ "Read-only."
+ )]
+ public async Task GetFailedMessageById(
+ [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId)
+ {
+ logger.LogInformation("MCP GetFailedMessageById invoked (failedMessageId={FailedMessageId})", failedMessageId);
+
+ var result = await store.ErrorBy(failedMessageId);
+
+ if (result == null)
+ {
+ logger.LogWarning("MCP GetFailedMessageById: message '{FailedMessageId}' not found", failedMessageId);
+ return new McpFailedMessageResult
+ {
+ Error = $"Failed message '{failedMessageId}' not found."
+ };
+ }
+
+ return McpFailedMessageResult.From(result);
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve the last processing attempt for a failed message. " +
+ "Use this to understand the most recent failure behavior, including exception details and processing context. " +
+ "Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. " +
+ "Read-only."
+ )]
+ public async Task GetFailedMessageLastAttempt(
+ [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId)
+ {
+ logger.LogInformation("MCP GetFailedMessageLastAttempt invoked (failedMessageId={FailedMessageId})", failedMessageId);
+
+ var result = await store.ErrorLastBy(failedMessageId);
+
+ if (result == null)
+ {
+ logger.LogWarning("MCP GetFailedMessageLastAttempt: message '{FailedMessageId}' not found", failedMessageId);
+ return new McpFailedMessageViewResult
+ {
+ Error = $"Failed message '{failedMessageId}' not found."
+ };
+ }
+
+ return McpFailedMessageViewResult.From(result);
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool as a quick health check to see how many messages are in each failure state. " +
+ "Good for questions like: 'how many errors are there?', 'what is the error situation?', or 'are there unresolved failures?'. " +
+ "Returns counts for unresolved, archived, resolved, and retryissued statuses. " +
+ "This is a good first tool to call when asked about the overall error situation before drilling into specific messages. " +
+ "Read-only."
+ )]
+ public async Task GetErrorsSummary()
+ {
+ logger.LogInformation("MCP GetErrorsSummary invoked");
+
+ return McpErrorsSummaryResult.From(await store.ErrorsSummary());
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve failed messages for a specific endpoint. " +
+ "Use this when investigating failures in a named endpoint such as Billing or Sales. " +
+ "Prefer GetFailureGroups when you need root-cause analysis across many failures. " +
+ "Use GetFailedMessageLastAttempt after this when you need the most recent failure details for a specific message. " +
+ "Read-only."
+ )]
+ public async Task> GetFailedMessagesByEndpoint(
+ [Description("The endpoint name that owns the failed messages. Use values obtained from endpoint-aware failed-message results.")] string endpointName,
+ [Description("Filter failed messages by status: unresolved, resolved, archived, or retryissued. Omit this filter to include all statuses for the endpoint.")] string? status = null,
+ [Description("Restricts endpoint failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null,
+ [Description("Page number, 1-based")] int page = 1,
+ [Description("Results per page")] int perPage = 50,
+ [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure",
+ [Description("Sort direction: asc or desc")] string direction = "desc")
+ {
+ logger.LogInformation("MCP GetFailedMessagesByEndpoint invoked (endpoint={EndpointName}, status={Status}, page={Page})", endpointName, status, page);
+
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.ErrorsByEndpointName(status, endpointName, modified, pagingInfo, sortInfo);
+
+ logger.LogInformation("MCP GetFailedMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName);
+
+ return new McpCollectionResult
+ {
+ TotalCount = (int)results.QueryStats.TotalCount,
+ Results = results.Results.ToArray()
+ };
+ }
+}
diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs
new file mode 100644
index 0000000000..4ffcd9bb19
--- /dev/null
+++ b/src/ServiceControl/Mcp/FailureGroupTools.cs
@@ -0,0 +1,53 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using Persistence;
+using Recoverability;
+
+[McpServerToolType, Description(
+ "Read-only tools for inspecting failure groups and retry history.\n\n" +
+ "Agent guidance:\n" +
+ "1. GetFailureGroups is usually the best starting point for diagnosing production issues — call it before drilling into individual messages.\n" +
+ "2. Call GetFailureGroups with no parameters to use the default grouping by exception type and stack trace.\n" +
+ "3. Use GetRetryHistory to check whether someone has already retried a group before retrying it again."
+)]
+public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore, ILogger logger)
+{
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retrieve failure groups, where failed messages are grouped by exception type and stack trace. " +
+ "Use this as the first step when analyzing large numbers of failures to identify dominant root causes. " +
+ "Prefer GetFailedMessages when you need individual message details. " +
+ "Read-only."
+ )]
+ public async Task GetFailureGroups(
+ [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace",
+ [Description("Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.")] string? classifierFilter = null)
+ {
+ logger.LogInformation("MCP GetFailureGroups invoked (classifier={Classifier})", classifier);
+
+ var results = await fetcher.GetGroups(classifier, classifierFilter);
+
+ logger.LogInformation("MCP GetFailureGroups returned {Count} groups", results.Length);
+
+ return results;
+ }
+
+ [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to check the history of retry operations. " +
+ "Good for questions like: 'has someone already retried these?', 'what happened the last time we retried this group?', 'show retry history', or 'were any retries attempted today?'. " +
+ "Returns which groups were retried, when, and whether the retries succeeded or failed. " +
+ "Use this before retrying a group to avoid duplicate retry attempts. " +
+ "Read-only."
+ )]
+ public async Task GetRetryHistory()
+ {
+ logger.LogInformation("MCP GetRetryHistory invoked");
+
+ return await retryStore.GetRetryHistory();
+ }
+}
diff --git a/src/ServiceControl/Mcp/McpFailedMessageResult.cs b/src/ServiceControl/Mcp/McpFailedMessageResult.cs
new file mode 100644
index 0000000000..99e308854b
--- /dev/null
+++ b/src/ServiceControl/Mcp/McpFailedMessageResult.cs
@@ -0,0 +1,132 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using ServiceControl.Contracts.Operations;
+using MessageFailures;
+
+public class McpFailedMessageResult
+{
+ public string? Error { get; init; }
+ public string Id { get; init; } = string.Empty;
+ public List ProcessingAttempts { get; init; } = [];
+ public List FailureGroups { get; init; } = [];
+ public string UniqueMessageId { get; init; } = string.Empty;
+ public FailedMessageStatus Status { get; init; }
+
+ public static McpFailedMessageResult From(FailedMessage message)
+ {
+ return new McpFailedMessageResult
+ {
+ Id = message.Id,
+ ProcessingAttempts = message.ProcessingAttempts.Select(McpFailedProcessingAttemptResult.From).ToList(),
+ FailureGroups = message.FailureGroups.Select(McpFailedFailureGroupResult.From).ToList(),
+ UniqueMessageId = message.UniqueMessageId,
+ Status = message.Status
+ };
+ }
+}
+
+public class McpFailedProcessingAttemptResult
+{
+ public List MessageMetadata { get; init; } = [];
+ public FailureDetails? FailureDetails { get; init; }
+ public DateTime AttemptedAt { get; init; }
+ public string MessageId { get; init; } = string.Empty;
+ public string Body { get; init; } = string.Empty;
+ public Dictionary Headers { get; init; } = [];
+
+ public static McpFailedProcessingAttemptResult From(FailedMessage.ProcessingAttempt attempt)
+ {
+ return new McpFailedProcessingAttemptResult
+ {
+ MessageMetadata = attempt.MessageMetadata.Select(entry => McpMessageMetadataEntryResult.From(entry.Key, entry.Value)).ToList(),
+ FailureDetails = attempt.FailureDetails,
+ AttemptedAt = attempt.AttemptedAt,
+ MessageId = attempt.MessageId,
+ Body = attempt.Body,
+ Headers = attempt.Headers
+ };
+ }
+}
+
+public class McpMessageMetadataEntryResult
+{
+ public string Key { get; init; } = string.Empty;
+ public string? Value { get; init; }
+ public string Type { get; init; } = string.Empty;
+
+ public static McpMessageMetadataEntryResult From(string key, object? value)
+ {
+ return new McpMessageMetadataEntryResult
+ {
+ Key = key,
+ Value = FormatValue(value),
+ Type = GetTypeName(value)
+ };
+ }
+
+ static string? FormatValue(object? value)
+ {
+ return value switch
+ {
+ null => null,
+ DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture),
+ DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture),
+ TimeSpan timeSpan => timeSpan.ToString("c", CultureInfo.InvariantCulture),
+ bool boolean => boolean ? "true" : "false",
+ string text => text,
+ sbyte number => number.ToString(CultureInfo.InvariantCulture),
+ byte number => number.ToString(CultureInfo.InvariantCulture),
+ short number => number.ToString(CultureInfo.InvariantCulture),
+ ushort number => number.ToString(CultureInfo.InvariantCulture),
+ int number => number.ToString(CultureInfo.InvariantCulture),
+ uint number => number.ToString(CultureInfo.InvariantCulture),
+ long number => number.ToString(CultureInfo.InvariantCulture),
+ ulong number => number.ToString(CultureInfo.InvariantCulture),
+ float number => number.ToString(CultureInfo.InvariantCulture),
+ double number => number.ToString(CultureInfo.InvariantCulture),
+ decimal number => number.ToString(CultureInfo.InvariantCulture),
+ Enum enumValue => enumValue.ToString(),
+ _ => JsonSerializer.Serialize(value, value.GetType(), McpJsonOptions.Default)
+ };
+ }
+
+ static string GetTypeName(object? value)
+ {
+ return value switch
+ {
+ null => "null",
+ string => "string",
+ bool => "boolean",
+ sbyte or byte or short or ushort or int or uint or long or ulong => "integer",
+ float or double or decimal => "number",
+ DateTime or DateTimeOffset => "date-time",
+ TimeSpan => "time-span",
+ Enum => "enum",
+ _ => "json"
+ };
+ }
+}
+
+public class McpFailedFailureGroupResult
+{
+ public string Id { get; init; } = string.Empty;
+ public string Title { get; init; } = string.Empty;
+ public string Type { get; init; } = string.Empty;
+
+ public static McpFailedFailureGroupResult From(FailedMessage.FailureGroup group)
+ {
+ return new McpFailedFailureGroupResult
+ {
+ Id = group.Id,
+ Title = group.Title,
+ Type = group.Type
+ };
+ }
+}
diff --git a/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs
new file mode 100644
index 0000000000..557b6d1541
--- /dev/null
+++ b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs
@@ -0,0 +1,51 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System;
+using ServiceControl.Contracts.Operations;
+using ServiceControl.MessageFailures;
+using MessageFailures.Api;
+using ServiceControl.Operations;
+
+public class McpFailedMessageViewResult
+{
+ public string? Error { get; init; }
+ public string Id { get; init; } = string.Empty;
+ public string MessageType { get; init; } = string.Empty;
+ public DateTime? TimeSent { get; init; }
+ public bool IsSystemMessage { get; init; }
+ public ExceptionDetails? Exception { get; init; }
+ public string MessageId { get; init; } = string.Empty;
+ public int NumberOfProcessingAttempts { get; init; }
+ public FailedMessageStatus Status { get; init; }
+ public EndpointDetails? SendingEndpoint { get; init; }
+ public EndpointDetails? ReceivingEndpoint { get; init; }
+ public string QueueAddress { get; init; } = string.Empty;
+ public DateTime TimeOfFailure { get; init; }
+ public DateTime LastModified { get; init; }
+ public bool Edited { get; init; }
+ public string EditOf { get; init; } = string.Empty;
+
+ public static McpFailedMessageViewResult From(FailedMessageView message)
+ {
+ return new McpFailedMessageViewResult
+ {
+ Id = message.Id,
+ MessageType = message.MessageType,
+ TimeSent = message.TimeSent,
+ IsSystemMessage = message.IsSystemMessage,
+ Exception = message.Exception,
+ MessageId = message.MessageId,
+ NumberOfProcessingAttempts = message.NumberOfProcessingAttempts,
+ Status = message.Status,
+ SendingEndpoint = message.SendingEndpoint,
+ ReceivingEndpoint = message.ReceivingEndpoint,
+ QueueAddress = message.QueueAddress,
+ TimeOfFailure = message.TimeOfFailure,
+ LastModified = message.LastModified,
+ Edited = message.Edited,
+ EditOf = message.EditOf
+ };
+ }
+}
diff --git a/src/ServiceControl/Mcp/McpJsonOptions.cs b/src/ServiceControl/Mcp/McpJsonOptions.cs
new file mode 100644
index 0000000000..1288bb7b1c
--- /dev/null
+++ b/src/ServiceControl/Mcp/McpJsonOptions.cs
@@ -0,0 +1,16 @@
+namespace ServiceControl.Mcp;
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+static class McpJsonOptions
+{
+ public static JsonSerializerOptions Default { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ TypeInfoResolverChain = { McpSerializationContext.Default, new DefaultJsonTypeInfoResolver() }
+ };
+}
diff --git a/src/ServiceControl/Mcp/McpSerializationContext.cs b/src/ServiceControl/Mcp/McpSerializationContext.cs
new file mode 100644
index 0000000000..6a3091da3a
--- /dev/null
+++ b/src/ServiceControl/Mcp/McpSerializationContext.cs
@@ -0,0 +1,24 @@
+namespace ServiceControl.Mcp;
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MessageFailures;
+using ServiceControl.Contracts.Operations;
+using ServiceControl.Infrastructure.Mcp;
+using ServiceControl.MessageFailures.Api;
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false)]
+[JsonSerializable(typeof(McpCollectionResult))]
+[JsonSerializable(typeof(McpErrorsSummaryResult))]
+[JsonSerializable(typeof(McpFailedMessageResult))]
+[JsonSerializable(typeof(McpFailedMessageViewResult))]
+[JsonSerializable(typeof(McpOperationResult))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(McpMessageMetadataEntryResult))]
+[JsonSerializable(typeof(McpFailedProcessingAttemptResult))]
+[JsonSerializable(typeof(McpFailedFailureGroupResult))]
+[JsonSerializable(typeof(FailedMessageStatus))]
+public partial class McpSerializationContext : JsonSerializerContext;
diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs
new file mode 100644
index 0000000000..11cfa92834
--- /dev/null
+++ b/src/ServiceControl/Mcp/RetryTools.cs
@@ -0,0 +1,150 @@
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using MessageFailures;
+using MessageFailures.InternalMessages;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using NServiceBus;
+using Recoverability;
+using Persistence;
+using ServiceControl.Infrastructure.Mcp;
+
+[McpServerToolType, Description(
+ "Tools for retrying failed messages.\n\n" +
+ "Agent guidance:\n" +
+ "1. Every tool in this group changes system state by sending failed messages back for reprocessing. Only retry after the underlying issue has been resolved.\n" +
+ "2. Prefer RetryFailureGroup when all messages share the same root cause — it is the most targeted approach.\n" +
+ "3. Use RetryAllFailedMessagesByEndpoint when a bug in one endpoint has been fixed.\n" +
+ "4. Use RetryFailedMessagesByQueue when a queue's consumer was down and is now back.\n" +
+ "5. Use RetryAllFailedMessages only as a last resort — it retries everything.\n" +
+ "6. All operations are asynchronous — they return Accepted immediately and complete in the background."
+)]
+public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager, ILogger logger)
+{
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Use this tool to reprocess a single failed message by sending it back to its original queue. " +
+ "This operation changes system state. " +
+ "Good for questions like: 'retry this message', 'reprocess this failure', or 'send this message back for processing'. " +
+ "The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. " +
+ "If you need to retry many messages with the same root cause, use RetryFailureGroup instead."
+ )]
+ public async Task RetryFailedMessage(
+ [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId)
+ {
+ logger.LogInformation("MCP RetryFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId);
+
+ await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId);
+ return McpRetryOperationResult.Accepted($"Retry requested for message '{failedMessageId}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retry a selected set of failed messages by their IDs. " +
+ "Use this when the user explicitly wants to retry specific known messages. " +
+ "Prefer RetryFailureGroup when retrying all messages with the same root cause. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Use values obtained from failed-message investigation tools."
+ )]
+ public async Task RetryFailedMessages(
+ [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds)
+ {
+ logger.LogInformation("MCP RetryFailedMessages invoked (count={Count})", messageIds.Length);
+
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ logger.LogWarning("MCP RetryFailedMessages: rejected due to empty message IDs");
+ return McpRetryOperationResult.ValidationError("All message IDs must be non-empty strings.");
+ }
+
+ await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds);
+ return McpRetryOperationResult.Accepted($"Retry requested for {messageIds.Length} messages.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retry all unresolved failed messages from a specific queue. " +
+ "Use this when the user explicitly wants a queue-scoped retry after a queue or consumer issue is fixed. " +
+ "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Use the queue address from failed-message results."
+ )]
+ public async Task RetryFailedMessagesByQueue(
+ [Description("Queue address whose unresolved failed messages should be retried. Use values obtained from failed-message results.")] string queueAddress)
+ {
+ logger.LogInformation("MCP RetryFailedMessagesByQueue invoked (queueAddress={QueueAddress})", queueAddress);
+
+ await messageSession.SendLocal(m =>
+ {
+ m.QueueAddress = queueAddress;
+ m.Status = FailedMessageStatus.Unresolved;
+ });
+ return McpRetryOperationResult.Accepted($"Retry requested for all failed messages in queue '{queueAddress}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retry all currently failed messages across all queues. " +
+ "Use only when the user explicitly requests a broad retry operation. " +
+ "Prefer narrower retry tools such as RetryFailureGroup or RetryFailedMessages when possible. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "It affects all unresolved failed messages across the instance and may affect a large number of messages."
+ )]
+ public async Task RetryAllFailedMessages()
+ {
+ logger.LogInformation("MCP RetryAllFailedMessages invoked");
+
+ await messageSession.SendLocal(new RequestRetryAll());
+ return McpRetryOperationResult.Accepted("Retry requested for all failed messages.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retry all failed messages for a specific endpoint. " +
+ "Use this when the user explicitly wants an endpoint-scoped retry after an endpoint-specific issue is fixed. " +
+ "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Use the endpoint name from failed-message results."
+ )]
+ public async Task RetryAllFailedMessagesByEndpoint(
+ [Description("The endpoint name whose failed messages should be retried. Use values obtained from failed-message results.")] string endpointName)
+ {
+ logger.LogInformation("MCP RetryAllFailedMessagesByEndpoint invoked (endpoint={EndpointName})", endpointName);
+
+ await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName });
+ return McpRetryOperationResult.Accepted($"Retry requested for all failed messages in endpoint '{endpointName}'.");
+ }
+
+ [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description(
+ "Retry all failed messages in a failure group that share the same root cause. " +
+ "Use this when multiple failures are caused by the same issue and can be retried together. " +
+ "Prefer RetryFailedMessages for more granular control. " +
+ "This operation changes system state. " +
+ "It may affect many messages. " +
+ "Use the failure group ID from GetFailureGroups. " +
+ "Returns InProgress if a retry is already running for this group."
+ )]
+ public async Task RetryFailureGroup(
+ [Description("The failure group ID from previous GetFailureGroups results.")] string groupId)
+ {
+ logger.LogInformation("MCP RetryFailureGroup invoked (groupId={GroupId})", groupId);
+
+ if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup))
+ {
+ logger.LogInformation("MCP RetryFailureGroup: operation already in progress for group '{GroupId}'", groupId);
+ return McpRetryOperationResult.InProgress($"A retry operation is already in progress for group '{groupId}'.");
+ }
+
+ var started = System.DateTime.UtcNow;
+ await retryingManager.Wait(groupId, RetryType.FailureGroup, started);
+ await messageSession.SendLocal(new RetryAllInGroup
+ {
+ GroupId = groupId,
+ Started = started
+ });
+
+ return McpRetryOperationResult.Accepted($"Retry requested for all messages in failure group '{groupId}'.");
+ }
+}
diff --git a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs
index 2e317cba54..ae852de26e 100644
--- a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs
+++ b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs
@@ -21,7 +21,7 @@ public async Task Handle(ArchiveMessage message, IMessageHandlerContext context)
var failedMessage = await dataStore.ErrorBy(failedMessageId);
- if (failedMessage.Status != FailedMessageStatus.Archived)
+ if (failedMessage is not null && failedMessage.Status != FailedMessageStatus.Archived)
{
await domainEvents.Raise(new FailedMessageArchived
{
diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj
index d931751d34..2475998650 100644
--- a/src/ServiceControl/ServiceControl.csproj
+++ b/src/ServiceControl/ServiceControl.csproj
@@ -33,6 +33,7 @@
+
diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs
index 685bc7dc16..4d3be18f2c 100644
--- a/src/ServiceControl/WebApplicationExtensions.cs
+++ b/src/ServiceControl/WebApplicationExtensions.cs
@@ -3,13 +3,15 @@ namespace ServiceControl;
using Infrastructure.SignalR;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
+using ModelContextProtocol.AspNetCore;
using ServiceControl.Hosting.ForwardedHeaders;
using ServiceControl.Hosting.Https;
using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
+ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, bool enableMcpServer)
{
app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
app.UseServiceControlHttps(httpsSettings);
@@ -19,5 +21,10 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe
app.MapHub("/api/messagestream");
app.UseCors();
app.MapControllers();
+
+ if (enableMcpServer)
+ {
+ app.MapMcp("/mcp");
+ }
}
}
\ No newline at end of file