diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 6b5b11befb..8fb6561a6c 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -1,5 +1,37 @@
{
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
packageRules: [
+ {
+ description:"Packages that ship as part of .NET",
+ matchDatasources: ["nuget"],
+ matchPackageNames: [
+ "/^Microsoft.AspNetCore./",
+ "/^Microsoft.Extensions./",
+ "/^System./",
+ "!/^System.IdentityModel./",
+ "!/^System.Management.Automation/",
+ "!/^System.Reactive/"
+ ],
+ groupName: ".NET Packages",
+ groupSlug: "dotnet-packages"
+ },
+ {
+ description:"IdentityModel packages that release separately from .NET",
+ matchDatasources: ["nuget"],
+ matchPackageNames: [
+ "/^Microsoft.IdentityModel./",
+ "/^System.IdentityModel./"
+ ],
+ groupName: "IdentityModel",
+ groupSlug: "identity-model"
+ },
+ {
+ description:"OpenTelemetry packages",
+ matchDatasources: ["nuget"],
+ matchPackageNames: ["/^OpenTelemetry./"],
+ groupName: "OpenTelemetry",
+ groupSlug: "open-telemetry"
+ },
{
description: "Keep ServiceControl.Management.PowerShell on 8.x",
matchFileNames: ["**/ServiceControl.Management.PowerShell.csproj"],
diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml
index 6f5fec5907..fb42dd4697 100644
--- a/.github/workflows/build-containers.yml
+++ b/.github/workflows/build-containers.yml
@@ -47,7 +47,7 @@ jobs:
with:
version: ${{ env.MinVerVersion }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.12.0
+ uses: docker/setup-buildx-action@v4.0.0
- name: Log in to GitHub container registry
uses: docker/login-action@v3.7.0
with:
@@ -58,7 +58,7 @@ jobs:
id: date
run: echo "date=$(date '+%FT%TZ')" >> $GITHUB_OUTPUT
- name: Build and push image to GitHub container registry
- uses: docker/build-push-action@v6.19.2
+ uses: docker/build-push-action@v7.0.0
with:
context: .
push: true
diff --git a/.github/workflows/build-db-container.yml b/.github/workflows/build-db-container.yml
index 6cae00fabc..21da8c658f 100644
--- a/.github/workflows/build-db-container.yml
+++ b/.github/workflows/build-db-container.yml
@@ -31,7 +31,7 @@ jobs:
with:
version: ${{ env.MinVerVersion }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.12.0
+ uses: docker/setup-buildx-action@v4.0.0
- name: Log in to GitHub container registry
uses: docker/login-action@v3.7.0
with:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a8b91de03..89d17f3c27 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -63,7 +63,7 @@ jobs:
if: matrix.os-name == 'Windows'
run: Import-Module ./deploy/PowerShellModules/Particular.ServiceControl.Management
- name: Azure login
- uses: azure/login@v2.3.0
+ uses: azure/login@v3.0.0
if: matrix.test-category == 'AzureServiceBus' || matrix.test-category == 'AzureStorageQueues' || matrix.test-category == 'RabbitMQ' || matrix.test-category == 'PostgreSQL'
with:
creds: ${{ secrets.AZURE_ACI_CREDENTIALS }}
@@ -90,7 +90,7 @@ jobs:
registry-username: ${{ secrets.DOCKERHUB_USERNAME }}
registry-password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Azure Service Bus
- uses: Particular/setup-azureservicebus-action@v2.0.0
+ uses: Particular/setup-azureservicebus-action@v2.1.0
if: matrix.test-category == 'AzureServiceBus'
with:
connection-string-name: ServiceControl_TransportTests_ASBS_ConnectionString
diff --git a/.github/workflows/container-integration-test.yml b/.github/workflows/container-integration-test.yml
index 1b59cd927b..413dba6e8c 100644
--- a/.github/workflows/container-integration-test.yml
+++ b/.github/workflows/container-integration-test.yml
@@ -72,12 +72,12 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Azure Login
if: ${{ matrix.name == 'asb' || matrix.name == 'asq' }}
- uses: azure/login@v2.3.0
+ uses: azure/login@v3.0.0
with:
creds: ${{ secrets.AZURE_ACI_CREDENTIALS }}
- name: Setup Azure Service Bus
if: ${{ matrix.name == 'asb' }}
- uses: Particular/setup-azureservicebus-action@v2.0.0
+ uses: Particular/setup-azureservicebus-action@v2.1.0
with:
connection-string-name: CONNECTIONSTRING
azure-credentials: ${{ secrets.AZURE_ACI_CREDENTIALS }}
diff --git a/.github/workflows/push-container-images.yml b/.github/workflows/push-container-images.yml
index f556b7da43..d5fc93ee62 100644
--- a/.github/workflows/push-container-images.yml
+++ b/.github/workflows/push-container-images.yml
@@ -33,7 +33,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.12.0
+ uses: docker/setup-buildx-action@v4.0.0
- name: Publish to Docker Hub
run: |
$containers = @('servicecontrol', 'servicecontrol-audit', 'servicecontrol-monitoring', 'servicecontrol-ravendb')
diff --git a/README.md b/README.md
index 7261e84be4..0668ad072b 100644
--- a/README.md
+++ b/README.md
@@ -102,3 +102,9 @@ Steps:
-ErrorRetentionPeriod 10:00:00:00 `
-Acknowledgements RabbitMQBrokerVersion310
```
+
+## Integrated ServicePulse
+
+Since version 6.13, ServiceControl ships with a copy of ServicePulse and [can host it from an Error instance](https://docs.particular.net/servicecontrol/servicecontrol-instances/integrated-servicepulse).
+
+ServiceControl Error instances have a reference to the Particular.ServicePulse.Core package; this contains the ServicePulse assets, along with the code required to serve them out of an ASP.NET web host.
diff --git a/nuget.config b/nuget.config
index ff985a1e0a..d72d1d3d28 100644
--- a/nuget.config
+++ b/nuget.config
@@ -15,4 +15,4 @@
-
\ No newline at end of file
+
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 4b7369cc5e..cb3436b7a2 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -10,7 +10,7 @@
low
all
- 2.1.3
+ 2.1.4
0024000004800000940000000602000000240000525341310004000001000100dde965e6172e019ac82c2639ffe494dd2e7dd16347c34762a05732b492e110f2e4e2e1b5ef2d85c848ccfb671ee20a47c8d1376276708dc30a90ff1121b647ba3b7259a6bc383b2034938ef0e275b58b920375ac605076178123693c6c4f1331661a62eba28c249386855637780e3ff5f23a6d854700eaa6803ef48907513b92
00240000048000009400000006020000002400005253413100040000010001007f16e21368ff041183fab592d9e8ed37e7be355e93323147a1d29983d6e591b04282e4da0c9e18bd901e112c0033925eb7d7872c2f1706655891c5c9d57297994f707d16ee9a8f40d978f064ee1ffc73c0db3f4712691b23bf596f75130f4ec978cf78757ec034625a5f27e6bb50c618931ea49f6f628fd74271c32959efb1c5
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index fa54c02e52..9d874478da 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -6,7 +6,7 @@
-
+
@@ -16,24 +16,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -44,17 +44,17 @@
-
+
-
+
-
+
@@ -64,7 +64,7 @@
-
+
@@ -72,26 +72,26 @@
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
+
-
\ No newline at end of file
+
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/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/InMemoryAttachmentsBodyStorage.cs b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAttachmentsBodyStorage.cs
index b46d3b1fac..d2193c57fe 100644
--- a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAttachmentsBodyStorage.cs
+++ b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAttachmentsBodyStorage.cs
@@ -1,5 +1,6 @@
namespace ServiceControl.Audit.Persistence.InMemory
{
+ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -60,7 +61,7 @@ public async Task TryFetch(string bodyId, CancellationToken cancel
Stream = new MemoryStream(messageBody.Content),
ContentType = messageBody.ContentType,
BodySize = messageBody.BodySize,
- Etag = string.Empty
+ Etag = Guid.NewGuid().ToString()
});
}
diff --git a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
index 9dcffaee85..52f360ae46 100644
--- a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
+++ b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs
@@ -101,7 +101,7 @@ public async Task Can_roundtrip_message_body()
Assert.That(retrievedMessage.Found, Is.True);
Assert.That(retrievedMessage.HasContent, Is.True);
Assert.That(retrievedMessage.ContentLength, Is.EqualTo(body.Length));
- Assert.That(retrievedMessage.ETag, Is.Not.Null.Or.Empty);
+ Assert.That(retrievedMessage.ETag, Is.Not.Null.And.Not.Empty);
Assert.That(retrievedMessage.StreamContent, Is.Not.Null);
Assert.That(retrievedMessage.ContentType, Is.EqualTo(expectedContentType));
});
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/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..f650640314 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
@@ -4,13 +4,22 @@
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
+ .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();
diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs
new file mode 100644
index 0000000000..b4fddeaa51
--- /dev/null
+++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs
@@ -0,0 +1,147 @@
+#nullable enable
+
+namespace ServiceControl.Audit.Mcp;
+
+using System.ComponentModel;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Infrastructure;
+using ModelContextProtocol.Server;
+using Persistence;
+
+[McpServerToolType]
+public class AuditMessageTools(IAuditDataStore store)
+{
+ [McpServerTool, Description("Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.")]
+ public async Task GetAuditMessages(
+ [Description("Whether to include system messages in results. Default is false")] bool includeSystemMessages = false,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc",
+ [Description("Filter by time sent start (ISO 8601 format)")] string? timeSentFrom = null,
+ [Description("Filter by time sent end (ISO 8601 format)")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ 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);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Search audit messages by a keyword or phrase. Searches across message content and metadata.")]
+ public async Task SearchAuditMessages(
+ [Description("The search query string")] string query,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc",
+ [Description("Filter by time sent start (ISO 8601 format)")] string? timeSentFrom = null,
+ [Description("Filter by time sent end (ISO 8601 format)")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ 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);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get audit messages received by a specific endpoint. Can optionally filter by keyword.")]
+ public async Task GetAuditMessagesByEndpoint(
+ [Description("The name of the receiving endpoint")] string endpointName,
+ [Description("Optional keyword to filter messages")] string? keyword = null,
+ [Description("Whether to include system messages in results. Default is false")] bool includeSystemMessages = false,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc",
+ [Description("Filter by time sent start (ISO 8601 format)")] string? timeSentFrom = null,
+ [Description("Filter by time sent end (ISO 8601 format)")] string? timeSentTo = null,
+ CancellationToken cancellationToken = default)
+ {
+ 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(endpointName, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken)
+ : await store.QueryMessagesByReceivingEndpoint(includeSystemMessages, endpointName, pagingInfo, sortInfo, timeSentRange, cancellationToken);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get all audit messages that belong to a specific conversation. A conversation groups related messages that were triggered by the same initial message.")]
+ public async Task GetAuditMessagesByConversation(
+ [Description("The conversation ID to filter by")] string conversationId,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent")] string sort = "time_sent",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc",
+ CancellationToken cancellationToken = default)
+ {
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.QueryMessagesByConversationId(conversationId, pagingInfo, sortInfo, cancellationToken);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get the body content of a specific audit message by its message ID.")]
+ public async Task GetAuditMessageBody(
+ [Description("The message ID")] string messageId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await store.GetMessageBody(messageId, cancellationToken);
+
+ if (!result.Found)
+ {
+ return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' not found." }, McpJsonOptions.Default);
+ }
+
+ if (!result.HasContent)
+ {
+ return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' has no body content." }, McpJsonOptions.Default);
+ }
+
+ if (result.StringContent != null)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ result.ContentType,
+ result.ContentLength,
+ Body = result.StringContent
+ }, McpJsonOptions.Default);
+ }
+
+ return JsonSerializer.Serialize(new
+ {
+ result.ContentType,
+ result.ContentLength,
+ Body = "(stream content - not available as text)"
+ }, McpJsonOptions.Default);
+ }
+}
diff --git a/src/ServiceControl.Audit/Mcp/EndpointTools.cs b/src/ServiceControl.Audit/Mcp/EndpointTools.cs
new file mode 100644
index 0000000000..705a88fbb2
--- /dev/null
+++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs
@@ -0,0 +1,40 @@
+#nullable enable
+
+namespace ServiceControl.Audit.Mcp;
+
+using System.ComponentModel;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Persistence;
+
+[McpServerToolType]
+public class EndpointTools(IAuditDataStore store)
+{
+ [McpServerTool, Description("Get a list of all known endpoints that have sent or received audit messages.")]
+ public async Task GetKnownEndpoints(CancellationToken cancellationToken = default)
+ {
+ var results = await store.QueryKnownEndpoints(cancellationToken);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.")]
+ public async Task GetEndpointAuditCounts(
+ [Description("The name of the endpoint")] string endpointName,
+ CancellationToken cancellationToken = default)
+ {
+ var results = await store.QueryAuditCounts(endpointName, cancellationToken);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+}
diff --git a/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs b/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs
new file mode 100644
index 0000000000..ff03d91eae
--- /dev/null
+++ b/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs
@@ -0,0 +1,16 @@
+#nullable enable
+
+namespace ServiceControl.Audit.Mcp;
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+static class McpJsonOptions
+{
+ public static JsonSerializerOptions Default { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false
+ };
+}
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.Transports.ASBS/TokenCredentialAuthentication.cs b/src/ServiceControl.Transports.ASBS/TokenCredentialAuthentication.cs
index a11f74463f..d00e277891 100644
--- a/src/ServiceControl.Transports.ASBS/TokenCredentialAuthentication.cs
+++ b/src/ServiceControl.Transports.ASBS/TokenCredentialAuthentication.cs
@@ -1,4 +1,5 @@
-namespace ServiceControl.Transports.ASBS
+#nullable enable
+namespace ServiceControl.Transports.ASBS
{
using Azure.Core;
using Azure.Identity;
@@ -13,21 +14,20 @@ public TokenCredentialAuthentication(string fullyQualifiedNamespace)
Credential = new DefaultAzureCredential();
}
- public TokenCredentialAuthentication(string fullyQualifiedNamespace, string clientId)
+ public TokenCredentialAuthentication(string fullyQualifiedNamespace, string? clientId)
{
FullyQualifiedNamespace = fullyQualifiedNamespace;
ClientId = clientId;
- Credential = new ManagedIdentityCredential(clientId);
+ Credential = new ManagedIdentityCredential(clientId is not null ? ManagedIdentityId.FromUserAssignedClientId(clientId) : ManagedIdentityId.SystemAssigned);
}
public string FullyQualifiedNamespace { get; }
public TokenCredential Credential { get; }
- public string ClientId { get; }
+ public string? ClientId { get; }
- public override ServiceBusAdministrationClient BuildManagementClient()
- => new ServiceBusAdministrationClient(FullyQualifiedNamespace, Credential);
+ public override ServiceBusAdministrationClient BuildManagementClient() => new(FullyQualifiedNamespace, Credential);
public override AzureServiceBusTransport CreateTransportDefinition(ConnectionSettings connectionSettings, TopicTopology topology)
{
diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs
index ebf285839c..b4dc699228 100644
--- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs
+++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
+using global::RabbitMQ.Client;
using NServiceBus;
static class RabbitMQTransportExtensions
@@ -40,7 +41,7 @@ public static void ApplySettingsFromConnectionString(this RabbitMQTransport tran
if (dictionary.TryGetValue("UseExternalAuthMechanism", out var useExternalAuthMechanismString))
{
_ = bool.TryParse(useExternalAuthMechanismString, out var useExternalAuthMechanism);
- transport.UseExternalAuthMechanism = useExternalAuthMechanism;
+ transport.AuthMechanisms = [new ExternalMechanismFactory()];
}
}
}
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/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/Container-README.md b/src/ServiceControl/Container-README.md
index 011e8a7f00..806df8c49c 100644
--- a/src/ServiceControl/Container-README.md
+++ b/src/ServiceControl/Container-README.md
@@ -20,7 +20,7 @@ docker run -d --name servicecontrol -p 33333:33333 \
-e CONNECTIONSTRING="host=rabbitmq" \
-e RAVENDB_CONNECTIONSTRING="http://servicecontrol-db:8080" \
-e REMOTEINSTANCES='[{"api_uri":"http://audit:44444/api"}]' \
- -e ENABLEDINTEGRATEDSERVICEPULSE="true" \
+ -e ENABLEINTEGRATEDSERVICEPULSE="true" \
particular/servicecontrol:latest --setup-and-run
```
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..17dc44d5d3 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,15 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co
builder.AddServiceControlApis();
- builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
+ if (settings.EnableMcpServer)
+ {
+ 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();
diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs
new file mode 100644
index 0000000000..86abe21de0
--- /dev/null
+++ b/src/ServiceControl/Mcp/ArchiveTools.cs
@@ -0,0 +1,90 @@
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using MessageFailures.InternalMessages;
+using ModelContextProtocol.Server;
+using NServiceBus;
+using Persistence.Recoverability;
+using ServiceControl.Recoverability;
+
+[McpServerToolType]
+public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver)
+{
+ [McpServerTool, Description("Archive a single failed message by its unique ID. The message will be moved to the archived status.")]
+ public async Task ArchiveFailedMessage(
+ [Description("The unique ID of the failed message to archive")] string failedMessageId)
+ {
+ await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId);
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for message '{failedMessageId}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Archive multiple failed messages by their unique IDs. All specified messages will be moved to the archived status.")]
+ public async Task ArchiveFailedMessages(
+ [Description("Array of unique message IDs to archive")] string[] messageIds)
+ {
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default);
+ }
+
+ foreach (var id in messageIds)
+ {
+ await messageSession.SendLocal(m => m.FailedMessageId = id);
+ }
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for {messageIds.Length} messages." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Archive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.")]
+ public async Task ArchiveFailureGroup(
+ [Description("The ID of the failure group to archive")] string groupId)
+ {
+ if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup))
+ {
+ return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default);
+ }
+
+ await archiver.StartArchiving(groupId, ArchiveType.FailureGroup);
+ await messageSession.SendLocal(m => m.GroupId = groupId);
+
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Unarchive a single failed message by its unique ID. The message will be moved back to the unresolved status.")]
+ public async Task UnarchiveFailedMessage(
+ [Description("The unique ID of the failed message to unarchive")] string failedMessageId)
+ {
+ await messageSession.SendLocal(m => m.FailedMessageIds = [failedMessageId]);
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for message '{failedMessageId}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Unarchive multiple failed messages by their unique IDs. All specified messages will be moved back to the unresolved status.")]
+ public async Task UnarchiveFailedMessages(
+ [Description("Array of unique message IDs to unarchive")] string[] messageIds)
+ {
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default);
+ }
+
+ await messageSession.SendLocal(m => m.FailedMessageIds = messageIds);
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for {messageIds.Length} messages." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Unarchive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.")]
+ public async Task UnarchiveFailureGroup(
+ [Description("The ID of the failure group to unarchive")] string groupId)
+ {
+ if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup))
+ {
+ return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default);
+ }
+
+ await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup);
+ await messageSession.SendLocal(m => m.GroupId = groupId);
+
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default);
+ }
+}
diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs
new file mode 100644
index 0000000000..79c6d47e96
--- /dev/null
+++ b/src/ServiceControl/Mcp/FailedMessageTools.cs
@@ -0,0 +1,94 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Text.Json;
+using System.Threading.Tasks;
+using MessageFailures.Api;
+using ModelContextProtocol.Server;
+using Persistence;
+using Persistence.Infrastructure;
+
+[McpServerToolType]
+public class FailedMessageTools(IErrorMessageDataStore store)
+{
+ [McpServerTool, Description("Get a list of failed messages. Supports filtering by status (unresolved, resolved, archived, retryissued), modified date, and queue address. Returns paged results.")]
+ public async Task GetFailedMessages(
+ [Description("Filter by status: unresolved, resolved, archived, retryissued")] string? status = null,
+ [Description("Filter by modified date (ISO 8601 format)")] string? modified = null,
+ [Description("Filter by queue address")] string? queueAddress = null,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure")] string sort = "time_of_failure",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc")
+ {
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.ErrorGet(status, modified, queueAddress, pagingInfo, sortInfo);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get details of a specific failed message by its unique ID.")]
+ public async Task GetFailedMessageById(
+ [Description("The unique ID of the failed message")] string failedMessageId)
+ {
+ var result = await store.ErrorBy(failedMessageId);
+
+ if (result == null)
+ {
+ return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default);
+ }
+
+ return JsonSerializer.Serialize(result, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get the last processing attempt for a specific failed message.")]
+ public async Task GetFailedMessageLastAttempt(
+ [Description("The unique ID of the failed message")] string failedMessageId)
+ {
+ var result = await store.ErrorLastBy(failedMessageId);
+
+ if (result == null)
+ {
+ return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default);
+ }
+
+ return JsonSerializer.Serialize(result, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get a summary of error counts grouped by status (unresolved, archived, resolved, retryissued).")]
+ public async Task GetErrorsSummary()
+ {
+ var result = await store.ErrorsSummary();
+ return JsonSerializer.Serialize(result, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get failed messages for a specific endpoint.")]
+ public async Task GetFailedMessagesByEndpoint(
+ [Description("The name of the endpoint")] string endpointName,
+ [Description("Filter by status: unresolved, resolved, archived, retryissued")] string? status = null,
+ [Description("Filter by modified date (ISO 8601 format)")] string? modified = null,
+ [Description("Page number (1-based). Default is 1")] int page = 1,
+ [Description("Number of results per page. Default is 50")] int perPage = 50,
+ [Description("Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure")] string sort = "time_of_failure",
+ [Description("Sort direction: asc or desc. Default is desc")] string direction = "desc")
+ {
+ var pagingInfo = new PagingInfo(page, perPage);
+ var sortInfo = new SortInfo(sort, direction);
+
+ var results = await store.ErrorsByEndpointName(status, endpointName, modified, pagingInfo, sortInfo);
+
+ return JsonSerializer.Serialize(new
+ {
+ results.QueryStats.TotalCount,
+ results.Results
+ }, McpJsonOptions.Default);
+ }
+}
diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs
new file mode 100644
index 0000000000..ec311f4ff8
--- /dev/null
+++ b/src/ServiceControl/Mcp/FailureGroupTools.cs
@@ -0,0 +1,30 @@
+#nullable enable
+
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Persistence;
+using Recoverability;
+
+[McpServerToolType]
+public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore)
+{
+ [McpServerTool, Description("Get failure groups, which are collections of failed messages grouped by a classifier (default: exception type and stack trace). Each group shows the count of failures, the first and last occurrence, and any retry operation status.")]
+ public async Task GetFailureGroups(
+ [Description("The classifier to group by. Default is 'Exception Type and Stack Trace'")] string classifier = "Exception Type and Stack Trace",
+ [Description("Optional filter for the classifier")] string? classifierFilter = null)
+ {
+ var results = await fetcher.GetGroups(classifier, classifierFilter);
+ return JsonSerializer.Serialize(results, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Get the retry history showing past retry operations and their outcomes.")]
+ public async Task GetRetryHistory()
+ {
+ var retryHistory = await retryStore.GetRetryHistory();
+ return JsonSerializer.Serialize(retryHistory, McpJsonOptions.Default);
+ }
+}
diff --git a/src/ServiceControl/Mcp/McpJsonOptions.cs b/src/ServiceControl/Mcp/McpJsonOptions.cs
new file mode 100644
index 0000000000..1e37e52d37
--- /dev/null
+++ b/src/ServiceControl/Mcp/McpJsonOptions.cs
@@ -0,0 +1,14 @@
+namespace ServiceControl.Mcp;
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+static class McpJsonOptions
+{
+ public static JsonSerializerOptions Default { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false
+ };
+}
diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs
new file mode 100644
index 0000000000..7d41f9d2f2
--- /dev/null
+++ b/src/ServiceControl/Mcp/RetryTools.cs
@@ -0,0 +1,84 @@
+namespace ServiceControl.Mcp;
+
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using MessageFailures;
+using MessageFailures.InternalMessages;
+using ModelContextProtocol.Server;
+using NServiceBus;
+using Recoverability;
+using Persistence;
+
+[McpServerToolType]
+public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager)
+{
+ [McpServerTool, Description("Retry a single failed message by its unique ID. The message will be sent back to its original queue for reprocessing.")]
+ public async Task RetryFailedMessage(
+ [Description("The unique ID of the failed message to retry")] string failedMessageId)
+ {
+ await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId);
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for message '{failedMessageId}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Retry multiple failed messages by their unique IDs. All specified messages will be sent back to their original queues for reprocessing.")]
+ public async Task RetryFailedMessages(
+ [Description("Array of unique message IDs to retry")] string[] messageIds)
+ {
+ if (messageIds.Any(string.IsNullOrEmpty))
+ {
+ return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default);
+ }
+
+ await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds);
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for {messageIds.Length} messages." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Retry all failed messages from a specific queue address.")]
+ public async Task RetryFailedMessagesByQueue(
+ [Description("The queue address to retry all failed messages from")] string queueAddress)
+ {
+ await messageSession.SendLocal(m =>
+ {
+ m.QueueAddress = queueAddress;
+ m.Status = FailedMessageStatus.Unresolved;
+ });
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all failed messages in queue '{queueAddress}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Retry all failed messages across all queues. Use with caution as this affects all unresolved failed messages.")]
+ public async Task RetryAllFailedMessages()
+ {
+ await messageSession.SendLocal(new RequestRetryAll());
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = "Retry requested for all failed messages." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Retry all failed messages for a specific endpoint.")]
+ public async Task RetryAllFailedMessagesByEndpoint(
+ [Description("The name of the endpoint to retry all failed messages for")] string endpointName)
+ {
+ await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName });
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all failed messages in endpoint '{endpointName}'." }, McpJsonOptions.Default);
+ }
+
+ [McpServerTool, Description("Retry all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.")]
+ public async Task RetryFailureGroup(
+ [Description("The ID of the failure group to retry")] string groupId)
+ {
+ if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup))
+ {
+ return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"A retry operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default);
+ }
+
+ var started = System.DateTime.UtcNow;
+ await retryingManager.Wait(groupId, RetryType.FailureGroup, started);
+ await messageSession.SendLocal(new RetryAllInGroup
+ {
+ GroupId = groupId,
+ Started = started
+ });
+
+ return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default);
+ }
+}
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..629b12dea0 100644
--- a/src/ServiceControl/ServiceControl.csproj
+++ b/src/ServiceControl/ServiceControl.csproj
@@ -33,12 +33,14 @@
+
+
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