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