From 2189e049114bc787b8053c546fa9d711473444d8 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 11:35:16 +0200 Subject: [PATCH 01/29] Add failed message MCP server --- src/Directory.Packages.props | 1 + src/ServiceControl/App.config | 2 + .../Infrastructure/Settings/Settings.cs | 3 + .../HostApplicationBuilderExtensions.cs | 10 ++ src/ServiceControl/Mcp/ArchiveTools.cs | 90 ++++++++++++++++++ src/ServiceControl/Mcp/FailedMessageTools.cs | 94 +++++++++++++++++++ src/ServiceControl/Mcp/FailureGroupTools.cs | 30 ++++++ src/ServiceControl/Mcp/McpJsonOptions.cs | 14 +++ src/ServiceControl/Mcp/RetryTools.cs | 84 +++++++++++++++++ .../Handlers/ArchiveMessageHandler.cs | 2 +- src/ServiceControl/ServiceControl.csproj | 1 + .../WebApplicationExtensions.cs | 3 + 12 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/ServiceControl/Mcp/ArchiveTools.cs create mode 100644 src/ServiceControl/Mcp/FailedMessageTools.cs create mode 100644 src/ServiceControl/Mcp/FailureGroupTools.cs create mode 100644 src/ServiceControl/Mcp/McpJsonOptions.cs create mode 100644 src/ServiceControl/Mcp/RetryTools.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 52780bef2d..82656073d8 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -82,6 +82,7 @@ + diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index d6271805e5..817bd95e11 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/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..173ce94b70 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Hosting; using Particular.LicensingComponent.WebApi; using Particular.ServiceControl; + using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Configuration; static class HostApplicationBuilderExtensions { @@ -20,6 +22,14 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co builder.AddServiceControlApis(); + if (SettingsReader.Read(Settings.SettingsRootNamespace, "EnableMcpServer", false)) + { + builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly(); + } + builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings))); // We're not explicitly adding Gzip here because it's already in the default list of supported compressors 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..39aea072bc 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -39,6 +39,7 @@ + diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 685bc7dc16..4de53c6406 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -3,6 +3,8 @@ 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; @@ -19,5 +21,6 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.MapHub("/api/messagestream"); app.UseCors(); app.MapControllers(); + app.MapMcp(); } } \ No newline at end of file From 1c8099115bcbcec2f8b44efd9945a6d9919364c4 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 12:11:14 +0200 Subject: [PATCH 02/29] Add feature flag check for MCP --- .../TestSupport/ServiceControlComponentRunner.cs | 4 ++-- .../Hosting/Commands/ImportFailedErrorsCommand.cs | 2 +- src/ServiceControl/Hosting/Commands/RunCommand.cs | 4 ++-- .../WebApi/HostApplicationBuilderExtensions.cs | 7 +++---- src/ServiceControl/WebApplicationExtensions.cs | 11 +++++++---- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 657a84244d..0d23f4febc 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); await host.StartAsync(); DomainEvents = host.Services.GetRequiredService(); // Bring this back and look into the base address of the client 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..e3d391ca12 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); if (settings.EnableIntegratedServicePulse) { app.UseServicePulse(settings.ServicePulseSettings); diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 173ce94b70..17dc44d5d3 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -10,11 +10,10 @@ using Particular.LicensingComponent.WebApi; using Particular.ServiceControl; using ServiceBus.Management.Infrastructure.Settings; - using ServiceControl.Configuration; 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. @@ -22,7 +21,7 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co builder.AddServiceControlApis(); - if (SettingsReader.Read(Settings.SettingsRootNamespace, "EnableMcpServer", false)) + if (settings.EnableMcpServer) { builder.Services .AddMcpServer() @@ -30,7 +29,7 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co .WithToolsFromAssembly(); } - builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings))); + 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/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 4de53c6406..c912c9756b 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -3,15 +3,14 @@ namespace ServiceControl; using Infrastructure.SignalR; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.HttpOverrides; -using ModelContextProtocol.AspNetCore; +using ServiceBus.Management.Infrastructure.Settings; 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, Settings settings) { app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); @@ -21,6 +20,10 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.MapHub("/api/messagestream"); app.UseCors(); app.MapControllers(); - app.MapMcp(); + + if (settings.EnableMcpServer) + { + app.MapMcp(); + } } } \ No newline at end of file From 7b47a30cfbdb9f3c5a2bc76a08185a8b5c1606c5 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 12:37:25 +0200 Subject: [PATCH 03/29] Update to v1.1.0 of ModelContextProtocol.AspNetCore --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 82656073d8..9d874478da 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -82,7 +82,7 @@ - + From 5c5645eed0426d20e75e48779e61a81e4c91a00b Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 12:38:39 +0200 Subject: [PATCH 04/29] Turn MCP off by default --- src/ServiceControl/App.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 817bd95e11..698755c9a7 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -5,7 +5,7 @@ These settings are only here so that we can debug ServiceControl while developin --> - + From 2c00d5ea4e589a72ea3d3ea9c89ce82b097712e9 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 12:40:15 +0200 Subject: [PATCH 05/29] Put packages in alphabetical order --- src/ServiceControl/ServiceControl.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 39aea072bc..2475998650 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -33,13 +33,13 @@ + - From e4ab0ea4e41c95ea9d2edbf97bc631373c8e1f7d Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 14:07:21 +0200 Subject: [PATCH 06/29] Update approvals --- .../APIApprovals.PlatformSampleSettings.approved.txt | 1 + 1 file changed, 1 insertion(+) 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, From 4c8ecdad30d7edf371894cc9cccdae92017de3cf Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Mon, 9 Mar 2026 15:30:59 +0200 Subject: [PATCH 07/29] Don't pass the full settings object in --- .../TestSupport/ServiceControlComponentRunner.cs | 2 +- src/ServiceControl/Hosting/Commands/RunCommand.cs | 2 +- src/ServiceControl/WebApplicationExtensions.cs | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 0d23f4febc..b6b3b8048a 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -135,7 +135,7 @@ async Task InitializeServiceControl(ScenarioContext context) host.UseTestRemoteIp(); host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled); - host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings); + 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/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index e3d391ca12..9778db2cc0 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -30,7 +30,7 @@ public override async Task Execute(HostArguments args, Settings settings) hostBuilder.AddServiceControlApi(settings); var app = hostBuilder.Build(); - app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings); + app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer); if (settings.EnableIntegratedServicePulse) { app.UseServicePulse(settings.ServicePulseSettings); diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index c912c9756b..ac015a5c5b 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -3,14 +3,13 @@ namespace ServiceControl; using Infrastructure.SignalR; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using ServiceBus.Management.Infrastructure.Settings; 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, Settings settings) + public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, bool enableMcpServer) { app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); @@ -21,7 +20,7 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.UseCors(); app.MapControllers(); - if (settings.EnableMcpServer) + if (enableMcpServer) { app.MapMcp(); } From add92350b2a2bd6af4977febc6e6506644d07248 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 9 Mar 2026 11:35:16 +0200 Subject: [PATCH 08/29] Add failed message MCP server --- src/ServiceControl/ServiceControl.csproj | 1 + src/ServiceControl/WebApplicationExtensions.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 2475998650..629b12dea0 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -40,6 +40,7 @@ + diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index ac015a5c5b..5907ff8134 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -3,6 +3,8 @@ 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; From ae22f8adc5e8c41f80b5e5ff7f73121ba6788769 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 20 Mar 2026 12:20:01 +0200 Subject: [PATCH 09/29] Use /mcp as the route --- src/ServiceControl/WebApplicationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 5907ff8134..4d3be18f2c 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -24,7 +24,7 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe if (enableMcpServer) { - app.MapMcp(); + app.MapMcp("/mcp"); } } } \ No newline at end of file From a160b4d121ccda7a1fcad567c8992414fe214013 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 20 Mar 2026 12:33:48 +0200 Subject: [PATCH 10/29] Add MCP for audit --- .../ServiceControlComponentRunner.cs | 4 +- src/ServiceControl.Audit/App.config | 2 + .../Hosting/Commands/RunCommand.cs | 4 +- .../Infrastructure/Settings/Settings.cs | 3 + .../HostApplicationBuilderExtensions.cs | 13 +- .../Mcp/AuditMessageTools.cs | 147 ++++++++++++++++++ src/ServiceControl.Audit/Mcp/EndpointTools.cs | 40 +++++ .../Mcp/McpJsonOptions.cs | 16 ++ .../ServiceControl.Audit.csproj | 1 + .../WebApplicationExtensions.cs | 7 +- 10 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 src/ServiceControl.Audit/Mcp/AuditMessageTools.cs create mode 100644 src/ServiceControl.Audit/Mcp/EndpointTools.cs create mode 100644 src/ServiceControl.Audit/Mcp/McpJsonOptions.cs 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/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 From 5da11817ec7ffdc33aead88fa79e3e5bf85b2daf Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 20 Mar 2026 13:09:26 +0200 Subject: [PATCH 11/29] Remove duplicate project reference --- src/ServiceControl/ServiceControl.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 629b12dea0..2475998650 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -40,7 +40,6 @@ - From 49d946b3c892158758f885c424877f3d56ea87c3 Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Fri, 20 Mar 2026 16:47:24 +0200 Subject: [PATCH 12/29] Update approvals --- .../APIApprovals.PlatformSampleSettings.approved.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 83897faeba..fa983cf7d6 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -58,5 +58,6 @@ "ServiceControlQueueAddress": "Particular.ServiceControl", "TimeToRestartAuditIngestionAfterFailure": "00:01:00", "EnableFullTextSearchOnBodies": true, + "EnableMcpServer": false, "ShutdownTimeout": "00:00:05" } \ No newline at end of file From 312e2a7ed2e8226168108aa8637a91ff024d7722 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 20 Mar 2026 12:53:14 +0200 Subject: [PATCH 13/29] Add test --- .../Mcp/When_mcp_server_is_enabled.cs | 214 ++++++++++++++++++ .../Mcp/AuditMessageMcpToolsTests.cs | 188 +++++++++++++++ .../Mcp/EndpointMcpToolsTests.cs | 96 ++++++++ 3 files changed, 498 insertions(+) create mode 100644 src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs create mode 100644 src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs create mode 100644 src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs new file mode 100644 index 0000000000..8e30222545 --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -0,0 +1,214 @@ +namespace ServiceControl.Audit.AcceptanceTests.Mcp; + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using AcceptanceTesting.EndpointTemplates; +using Audit.Auditing.MessagesView; +using NServiceBus; +using NServiceBus.AcceptanceTesting; +using NServiceBus.AcceptanceTesting.Customization; +using NServiceBus.Settings; +using NUnit.Framework; + +class When_mcp_server_is_enabled : AcceptanceTest +{ + [SetUp] + public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true; + + [Test] + public async Task Should_expose_mcp_endpoint() + { + await Define() + .Done(async _ => + { + var response = await InitializeMcpSession(); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + } + + [Test] + public async Task Should_list_audit_message_tools() + { + string toolsJson = null; + + await Define() + .Done(async _ => + { + var sessionId = await InitializeAndGetSessionId(); + if (sessionId == null) + { + return false; + } + + var response = await SendMcpRequest(sessionId, "tools/list", new { }); + if (response == null) + { + return false; + } + + toolsJson = await response.Content.ReadAsStringAsync(); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + + Assert.That(toolsJson, Is.Not.Null); + var doc = JsonDocument.Parse(toolsJson); + var result = doc.RootElement.GetProperty("result"); + var tools = result.GetProperty("tools"); + + var toolNames = tools.EnumerateArray() + .Select(t => t.GetProperty("name").GetString()) + .ToList(); + + Assert.That(toolNames, Does.Contain("GetAuditMessages")); + Assert.That(toolNames, Does.Contain("SearchAuditMessages")); + Assert.That(toolNames, Does.Contain("GetAuditMessagesByEndpoint")); + Assert.That(toolNames, Does.Contain("GetAuditMessagesByConversation")); + Assert.That(toolNames, Does.Contain("GetAuditMessageBody")); + Assert.That(toolNames, Does.Contain("GetKnownEndpoints")); + Assert.That(toolNames, Does.Contain("GetEndpointAuditCounts")); + } + + [Test] + public async Task Should_call_get_audit_messages_tool() + { + string toolResult = null; + + var context = await Define() + .WithEndpoint(b => b.When((bus, c) => bus.Send(new MyMessage()))) + .WithEndpoint() + .Done(async c => + { + if (c.MessageId == null) + { + return false; + } + + // Wait for the message to be ingested + if (!await this.TryGetMany("/api/messages?include_system_messages=false&sort=id", m => m.MessageId == c.MessageId)) + { + return false; + } + + var sessionId = await InitializeAndGetSessionId(); + if (sessionId == null) + { + return false; + } + + var response = await SendMcpRequest(sessionId, "tools/call", new + { + name = "GetAuditMessages", + arguments = new { includeSystemMessages = false, page = 1, perPage = 50 } + }); + + if (response == null || response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + toolResult = await response.Content.ReadAsStringAsync(); + return true; + }) + .Run(); + + Assert.That(toolResult, Is.Not.Null); + var doc = JsonDocument.Parse(toolResult); + var result = doc.RootElement.GetProperty("result"); + var content = result.GetProperty("content"); + var textContent = content.EnumerateArray().First().GetProperty("text").GetString(); + var messagesResult = JsonDocument.Parse(textContent); + Assert.That(messagesResult.RootElement.GetProperty("totalCount").GetInt32(), Is.GreaterThanOrEqualTo(1)); + } + + async Task InitializeMcpSession() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "test-client", version = "1.0" } + } + }) + }; + return await HttpClient.SendAsync(request); + } + + async Task InitializeAndGetSessionId() + { + var response = await InitializeMcpSession(); + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + if (response.Headers.TryGetValues("mcp-session-id", out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + async Task SendMcpRequest(string sessionId, string method, object @params) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 2, + method, + @params + }) + }; + request.Headers.Add("mcp-session-id", sessionId); + return await HttpClient.SendAsync(request); + } + + public class Sender : EndpointConfigurationBuilder + { + public Sender() => + EndpointSetup(c => + { + var routing = c.ConfigureRouting(); + routing.RouteToEndpoint(typeof(MyMessage), typeof(Receiver)); + }); + } + + public class Receiver : EndpointConfigurationBuilder + { + public Receiver() => EndpointSetup(); + + public class MyMessageHandler(MyContext testContext, IReadOnlySettings settings) : IHandleMessages + { + public Task Handle(MyMessage message, IMessageHandlerContext context) + { + testContext.EndpointNameOfReceivingEndpoint = settings.EndpointName(); + testContext.MessageId = context.MessageId; + return Task.CompletedTask; + } + } + } + + public class MyMessage : ICommand; + + public class MyContext : ScenarioContext + { + public string MessageId { get; set; } + public string EndpointNameOfReceivingEndpoint { get; set; } + } +} diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs new file mode 100644 index 0000000000..f47daaf176 --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs @@ -0,0 +1,188 @@ +#nullable enable + +namespace ServiceControl.Audit.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Audit.Auditing; +using Audit.Auditing.MessagesView; +using Audit.Infrastructure; +using Audit.Mcp; +using Audit.Monitoring; +using Audit.Persistence; +using NUnit.Framework; +using ServiceControl.SagaAudit; + +[TestFixture] +class AuditMessageMcpToolsTests +{ + StubAuditDataStore store = null!; + AuditMessageTools tools = null!; + + [SetUp] + public void SetUp() + { + store = new StubAuditDataStore(); + tools = new AuditMessageTools(store); + } + + [Test] + public async Task GetAuditMessages_returns_messages() + { + store.MessagesResult = new QueryResult>( + [new() { MessageId = "msg-1", MessageType = "MyNamespace.MyMessage" }], + new QueryStatsInfo("etag", 1)); + + var result = await tools.GetAuditMessages(); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); + Assert.That(doc.RootElement.GetProperty("results").GetArrayLength(), Is.EqualTo(1)); + } + + [Test] + public async Task GetAuditMessages_passes_paging_and_sort_parameters() + { + await tools.GetAuditMessages(page: 2, perPage: 25, sort: "processed_at", direction: "asc"); + + Assert.That(store.LastGetMessagesArgs, Is.Not.Null); + Assert.That(store.LastGetMessagesArgs!.Value.PagingInfo.Page, Is.EqualTo(2)); + Assert.That(store.LastGetMessagesArgs!.Value.PagingInfo.PageSize, Is.EqualTo(25)); + Assert.That(store.LastGetMessagesArgs!.Value.SortInfo.Sort, Is.EqualTo("processed_at")); + Assert.That(store.LastGetMessagesArgs!.Value.SortInfo.Direction, Is.EqualTo("asc")); + } + + [Test] + public async Task SearchAuditMessages_passes_query() + { + await tools.SearchAuditMessages("OrderPlaced"); + + Assert.That(store.LastQueryMessagesSearchParam, Is.EqualTo("OrderPlaced")); + } + + [Test] + public async Task GetAuditMessagesByEndpoint_queries_by_endpoint() + { + await tools.GetAuditMessagesByEndpoint("Sales"); + + Assert.That(store.LastQueryByEndpointName, Is.EqualTo("Sales")); + Assert.That(store.LastQueryByEndpointKeyword, Is.Null); + } + + [Test] + public async Task GetAuditMessagesByEndpoint_with_keyword_uses_keyword_query() + { + await tools.GetAuditMessagesByEndpoint("Sales", keyword: "OrderPlaced"); + + Assert.That(store.LastQueryByEndpointAndKeywordEndpoint, Is.EqualTo("Sales")); + Assert.That(store.LastQueryByEndpointAndKeywordKeyword, Is.EqualTo("OrderPlaced")); + } + + [Test] + public async Task GetAuditMessagesByConversation_queries_by_conversation_id() + { + await tools.GetAuditMessagesByConversation("conv-123"); + + Assert.That(store.LastConversationId, Is.EqualTo("conv-123")); + } + + [Test] + public async Task GetAuditMessageBody_returns_body_content() + { + store.MessageBodyResult = MessageBodyView.FromString("{\"orderId\": 123}", "application/json", 16, "etag"); + + var result = await tools.GetAuditMessageBody("msg-1"); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("contentType").GetString(), Is.EqualTo("application/json")); + Assert.That(doc.RootElement.GetProperty("body").GetString(), Is.EqualTo("{\"orderId\": 123}")); + } + + [Test] + public async Task GetAuditMessageBody_returns_error_when_not_found() + { + store.MessageBodyResult = MessageBodyView.NotFound(); + + var result = await tools.GetAuditMessageBody("msg-missing"); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("error").GetString(), Does.Contain("not found")); + } + + [Test] + public async Task GetAuditMessageBody_returns_error_when_no_content() + { + store.MessageBodyResult = MessageBodyView.NoContent(); + + var result = await tools.GetAuditMessageBody("msg-empty"); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("error").GetString(), Does.Contain("no body content")); + } + + class StubAuditDataStore : IAuditDataStore + { + static readonly QueryResult> EmptyMessagesResult = new([], QueryStatsInfo.Zero); + static readonly QueryResult> EmptyEndpointsResult = new([], QueryStatsInfo.Zero); + static readonly QueryResult> EmptyAuditCountsResult = new([], QueryStatsInfo.Zero); + + public QueryResult>? MessagesResult { get; set; } + public MessageBodyView MessageBodyResult { get; set; } = MessageBodyView.NotFound(); + + // Captured arguments + public (bool IncludeSystemMessages, PagingInfo PagingInfo, SortInfo SortInfo, DateTimeRange? TimeSentRange)? LastGetMessagesArgs { get; private set; } + public string? LastQueryMessagesSearchParam { get; private set; } + public string? LastQueryByEndpointName { get; private set; } + public string? LastQueryByEndpointKeyword { get; private set; } + public string? LastQueryByEndpointAndKeywordEndpoint { get; private set; } + public string? LastQueryByEndpointAndKeywordKeyword { get; private set; } + public string? LastConversationId { get; private set; } + + public Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + { + LastGetMessagesArgs = (includeSystemMessages, pagingInfo, sortInfo, timeSentRange); + return Task.FromResult(MessagesResult ?? EmptyMessagesResult); + } + + public Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + { + LastQueryMessagesSearchParam = searchParam; + return Task.FromResult(MessagesResult ?? EmptyMessagesResult); + } + + public Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + { + LastQueryByEndpointName = endpointName; + LastQueryByEndpointKeyword = null; + return Task.FromResult(MessagesResult ?? EmptyMessagesResult); + } + + public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + { + LastQueryByEndpointAndKeywordEndpoint = endpoint; + LastQueryByEndpointAndKeywordKeyword = keyword; + return Task.FromResult(MessagesResult ?? EmptyMessagesResult); + } + + public Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken) + { + LastConversationId = conversationId; + return Task.FromResult(MessagesResult ?? EmptyMessagesResult); + } + + public Task GetMessageBody(string messageId, CancellationToken cancellationToken) + => Task.FromResult(MessageBodyResult); + + public Task>> QueryKnownEndpoints(CancellationToken cancellationToken) + => Task.FromResult(EmptyEndpointsResult); + + public Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken) + => Task.FromResult(QueryResult.Empty()); + + public Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken) + => Task.FromResult(EmptyAuditCountsResult); + } +} diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs new file mode 100644 index 0000000000..0b3951ce97 --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs @@ -0,0 +1,96 @@ +#nullable enable + +namespace ServiceControl.Audit.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Audit.Auditing; +using Audit.Auditing.MessagesView; +using Audit.Infrastructure; +using Audit.Mcp; +using Audit.Monitoring; +using Audit.Persistence; +using NUnit.Framework; +using ServiceControl.SagaAudit; + +[TestFixture] +class EndpointMcpToolsTests +{ + StubAuditDataStore store = null!; + EndpointTools tools = null!; + + [SetUp] + public void SetUp() + { + store = new StubAuditDataStore(); + tools = new EndpointTools(store); + } + + [Test] + public async Task GetKnownEndpoints_returns_endpoints() + { + store.KnownEndpointsResult = new QueryResult>( + [new() { EndpointDetails = new EndpointDetails { Name = "Sales", Host = "server1" } }], + new QueryStatsInfo("etag", 1)); + + var result = await tools.GetKnownEndpoints(); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); + Assert.That(doc.RootElement.GetProperty("results").GetArrayLength(), Is.EqualTo(1)); + } + + [Test] + public async Task GetEndpointAuditCounts_returns_counts() + { + store.AuditCountsResult = new QueryResult>( + [new() { UtcDate = DateTime.UtcNow.Date, Count = 42 }], + new QueryStatsInfo("etag", 1)); + + var result = await tools.GetEndpointAuditCounts("Sales"); + var doc = JsonDocument.Parse(result); + + Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); + Assert.That(store.LastAuditCountsEndpointName, Is.EqualTo("Sales")); + } + + class StubAuditDataStore : IAuditDataStore + { + public QueryResult>? KnownEndpointsResult { get; set; } + public QueryResult>? AuditCountsResult { get; set; } + public string? LastAuditCountsEndpointName { get; private set; } + + public Task>> QueryKnownEndpoints(CancellationToken cancellationToken) + => Task.FromResult(KnownEndpointsResult ?? new QueryResult>([], QueryStatsInfo.Zero)); + + public Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken) + { + LastAuditCountsEndpointName = endpointName; + return Task.FromResult(AuditCountsResult ?? new QueryResult>([], QueryStatsInfo.Zero)); + } + + public Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task GetMessageBody(string messageId, CancellationToken cancellationToken) + => Task.FromResult(MessageBodyView.NotFound()); + + public Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken) + => Task.FromResult(QueryResult.Empty()); + } +} From fbc8a008b28264ed504d05b45c8d8074ac34a7a1 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 10:03:23 +0200 Subject: [PATCH 14/29] Move to approvals --- ...ntrol.Audit.AcceptanceTests.RavenDB.csproj | 1 + ...ould_list_audit_message_tools.approved.txt | 266 ++++++++++++++++++ .../Mcp/When_mcp_server_is_enabled.cs | 42 ++- ...erviceControl.Audit.AcceptanceTests.csproj | 1 + 4 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj index 223e117c20..b47764e74c 100644 --- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ServiceControl.Audit.AcceptanceTests.RavenDB.csproj @@ -20,6 +20,7 @@ + diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt new file mode 100644 index 0000000000..635537c09e --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -0,0 +1,266 @@ +[ + { + "name": "search_audit_messages", + "description": "Search audit messages by a keyword or phrase. Searches across message content and metadata.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "description": "The search query string", + "type": "string" + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "query" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages_by_conversation", + "description": "Get all audit messages that belong to a specific conversation. A conversation groups related messages that were triggered by the same initial message.", + "inputSchema": { + "type": "object", + "properties": { + "conversationId": { + "description": "The conversation ID to filter by", + "type": "string" + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + } + }, + "required": [ + "conversationId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_message_body", + "description": "Get the body content of a specific audit message by its message ID.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "description": "The message ID", + "type": "string" + } + }, + "required": [ + "messageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages_by_endpoint", + "description": "Get audit messages received by a specific endpoint. Can optionally filter by keyword.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the receiving endpoint", + "type": "string" + }, + "keyword": { + "description": "Optional keyword to filter messages", + "type": [ + "string", + "null" + ], + "default": null + }, + "includeSystemMessages": { + "description": "Whether to include system messages in results. Default is false", + "type": "boolean", + "default": false + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_endpoint_audit_counts", + "description": "Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the endpoint", + "type": "string" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_known_endpoints", + "description": "Get a list of all known endpoints that have sent or received audit messages.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages", + "description": "Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.", + "inputSchema": { + "type": "object", + "properties": { + "includeSystemMessages": { + "description": "Whether to include system messages in results. Default is false", + "type": "boolean", + "default": false + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "execution": { + "taskSupport": "optional" + } + } +] \ No newline at end of file diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 8e30222545..169d24eca7 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -14,6 +14,7 @@ namespace ServiceControl.Audit.AcceptanceTests.Mcp; using NServiceBus.AcceptanceTesting.Customization; using NServiceBus.Settings; using NUnit.Framework; +using Particular.Approvals; class When_mcp_server_is_enabled : AcceptanceTest { @@ -52,7 +53,7 @@ await Define() return false; } - toolsJson = await response.Content.ReadAsStringAsync(); + toolsJson = await ReadMcpResponseJson(response); return response.StatusCode == HttpStatusCode.OK; }) .Run(); @@ -61,18 +62,8 @@ await Define() var doc = JsonDocument.Parse(toolsJson); var result = doc.RootElement.GetProperty("result"); var tools = result.GetProperty("tools"); - - var toolNames = tools.EnumerateArray() - .Select(t => t.GetProperty("name").GetString()) - .ToList(); - - Assert.That(toolNames, Does.Contain("GetAuditMessages")); - Assert.That(toolNames, Does.Contain("SearchAuditMessages")); - Assert.That(toolNames, Does.Contain("GetAuditMessagesByEndpoint")); - Assert.That(toolNames, Does.Contain("GetAuditMessagesByConversation")); - Assert.That(toolNames, Does.Contain("GetAuditMessageBody")); - Assert.That(toolNames, Does.Contain("GetKnownEndpoints")); - Assert.That(toolNames, Does.Contain("GetEndpointAuditCounts")); + var formattedTools = JsonSerializer.Serialize(tools, new JsonSerializerOptions { WriteIndented = true }); + Approver.Verify(formattedTools); } [Test] @@ -113,7 +104,7 @@ public async Task Should_call_get_audit_messages_tool() return false; } - toolResult = await response.Content.ReadAsStringAsync(); + toolResult = await ReadMcpResponseJson(response); return true; }) .Run(); @@ -144,6 +135,8 @@ async Task InitializeMcpSession() } }) }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); return await HttpClient.SendAsync(request); } @@ -163,6 +156,25 @@ async Task InitializeAndGetSessionId() return null; } + static async Task ReadMcpResponseJson(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (contentType == "text/event-stream") + { + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data: ")) + { + return line.Substring("data: ".Length); + } + } + } + + return body; + } + async Task SendMcpRequest(string sessionId, string method, object @params) { var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") @@ -175,6 +187,8 @@ async Task SendMcpRequest(string sessionId, string method, @params }) }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); request.Headers.Add("mcp-session-id", sessionId); return await HttpClient.SendAsync(request); } diff --git a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj index 2bbf59bc45..231136d080 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj +++ b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj @@ -19,6 +19,7 @@ + From 481de0d32a361af2013968f875317e975d37fb22 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 10:26:56 +0200 Subject: [PATCH 15/29] Move tests to use POCOs --- ...ould_list_audit_message_tools.approved.txt | 266 ++++++++++++++++++ .../Mcp/When_mcp_server_is_enabled.cs | 51 +++- .../Mcp/AuditMessageMcpToolsTests.cs | 40 ++- .../Mcp/EndpointMcpToolsTests.cs | 19 +- 4 files changed, 350 insertions(+), 26 deletions(-) create mode 100644 src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt new file mode 100644 index 0000000000..635537c09e --- /dev/null +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -0,0 +1,266 @@ +[ + { + "name": "search_audit_messages", + "description": "Search audit messages by a keyword or phrase. Searches across message content and metadata.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "description": "The search query string", + "type": "string" + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "query" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages_by_conversation", + "description": "Get all audit messages that belong to a specific conversation. A conversation groups related messages that were triggered by the same initial message.", + "inputSchema": { + "type": "object", + "properties": { + "conversationId": { + "description": "The conversation ID to filter by", + "type": "string" + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + } + }, + "required": [ + "conversationId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_message_body", + "description": "Get the body content of a specific audit message by its message ID.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "description": "The message ID", + "type": "string" + } + }, + "required": [ + "messageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages_by_endpoint", + "description": "Get audit messages received by a specific endpoint. Can optionally filter by keyword.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the receiving endpoint", + "type": "string" + }, + "keyword": { + "description": "Optional keyword to filter messages", + "type": [ + "string", + "null" + ], + "default": null + }, + "includeSystemMessages": { + "description": "Whether to include system messages in results. Default is false", + "type": "boolean", + "default": false + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_endpoint_audit_counts", + "description": "Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the endpoint", + "type": "string" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_known_endpoints", + "description": "Get a list of all known endpoints that have sent or received audit messages.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages", + "description": "Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.", + "inputSchema": { + "type": "object", + "properties": { + "includeSystemMessages": { + "description": "Whether to include system messages in results. Default is false", + "type": "boolean", + "default": false + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "type": "string", + "default": "time_sent" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + }, + "timeSentFrom": { + "description": "Filter by time sent start (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "timeSentTo": { + "description": "Filter by time sent end (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "execution": { + "taskSupport": "optional" + } + } +] \ No newline at end of file diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 169d24eca7..aaa176077f 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Audit.AcceptanceTests.Mcp; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -59,10 +60,8 @@ await Define() .Run(); Assert.That(toolsJson, Is.Not.Null); - var doc = JsonDocument.Parse(toolsJson); - var result = doc.RootElement.GetProperty("result"); - var tools = result.GetProperty("tools"); - var formattedTools = JsonSerializer.Serialize(tools, new JsonSerializerOptions { WriteIndented = true }); + var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + var formattedTools = JsonSerializer.Serialize(mcpResponse.Result.Tools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Approver.Verify(formattedTools); } @@ -95,7 +94,7 @@ public async Task Should_call_get_audit_messages_tool() var response = await SendMcpRequest(sessionId, "tools/call", new { - name = "GetAuditMessages", + name = "get_audit_messages", arguments = new { includeSystemMessages = false, page = 1, perPage = 50 } }); @@ -110,12 +109,42 @@ public async Task Should_call_get_audit_messages_tool() .Run(); Assert.That(toolResult, Is.Not.Null); - var doc = JsonDocument.Parse(toolResult); - var result = doc.RootElement.GetProperty("result"); - var content = result.GetProperty("content"); - var textContent = content.EnumerateArray().First().GetProperty("text").GetString(); - var messagesResult = JsonDocument.Parse(textContent); - Assert.That(messagesResult.RootElement.GetProperty("totalCount").GetInt32(), Is.GreaterThanOrEqualTo(1)); + var mcpResponse = JsonSerializer.Deserialize(toolResult, JsonOptions)!; + var textContent = mcpResponse.Result.Content[0].Text; + var messagesResult = JsonSerializer.Deserialize(textContent, JsonOptions)!; + Assert.That(messagesResult.TotalCount, Is.GreaterThanOrEqualTo(1)); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpListToolsResponse + { + public McpListToolsResult Result { get; set; } + } + + class McpListToolsResult + { + public List Tools { get; set; } = []; + } + + class McpCallToolResponse + { + public McpCallToolResult Result { get; set; } + } + + class McpCallToolResult + { + public List Content { get; set; } = []; + } + + class McpContent + { + public string Text { get; set; } + } + + class McpToolResult + { + public int TotalCount { get; set; } } async Task InitializeMcpSession() diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs index f47daaf176..3bc8c18cc8 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs @@ -37,10 +37,10 @@ public async Task GetAuditMessages_returns_messages() new QueryStatsInfo("etag", 1)); var result = await tools.GetAuditMessages(); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); - Assert.That(doc.RootElement.GetProperty("results").GetArrayLength(), Is.EqualTo(1)); + Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(response.Results, Has.Count.EqualTo(1)); } [Test] @@ -95,10 +95,10 @@ public async Task GetAuditMessageBody_returns_body_content() store.MessageBodyResult = MessageBodyView.FromString("{\"orderId\": 123}", "application/json", 16, "etag"); var result = await tools.GetAuditMessageBody("msg-1"); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("contentType").GetString(), Is.EqualTo("application/json")); - Assert.That(doc.RootElement.GetProperty("body").GetString(), Is.EqualTo("{\"orderId\": 123}")); + Assert.That(response.ContentType, Is.EqualTo("application/json")); + Assert.That(response.Body, Is.EqualTo("{\"orderId\": 123}")); } [Test] @@ -107,9 +107,9 @@ public async Task GetAuditMessageBody_returns_error_when_not_found() store.MessageBodyResult = MessageBodyView.NotFound(); var result = await tools.GetAuditMessageBody("msg-missing"); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("error").GetString(), Does.Contain("not found")); + Assert.That(response.Error, Does.Contain("not found")); } [Test] @@ -118,9 +118,29 @@ public async Task GetAuditMessageBody_returns_error_when_no_content() store.MessageBodyResult = MessageBodyView.NoContent(); var result = await tools.GetAuditMessageBody("msg-empty"); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("error").GetString(), Does.Contain("no body content")); + Assert.That(response.Error, Does.Contain("no body content")); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpToolResponse + { + public int TotalCount { get; set; } + public List Results { get; set; } = []; + } + + class McpMessageBodyResponse + { + public string? ContentType { get; set; } + public int ContentLength { get; set; } + public string? Body { get; set; } + } + + class McpErrorResponse + { + public string? Error { get; set; } } class StubAuditDataStore : IAuditDataStore diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs index 0b3951ce97..567528290a 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs @@ -16,6 +16,7 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using NUnit.Framework; using ServiceControl.SagaAudit; + [TestFixture] class EndpointMcpToolsTests { @@ -37,10 +38,10 @@ public async Task GetKnownEndpoints_returns_endpoints() new QueryStatsInfo("etag", 1)); var result = await tools.GetKnownEndpoints(); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); - Assert.That(doc.RootElement.GetProperty("results").GetArrayLength(), Is.EqualTo(1)); + Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(response.Results, Has.Count.EqualTo(1)); } [Test] @@ -51,12 +52,20 @@ public async Task GetEndpointAuditCounts_returns_counts() new QueryStatsInfo("etag", 1)); var result = await tools.GetEndpointAuditCounts("Sales"); - var doc = JsonDocument.Parse(result); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(doc.RootElement.GetProperty("totalCount").GetInt32(), Is.EqualTo(1)); + Assert.That(response.TotalCount, Is.EqualTo(1)); Assert.That(store.LastAuditCountsEndpointName, Is.EqualTo("Sales")); } + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpToolResponse + { + public int TotalCount { get; set; } + public List Results { get; set; } = []; + } + class StubAuditDataStore : IAuditDataStore { public QueryResult>? KnownEndpointsResult { get; set; } From 7e014b577990d38aa53001cd11bd75c77ff9d23c Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 10:33:13 +0200 Subject: [PATCH 16/29] Move packages to be in alphabetical order --- .../ServiceControl.Audit.AcceptanceTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj index 231136d080..96b63d8264 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj +++ b/src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj @@ -19,8 +19,8 @@ - + \ No newline at end of file From 9bf1cbc01db11f33e5bb23c07b2f1abbd59c0504 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 11:05:08 +0200 Subject: [PATCH 17/29] Add unit tests for MCP --- .../Mcp/ArchiveMcpToolsTests.cs | 153 ++++++++++++ .../Mcp/FailedMessageMcpToolsTests.cs | 229 ++++++++++++++++++ .../Mcp/FailureGroupMcpToolsTests.cs | 108 +++++++++ .../Mcp/RetryMcpToolsTests.cs | 121 +++++++++ 4 files changed, 611 insertions(+) create mode 100644 src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs create mode 100644 src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs create mode 100644 src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs create mode 100644 src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs diff --git a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs new file mode 100644 index 0000000000..5fa584043f --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs @@ -0,0 +1,153 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Mcp; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +[TestFixture] +class ArchiveMcpToolsTests +{ + TestableMessageSession messageSession = null!; + StubArchiveMessages archiver = null!; + ArchiveTools tools = null!; + + [SetUp] + public void SetUp() + { + messageSession = new TestableMessageSession(); + archiver = new StubArchiveMessages(); + tools = new ArchiveTools(messageSession, archiver); + } + + [Test] + public async Task ArchiveFailedMessage_returns_accepted() + { + var result = await tools.ArchiveFailedMessage("msg-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task ArchiveFailedMessages_returns_accepted() + { + var result = await tools.ArchiveFailedMessages(["msg-1", "msg-2"]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(2)); + } + + [Test] + public async Task ArchiveFailedMessages_rejects_empty_ids() + { + var result = await tools.ArchiveFailedMessages(["msg-1", ""]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Error, Does.Contain("non-empty")); + } + + [Test] + public async Task ArchiveFailureGroup_returns_accepted() + { + var result = await tools.ArchiveFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + } + + [Test] + public async Task ArchiveFailureGroup_returns_in_progress_when_already_running() + { + archiver.OperationInProgress = true; + + var result = await tools.ArchiveFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("InProgress")); + } + + [Test] + public async Task UnarchiveFailedMessage_returns_accepted() + { + var result = await tools.UnarchiveFailedMessage("msg-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task UnarchiveFailedMessages_returns_accepted() + { + var result = await tools.UnarchiveFailedMessages(["msg-1", "msg-2"]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + } + + [Test] + public async Task UnarchiveFailedMessages_rejects_empty_ids() + { + var result = await tools.UnarchiveFailedMessages(["msg-1", ""]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Error, Does.Contain("non-empty")); + } + + [Test] + public async Task UnarchiveFailureGroup_returns_accepted() + { + var result = await tools.UnarchiveFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + } + + [Test] + public async Task UnarchiveFailureGroup_returns_in_progress_when_already_running() + { + archiver.OperationInProgress = true; + + var result = await tools.UnarchiveFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("InProgress")); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpStatusResponse + { + public string? Status { get; set; } + public string? Message { get; set; } + } + + class McpErrorResponse + { + public string? Error { get; set; } + } + + class StubArchiveMessages : IArchiveMessages + { + public bool OperationInProgress { get; set; } + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => OperationInProgress; + public bool IsArchiveInProgressFor(string groupId) => OperationInProgress; + public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; + public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) { } + public IEnumerable GetArchivalOperations() => []; + } +} diff --git a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs new file mode 100644 index 0000000000..0a3115e812 --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs @@ -0,0 +1,229 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.CompositeViews.Messages; +using ServiceControl.EventLog; +using ServiceControl.Infrastructure; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Mcp; +using ServiceControl.Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +[TestFixture] +class FailedMessageMcpToolsTests +{ + StubErrorMessageDataStore store = null!; + FailedMessageTools tools = null!; + + [SetUp] + public void SetUp() + { + store = new StubErrorMessageDataStore(); + tools = new FailedMessageTools(store); + } + + [Test] + public async Task GetFailedMessages_returns_messages() + { + store.ErrorGetResult = new QueryResult>( + [new() { Id = "msg-1", MessageType = "MyNamespace.MyMessage", Status = FailedMessageStatus.Unresolved }], + new QueryStatsInfo("etag", 1, false)); + + var result = await tools.GetFailedMessages(); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; + + Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(response.Results, Has.Count.EqualTo(1)); + } + + [Test] + public async Task GetFailedMessages_passes_paging_and_sort_parameters() + { + await tools.GetFailedMessages(page: 3, perPage: 10, sort: "time_sent", direction: "asc"); + + Assert.That(store.LastErrorGetArgs, Is.Not.Null); + Assert.That(store.LastErrorGetArgs!.Value.PagingInfo.Page, Is.EqualTo(3)); + Assert.That(store.LastErrorGetArgs!.Value.PagingInfo.PageSize, Is.EqualTo(10)); + Assert.That(store.LastErrorGetArgs!.Value.SortInfo.Sort, Is.EqualTo("time_sent")); + Assert.That(store.LastErrorGetArgs!.Value.SortInfo.Direction, Is.EqualTo("asc")); + } + + [Test] + public async Task GetFailedMessages_passes_filter_parameters() + { + await tools.GetFailedMessages(status: "unresolved", modified: "2026-01-01", queueAddress: "Sales"); + + Assert.That(store.LastErrorGetArgs!.Value.Status, Is.EqualTo("unresolved")); + Assert.That(store.LastErrorGetArgs!.Value.Modified, Is.EqualTo("2026-01-01")); + Assert.That(store.LastErrorGetArgs!.Value.QueueAddress, Is.EqualTo("Sales")); + } + + [Test] + public async Task GetFailedMessageById_returns_message() + { + store.ErrorByResult = new FailedMessage + { + Id = "msg-1", + UniqueMessageId = "unique-1", + Status = FailedMessageStatus.Unresolved + }; + + var result = await tools.GetFailedMessageById("msg-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.UniqueMessageId, Is.EqualTo("unique-1")); + } + + [Test] + public async Task GetFailedMessageById_returns_error_when_not_found() + { + store.ErrorByResult = null; + + var result = await tools.GetFailedMessageById("msg-missing"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Error, Does.Contain("not found")); + } + + [Test] + public async Task GetFailedMessageLastAttempt_returns_view() + { + store.ErrorLastByResult = new FailedMessageView + { + Id = "msg-1", + MessageType = "MyMessage", + Status = FailedMessageStatus.Unresolved + }; + + var result = await tools.GetFailedMessageLastAttempt("msg-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.MessageType, Is.EqualTo("MyMessage")); + } + + [Test] + public async Task GetFailedMessageLastAttempt_returns_error_when_not_found() + { + store.ErrorLastByResult = null; + + var result = await tools.GetFailedMessageLastAttempt("msg-missing"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Error, Does.Contain("not found")); + } + + [Test] + public async Task GetErrorsSummary_returns_summary() + { + store.ErrorsSummaryResult = new Dictionary + { + { "unresolved", 5 }, + { "archived", 3 } + }; + + var result = await tools.GetErrorsSummary(); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; + + Assert.That(response, Contains.Key("unresolved")); + Assert.That(response, Contains.Key("archived")); + } + + [Test] + public async Task GetFailedMessagesByEndpoint_returns_messages() + { + store.ErrorsByEndpointResult = new QueryResult>( + [new() { Id = "msg-1", MessageType = "MyMessage" }], + new QueryStatsInfo("etag", 1, false)); + + var result = await tools.GetFailedMessagesByEndpoint("Sales"); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; + + Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(store.LastErrorsByEndpointName, Is.EqualTo("Sales")); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpToolResponse + { + public int TotalCount { get; set; } + public List Results { get; set; } = []; + } + + class McpErrorResponse + { + public string? Error { get; set; } + } + + class StubErrorMessageDataStore : IErrorMessageDataStore + { + static readonly QueryResult> EmptyResult = new([], QueryStatsInfo.Zero); + + public QueryResult>? ErrorGetResult { get; set; } + public QueryResult>? ErrorsByEndpointResult { get; set; } + public FailedMessage? ErrorByResult { get; set; } + public FailedMessageView? ErrorLastByResult { get; set; } + public IDictionary? ErrorsSummaryResult { get; set; } + + public (string? Status, string? Modified, string? QueueAddress, PagingInfo PagingInfo, SortInfo SortInfo)? LastErrorGetArgs { get; private set; } + public string? LastErrorsByEndpointName { get; private set; } + + public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) + { + LastErrorGetArgs = (status, modified, queueAddress, pagingInfo, sortInfo); + return Task.FromResult(ErrorGetResult ?? EmptyResult); + } + + public Task ErrorBy(string failedMessageId) => Task.FromResult(ErrorByResult)!; + + public Task ErrorLastBy(string failedMessageId) => Task.FromResult(ErrorLastByResult)!; + + public Task> ErrorsSummary() => Task.FromResult(ErrorsSummaryResult ?? new Dictionary()); + + public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo) + { + LastErrorsByEndpointName = endpointName; + return Task.FromResult(ErrorsByEndpointResult ?? EmptyResult); + } + + // Unused interface members + public Task ErrorsHead(string status, string modified, string queueAddress) => throw new NotImplementedException(); + public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) => throw new NotImplementedException(); + public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task FailedMessageMarkAsArchived(string failedMessageId) => throw new NotImplementedException(); + public Task FailedMessagesFetch(Guid[] ids) => throw new NotImplementedException(); + public Task StoreFailedErrorImport(FailedErrorImport failure) => throw new NotImplementedException(); + public Task CreateEditFailedMessageManager() => throw new NotImplementedException(); + public Task> GetFailureGroupView(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task> GetFailureGroupsByClassifier(string classifier) => throw new NotImplementedException(); + public Task EditComment(string groupId, string comment) => throw new NotImplementedException(); + public Task DeleteComment(string groupId) => throw new NotImplementedException(); + public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) => throw new NotImplementedException(); + public Task GetGroupErrorsCount(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task>> GetGroup(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task MarkMessageAsResolved(string failedMessageId) => throw new NotImplementedException(); + public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, Func processCallback) => throw new NotImplementedException(); + public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => throw new NotImplementedException(); + public Task UnArchiveMessages(IEnumerable failedMessageIds) => throw new NotImplementedException(); + public Task RevertRetry(string messageUniqueId) => throw new NotImplementedException(); + public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) => throw new NotImplementedException(); + public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) => throw new NotImplementedException(); + public Task FetchFromFailedMessage(string uniqueMessageId) => throw new NotImplementedException(); + public Task StoreEventLogItem(EventLogItem logItem) => throw new NotImplementedException(); + public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => throw new NotImplementedException(); + public Task CreateNotificationsManager() => throw new NotImplementedException(); + } +} diff --git a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs new file mode 100644 index 0000000000..44e8fc131f --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs @@ -0,0 +1,108 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using ServiceControl.Mcp; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; +using ServiceControl.UnitTests.Operations; + +[TestFixture] +class FailureGroupMcpToolsTests +{ + StubGroupsDataStore groupsStore = null!; + StubRetryHistoryDataStore retryStore = null!; + FailureGroupTools tools = null!; + + [SetUp] + public void SetUp() + { + groupsStore = new StubGroupsDataStore(); + retryStore = new StubRetryHistoryDataStore(); + var domainEvents = new FakeDomainEvents(); + var retryingManager = new RetryingManager(domainEvents, NullLogger.Instance); + var archiver = new StubArchiveMessages(); + var fetcher = new GroupFetcher(groupsStore, retryStore, retryingManager, archiver); + tools = new FailureGroupTools(fetcher, retryStore); + } + + [Test] + public async Task GetFailureGroups_returns_groups() + { + groupsStore.FailureGroups = + [ + new FailureGroupView { Id = "group-1", Title = "NullReferenceException", Type = "Exception Type and Stack Trace", Count = 5, First = DateTime.UtcNow.AddHours(-1), Last = DateTime.UtcNow } + ]; + + var result = await tools.GetFailureGroups(); + var response = JsonSerializer.Deserialize>(result, JsonOptions)!; + + Assert.That(response, Has.Count.EqualTo(1)); + Assert.That(response[0].Id, Is.EqualTo("group-1")); + Assert.That(response[0].Count, Is.EqualTo(5)); + } + + [Test] + public async Task GetFailureGroups_passes_classifier() + { + await tools.GetFailureGroups(classifier: "Message Type"); + + Assert.That(groupsStore.LastClassifier, Is.EqualTo("Message Type")); + } + + [Test] + public async Task GetRetryHistory_returns_history() + { + retryStore.RetryHistoryResult = RetryHistory.CreateNew(); + + var result = await tools.GetRetryHistory(); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.HistoricOperations, Is.Empty); + Assert.That(response.UnacknowledgedOperations, Is.Empty); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class StubGroupsDataStore : IGroupsDataStore + { + public IList FailureGroups { get; set; } = []; + public string? LastClassifier { get; private set; } + + public Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter) + { + LastClassifier = classifier; + return Task.FromResult(FailureGroups); + } + + public Task GetCurrentForwardingBatch() => Task.FromResult(null!); + } + + class StubRetryHistoryDataStore : IRetryHistoryDataStore + { + public RetryHistory? RetryHistoryResult { get; set; } + + public Task GetRetryHistory() => Task.FromResult(RetryHistoryResult ?? RetryHistory.CreateNew()); + public Task AcknowledgeRetryGroup(string groupId) => Task.FromResult(true); + public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, DateTime completionTime, string originator, string classifier, bool messageFailed, int numberOfMessagesProcessed, DateTime lastProcessed, int retryHistoryDepth) => Task.CompletedTask; + } + + class StubArchiveMessages : IArchiveMessages + { + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; + public bool IsArchiveInProgressFor(string groupId) => false; + public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; + public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) { } + public IEnumerable GetArchivalOperations() => []; + } +} diff --git a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs new file mode 100644 index 0000000000..e6e6a3e1ef --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs @@ -0,0 +1,121 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Mcp; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; +using ServiceControl.UnitTests.Operations; + +[TestFixture] +class RetryMcpToolsTests +{ + TestableMessageSession messageSession = null!; + RetryingManager retryingManager = null!; + RetryTools tools = null!; + + [SetUp] + public void SetUp() + { + messageSession = new TestableMessageSession(); + retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); + tools = new RetryTools(messageSession, retryingManager); + } + + [Test] + public async Task RetryFailedMessage_returns_accepted() + { + var result = await tools.RetryFailedMessage("msg-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task RetryFailedMessages_returns_accepted() + { + var result = await tools.RetryFailedMessages(["msg-1", "msg-2"]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task RetryFailedMessages_rejects_empty_ids() + { + var result = await tools.RetryFailedMessages(["msg-1", ""]); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Error, Does.Contain("non-empty")); + } + + [Test] + public async Task RetryFailedMessagesByQueue_returns_accepted() + { + var result = await tools.RetryFailedMessagesByQueue("Sales@machine"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task RetryAllFailedMessages_returns_accepted() + { + var result = await tools.RetryAllFailedMessages(); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); + } + + [Test] + public async Task RetryAllFailedMessagesByEndpoint_returns_accepted() + { + var result = await tools.RetryAllFailedMessagesByEndpoint("Sales"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + } + + [Test] + public async Task RetryFailureGroup_returns_accepted() + { + var result = await tools.RetryFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("Accepted")); + } + + [Test] + public async Task RetryFailureGroup_returns_in_progress_when_already_running() + { + await retryingManager.Wait("group-1", RetryType.FailureGroup, System.DateTime.UtcNow); + await retryingManager.Preparing("group-1", RetryType.FailureGroup, 1); + + var result = await tools.RetryFailureGroup("group-1"); + var response = JsonSerializer.Deserialize(result, JsonOptions)!; + + Assert.That(response.Status, Is.EqualTo("InProgress")); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpStatusResponse + { + public string? Status { get; set; } + public string? Message { get; set; } + } + + class McpErrorResponse + { + public string? Error { get; set; } + } +} From fe4d19af0686de02f6ca2f0a9ec528c50a999924 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 11:14:51 +0200 Subject: [PATCH 18/29] Add primary acceptance tests --- ...viceControl.AcceptanceTests.RavenDB.csproj | 1 + ...d_list_primary_instance_tools.approved.txt | 427 ++++++++++++++++++ .../Mcp/When_mcp_server_is_enabled.cs | 150 ++++++ 3 files changed, 578 insertions(+) create mode 100644 src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt create mode 100644 src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj index f8033e858f..1923947295 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj @@ -22,6 +22,7 @@ + diff --git a/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt new file mode 100644 index 0000000000..f6b4c8e1b7 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -0,0 +1,427 @@ +[ + { + "name": "get_failed_messages", + "description": "Get a list of failed messages. Supports filtering by status (unresolved, resolved, archived, retryissued), modified date, and queue address. Returns paged results.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "description": "Filter by status: unresolved, resolved, archived, retryissued", + "type": [ + "string", + "null" + ], + "default": null + }, + "modified": { + "description": "Filter by modified date (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "queueAddress": { + "description": "Filter by queue address", + "type": [ + "string", + "null" + ], + "default": null + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure", + "type": "string", + "default": "time_of_failure" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + } + } + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_failed_message_by_id", + "description": "Get details of a specific failed message by its unique ID.", + "inputSchema": { + "type": "object", + "properties": { + "failedMessageId": { + "description": "The unique ID of the failed message", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_failed_message_last_attempt", + "description": "Get the last processing attempt for a specific failed message.", + "inputSchema": { + "type": "object", + "properties": { + "failedMessageId": { + "description": "The unique ID of the failed message", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_errors_summary", + "description": "Get a summary of error counts grouped by status (unresolved, archived, resolved, retryissued).", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_failed_messages_by_endpoint", + "description": "Get failed messages for a specific endpoint.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the endpoint", + "type": "string" + }, + "status": { + "description": "Filter by status: unresolved, resolved, archived, retryissued", + "type": [ + "string", + "null" + ], + "default": null + }, + "modified": { + "description": "Filter by modified date (ISO 8601 format)", + "type": [ + "string", + "null" + ], + "default": null + }, + "page": { + "description": "Page number (1-based). Default is 1", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Number of results per page. Default is 50", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure", + "type": "string", + "default": "time_of_failure" + }, + "direction": { + "description": "Sort direction: asc or desc. Default is desc", + "type": "string", + "default": "desc" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "archive_failed_message", + "description": "Archive a single failed message by its unique ID. The message will be moved to the archived status.", + "inputSchema": { + "type": "object", + "properties": { + "failedMessageId": { + "description": "The unique ID of the failed message to archive", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "archive_failed_messages", + "description": "Archive multiple failed messages by their unique IDs. All specified messages will be moved to the archived status.", + "inputSchema": { + "type": "object", + "properties": { + "messageIds": { + "description": "Array of unique message IDs to archive", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageIds" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "archive_failure_group", + "description": "Archive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "inputSchema": { + "type": "object", + "properties": { + "groupId": { + "description": "The ID of the failure group to archive", + "type": "string" + } + }, + "required": [ + "groupId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "unarchive_failed_message", + "description": "Unarchive a single failed message by its unique ID. The message will be moved back to the unresolved status.", + "inputSchema": { + "type": "object", + "properties": { + "failedMessageId": { + "description": "The unique ID of the failed message to unarchive", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "unarchive_failed_messages", + "description": "Unarchive multiple failed messages by their unique IDs. All specified messages will be moved back to the unresolved status.", + "inputSchema": { + "type": "object", + "properties": { + "messageIds": { + "description": "Array of unique message IDs to unarchive", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageIds" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "unarchive_failure_group", + "description": "Unarchive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "inputSchema": { + "type": "object", + "properties": { + "groupId": { + "description": "The ID of the failure group to unarchive", + "type": "string" + } + }, + "required": [ + "groupId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_failed_message", + "description": "Retry a single failed message by its unique ID. The message will be sent back to its original queue for reprocessing.", + "inputSchema": { + "type": "object", + "properties": { + "failedMessageId": { + "description": "The unique ID of the failed message to retry", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_failed_messages", + "description": "Retry multiple failed messages by their unique IDs. All specified messages will be sent back to their original queues for reprocessing.", + "inputSchema": { + "type": "object", + "properties": { + "messageIds": { + "description": "Array of unique message IDs to retry", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageIds" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_failed_messages_by_queue", + "description": "Retry all failed messages from a specific queue address.", + "inputSchema": { + "type": "object", + "properties": { + "queueAddress": { + "description": "The queue address to retry all failed messages from", + "type": "string" + } + }, + "required": [ + "queueAddress" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_all_failed_messages", + "description": "Retry all failed messages across all queues. Use with caution as this affects all unresolved failed messages.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_all_failed_messages_by_endpoint", + "description": "Retry all failed messages for a specific endpoint.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The name of the endpoint to retry all failed messages for", + "type": "string" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_failure_group", + "description": "Retry all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "inputSchema": { + "type": "object", + "properties": { + "groupId": { + "description": "The ID of the failure group to retry", + "type": "string" + } + }, + "required": [ + "groupId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_failure_groups", + "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.", + "inputSchema": { + "type": "object", + "properties": { + "classifier": { + "description": "The classifier to group by. Default is \u0027Exception Type and Stack Trace\u0027", + "type": "string", + "default": "Exception Type and Stack Trace" + }, + "classifierFilter": { + "description": "Optional filter for the classifier", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_retry_history", + "description": "Get the retry history showing past retry operations and their outcomes.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + } +] diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs new file mode 100644 index 0000000000..6516e84bc1 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -0,0 +1,150 @@ +namespace ServiceControl.AcceptanceTests.Mcp; + +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using Particular.Approvals; + +[TestFixture] +class When_mcp_server_is_enabled : AcceptanceTest +{ + [SetUp] + public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true; + + [Test] + public async Task Should_expose_mcp_endpoint() + { + await Define() + .Done(async _ => + { + var response = await InitializeMcpSession(); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + } + + [Test] + public async Task Should_list_primary_instance_tools() + { + string toolsJson = null; + + await Define() + .Done(async _ => + { + var sessionId = await InitializeAndGetSessionId(); + if (sessionId == null) + { + return false; + } + + var response = await SendMcpRequest(sessionId, "tools/list", new { }); + if (response == null) + { + return false; + } + + toolsJson = await ReadMcpResponseJson(response); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + + Assert.That(toolsJson, Is.Not.Null); + var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + var formattedTools = JsonSerializer.Serialize(mcpResponse.Result.Tools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + Approver.Verify(formattedTools); + } + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + class McpListToolsResponse + { + public McpListToolsResult Result { get; set; } + } + + class McpListToolsResult + { + public List Tools { get; set; } = []; + } + + async Task InitializeMcpSession() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "test-client", version = "1.0" } + } + }) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + return await HttpClient.SendAsync(request); + } + + async Task InitializeAndGetSessionId() + { + var response = await InitializeMcpSession(); + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + if (response.Headers.TryGetValues("mcp-session-id", out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + static async Task ReadMcpResponseJson(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (contentType == "text/event-stream") + { + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data: ")) + { + return line.Substring("data: ".Length); + } + } + } + + return body; + } + + async Task SendMcpRequest(string sessionId, string method, object @params) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 2, + method, + @params + }) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Headers.Add("mcp-session-id", sessionId); + return await HttpClient.SendAsync(request); + } +} From 0efd5dcd7ddcb43fb227bc675310ce539a74bb0a Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 11:32:09 +0200 Subject: [PATCH 19/29] Update MCP descriptions --- ...d_list_primary_instance_tools.approved.txt | 96 +++++++-------- ...ould_list_audit_message_tools.approved.txt | 76 ++++++------ .../Mcp/AuditMessageTools.cs | 110 ++++++++++++------ src/ServiceControl.Audit/Mcp/EndpointTools.cs | 23 +++- src/ServiceControl/Mcp/ArchiveTools.cs | 65 ++++++++--- src/ServiceControl/Mcp/FailedMessageTools.cs | 79 +++++++++---- src/ServiceControl/Mcp/FailureGroupTools.cs | 27 ++++- src/ServiceControl/Mcp/RetryTools.cs | 60 ++++++++-- 8 files changed, 359 insertions(+), 177 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index f6b4c8e1b7..da2019581d 100644 --- a/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -1,12 +1,12 @@ [ { "name": "get_failed_messages", - "description": "Get a list of failed messages. Supports filtering by status (unresolved, resolved, archived, retryissued), modified date, and queue address. Returns paged results.", + "description": "Use this tool to browse failed messages when the user wants to see what is failing. Good for questions like: \u0027what messages are currently failing?\u0027, \u0027are there failures in a specific queue?\u0027, or \u0027what failed recently?\u0027. Returns a paged list of failed messages with their status, exception details, and queue information. For broad requests, call with no parameters to get the most recent failures \u2014 only add filters when you need to narrow down results. Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint.", "inputSchema": { "type": "object", "properties": { "status": { - "description": "Filter by status: unresolved, resolved, archived, retryissued", + "description": "Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.", "type": [ "string", "null" @@ -14,7 +14,7 @@ "default": null }, "modified": { - "description": "Filter by modified date (ISO 8601 format)", + "description": "Only return messages modified after this date (ISO 8601). Useful for checking recent failures.", "type": [ "string", "null" @@ -22,7 +22,7 @@ "default": null }, "queueAddress": { - "description": "Filter by queue address", + "description": "Only return messages from this queue address, e.g. \u0027Sales@machine\u0027. Use when investigating a specific queue.", "type": [ "string", "null" @@ -30,22 +30,22 @@ "default": null }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure", + "description": "Sort by: time_sent, message_type, or time_of_failure", "type": "string", "default": "time_of_failure" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" } @@ -57,12 +57,12 @@ }, { "name": "get_failed_message_by_id", - "description": "Get details of a specific failed message by its unique ID.", + "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique ID of the failed message", + "description": "The unique message ID from a previous query result", "type": "string" } }, @@ -76,12 +76,12 @@ }, { "name": "get_failed_message_last_attempt", - "description": "Get the last processing attempt for a specific failed message.", + "description": "Use this tool to see how a specific message failed most recently. Good for questions like: \u0027what was the last error for this message?\u0027, \u0027show me the latest exception\u0027, or \u0027what happened on the last attempt?\u0027. Returns the latest processing attempt with its exception, stack trace, and headers. Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique ID of the failed message", + "description": "The unique message ID from a previous query result", "type": "string" } }, @@ -95,7 +95,7 @@ }, { "name": "get_errors_summary", - "description": "Get a summary of error counts grouped by status (unresolved, archived, resolved, retryissued).", + "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", "inputSchema": { "type": "object", "properties": {} @@ -106,16 +106,16 @@ }, { "name": "get_failed_messages_by_endpoint", - "description": "Get failed messages for a specific endpoint.", + "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the endpoint", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" }, "status": { - "description": "Filter by status: unresolved, resolved, archived, retryissued", + "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", "type": [ "string", "null" @@ -123,7 +123,7 @@ "default": null }, "modified": { - "description": "Filter by modified date (ISO 8601 format)", + "description": "Only return messages modified after this date (ISO 8601)", "type": [ "string", "null" @@ -131,22 +131,22 @@ "default": null }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, message_type, time_of_failure. Default is time_of_failure", + "description": "Sort by: time_sent, message_type, or time_of_failure", "type": "string", "default": "time_of_failure" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" } @@ -161,12 +161,12 @@ }, { "name": "archive_failed_message", - "description": "Archive a single failed message by its unique ID. The message will be moved to the archived status.", + "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique ID of the failed message to archive", + "description": "The unique message ID from a previous query result", "type": "string" } }, @@ -180,12 +180,12 @@ }, { "name": "archive_failed_messages", - "description": "Archive multiple failed messages by their unique IDs. All specified messages will be moved to the archived status.", + "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "Array of unique message IDs to archive", + "description": "The unique message IDs from a previous query result", "type": "array", "items": { "type": "string" @@ -202,12 +202,12 @@ }, { "name": "archive_failure_group", - "description": "Archive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The ID of the failure group to archive", + "description": "The failure group ID from get_failure_groups results", "type": "string" } }, @@ -221,12 +221,12 @@ }, { "name": "unarchive_failed_message", - "description": "Unarchive a single failed message by its unique ID. The message will be moved back to the unresolved status.", + "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique ID of the failed message to unarchive", + "description": "The unique message ID to restore", "type": "string" } }, @@ -240,12 +240,12 @@ }, { "name": "unarchive_failed_messages", - "description": "Unarchive multiple failed messages by their unique IDs. All specified messages will be moved back to the unresolved status.", + "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "Array of unique message IDs to unarchive", + "description": "The unique message IDs to restore", "type": "array", "items": { "type": "string" @@ -262,12 +262,12 @@ }, { "name": "unarchive_failure_group", - "description": "Unarchive all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The ID of the failure group to unarchive", + "description": "The failure group ID from get_failure_groups results", "type": "string" } }, @@ -281,12 +281,12 @@ }, { "name": "retry_failed_message", - "description": "Retry a single failed message by its unique ID. The message will be sent back to its original queue for reprocessing.", + "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique ID of the failed message to retry", + "description": "The unique message ID from a previous query result", "type": "string" } }, @@ -300,12 +300,12 @@ }, { "name": "retry_failed_messages", - "description": "Retry multiple failed messages by their unique IDs. All specified messages will be sent back to their original queues for reprocessing.", + "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "Array of unique message IDs to retry", + "description": "The unique message IDs from a previous query result", "type": "array", "items": { "type": "string" @@ -322,12 +322,12 @@ }, { "name": "retry_failed_messages_by_queue", - "description": "Retry all failed messages from a specific queue address.", + "description": "Use this tool to retry all unresolved failed messages from a specific queue. Good for questions like: \u0027retry all failures in the Sales queue\u0027, \u0027reprocess everything from this queue\u0027, or \u0027the queue consumer is back, retry its failures\u0027. Useful when a queue\u0027s consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status.", "inputSchema": { "type": "object", "properties": { "queueAddress": { - "description": "The queue address to retry all failed messages from", + "description": "The full queue address including machine name, e.g. \u0027Sales@machine\u0027", "type": "string" } }, @@ -341,7 +341,7 @@ }, { "name": "retry_all_failed_messages", - "description": "Retry all failed messages across all queues. Use with caution as this affects all unresolved failed messages.", + "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", "inputSchema": { "type": "object", "properties": {} @@ -352,12 +352,12 @@ }, { "name": "retry_all_failed_messages_by_endpoint", - "description": "Retry all failed messages for a specific endpoint.", + "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the endpoint to retry all failed messages for", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" } }, @@ -371,12 +371,12 @@ }, { "name": "retry_failure_group", - "description": "Retry all failed messages in a specific failure group. Failure groups are collections of messages grouped by exception type and stack trace.", + "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The ID of the failure group to retry", + "description": "The failure group ID from get_failure_groups results", "type": "string" } }, @@ -390,17 +390,17 @@ }, { "name": "get_failure_groups", - "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.", + "description": "Use this tool to understand why messages are failing by seeing failures grouped by root cause. Good for questions like: \u0027why are messages failing?\u0027, \u0027what errors are happening?\u0027, \u0027group failures by exception\u0027, or \u0027what are the top failure causes?\u0027. Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. This is usually the best starting point for diagnosing production issues \u2014 call it before drilling into individual messages. Call with no parameters to use the default grouping by exception type and stack trace.", "inputSchema": { "type": "object", "properties": { "classifier": { - "description": "The classifier to group by. Default is \u0027Exception Type and Stack Trace\u0027", + "description": "How to group failures. The default \u0027Exception Type and Stack Trace\u0027 is almost always what you want. Use \u0027Message Type\u0027 to group by the NServiceBus message type instead.", "type": "string", "default": "Exception Type and Stack Trace" }, "classifierFilter": { - "description": "Optional filter for the classifier", + "description": "Only include groups matching this filter text", "type": [ "string", "null" @@ -415,7 +415,7 @@ }, { "name": "get_retry_history", - "description": "Get the retry history showing past retry operations and their outcomes.", + "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", "inputSchema": { "type": "object", "properties": {} diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 635537c09e..b83a49c307 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,36 +1,36 @@ [ { "name": "search_audit_messages", - "description": "Search audit messages by a keyword or phrase. Searches across message content and metadata.", + "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", "inputSchema": { "type": "object", "properties": { "query": { - "description": "The search query string", + "description": "Free-text search query \u2014 matches against message body, headers, and metadata", "type": "string" }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -38,7 +38,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" @@ -56,31 +56,31 @@ }, { "name": "get_audit_messages_by_conversation", - "description": "Get all audit messages that belong to a specific conversation. A conversation groups related messages that were triggered by the same initial message.", + "description": "Use this tool to trace the full chain of messages triggered by an initial message. Good for questions like: \u0027what happened after this message was sent?\u0027, \u0027show me the full message flow\u0027, or \u0027trace this conversation\u0027. A conversation groups all related messages together \u2014 the original command and every event, reply, or saga message it caused. You need a conversation ID, which you can get from any audit message query result. Essential for understanding message flow and debugging cascading issues.", "inputSchema": { "type": "object", "properties": { "conversationId": { - "description": "The conversation ID to filter by", + "description": "The conversation ID from a previous audit message query result", "type": "string" }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" } @@ -95,12 +95,12 @@ }, { "name": "get_audit_message_body", - "description": "Get the body content of a specific audit message by its message ID.", + "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", "inputSchema": { "type": "object", "properties": { "messageId": { - "description": "The message ID", + "description": "The message ID from a previous audit message query result", "type": "string" } }, @@ -114,16 +114,16 @@ }, { "name": "get_audit_messages_by_endpoint", - "description": "Get audit messages received by a specific endpoint. Can optionally filter by keyword.", + "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the receiving endpoint", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" }, "keyword": { - "description": "Optional keyword to filter messages", + "description": "Optional keyword to search within this endpoint\u0027s messages", "type": [ "string", "null" @@ -131,32 +131,32 @@ "default": null }, "includeSystemMessages": { - "description": "Whether to include system messages in results. Default is false", + "description": "Set to true to include NServiceBus infrastructure messages", "type": "boolean", "default": false }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -164,7 +164,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" @@ -182,12 +182,12 @@ }, { "name": "get_endpoint_audit_counts", - "description": "Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.", + "description": "Use this tool to see daily message volume trends for a specific endpoint. Good for questions like: \u0027how much traffic does Sales handle?\u0027, \u0027has throughput changed recently?\u0027, or \u0027show me message counts for this endpoint\u0027. Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. You need an endpoint name \u2014 use GetKnownEndpoints first if you do not have one.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the endpoint", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" } }, @@ -201,7 +201,7 @@ }, { "name": "get_known_endpoints", - "description": "Get a list of all known endpoints that have sent or received audit messages.", + "description": "Use this tool to discover what NServiceBus endpoints exist in the system. Good for questions like: \u0027what endpoints do we have?\u0027, \u0027what services are running?\u0027, or \u0027list all endpoints\u0027. Returns all endpoints that have processed audit messages, including their name and host information. This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts.", "inputSchema": { "type": "object", "properties": {} @@ -212,37 +212,37 @@ }, { "name": "get_audit_messages", - "description": "Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.", + "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", "inputSchema": { "type": "object", "properties": { "includeSystemMessages": { - "description": "Whether to include system messages in results. Default is false", + "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", "type": "boolean", "default": false }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", "type": [ "string", "null" @@ -250,7 +250,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" @@ -263,4 +263,4 @@ "taskSupport": "optional" } } -] \ No newline at end of file +] diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs index b4fddeaa51..c6fd4a3e0d 100644 --- a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs +++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs @@ -10,18 +10,34 @@ namespace ServiceControl.Audit.Mcp; using ModelContextProtocol.Server; using Persistence; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for exploring audit messages.\n\n" + + "Agent guidance:\n" + + "1. For broad requests like 'show recent messages', start with GetAuditMessages using defaults.\n" + + "2. For requests containing a concrete text term, identifier, or phrase, use SearchAuditMessages.\n" + + "3. Keep page=1 unless the user asks for more results.\n" + + "4. Keep perPage modest, such as 20 to 50, unless the user asks for a larger batch.\n" + + "5. Use time filters when the user mentions a date or time window like 'today' or 'last hour'.\n" + + "6. Only change sorting when the user explicitly asks for it." +)] public class AuditMessageTools(IAuditDataStore store) { - [McpServerTool, Description("Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.")] + [McpServerTool, Description( + "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. " + + "Good for questions like: 'show recent audit messages', 'what messages were processed today?', 'list messages from endpoint X', or 'show slow messages'. " + + "Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. " + + "For broad requests, use the default paging and sorting. " + + "Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. " + + "If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead." + )] 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, + [Description("Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.")] bool includeSystemMessages = false, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", + [Description("Sort direction: asc or desc")] string direction = "desc", + [Description("Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.")] string? timeSentFrom = null, + [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { var pagingInfo = new PagingInfo(page, perPage); @@ -37,15 +53,21 @@ public async Task GetAuditMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description("Search audit messages by a keyword or phrase. Searches across message content and metadata.")] + [McpServerTool, Description( + "Use this tool to find audit messages by a keyword or phrase. " + + "Good for questions like: 'find messages containing order 12345', 'search for CustomerCreated messages', or 'look for messages mentioning this ID'. " + + "Searches across message body content, headers, and metadata using full-text search. " + + "Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. " + + "If the user just wants to browse recent messages without a search term, use GetAuditMessages instead." + )] 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, + [Description("Free-text search query — matches against message body, headers, and metadata")] string query, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", + [Description("Sort direction: asc or desc")] string direction = "desc", + [Description("Only return messages sent after this time (ISO 8601)")] string? timeSentFrom = null, + [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { var pagingInfo = new PagingInfo(page, perPage); @@ -61,17 +83,23 @@ public async Task SearchAuditMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description("Get audit messages received by a specific endpoint. Can optionally filter by keyword.")] + [McpServerTool, Description( + "Use this tool to see what messages a specific NServiceBus endpoint has processed. " + + "Good for questions like: 'what messages did Sales process?', 'show messages handled by Shipping', or 'find OrderPlaced messages in the Billing endpoint'. " + + "Returns the same metadata as GetAuditMessages but scoped to one endpoint. " + + "Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. " + + "Optionally pass a keyword to search within that endpoint's messages." + )] 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, + [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, + [Description("Optional keyword to search within this endpoint's messages")] string? keyword = null, + [Description("Set to true to include NServiceBus infrastructure messages")] bool includeSystemMessages = false, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", + [Description("Sort direction: asc or desc")] string direction = "desc", + [Description("Only return messages sent after this time (ISO 8601)")] string? timeSentFrom = null, + [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { var pagingInfo = new PagingInfo(page, perPage); @@ -89,13 +117,19 @@ public async Task GetAuditMessagesByEndpoint( }, 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.")] + [McpServerTool, Description( + "Use this tool to trace the full chain of messages triggered by an initial message. " + + "Good for questions like: 'what happened after this message was sent?', 'show me the full message flow', or 'trace this conversation'. " + + "A conversation groups all related messages together — the original command and every event, reply, or saga message it caused. " + + "You need a conversation ID, which you can get from any audit message query result. " + + "Essential for understanding message flow and debugging cascading issues." + )] 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", + [Description("The conversation ID from a previous audit message query result")] string conversationId, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", + [Description("Sort direction: asc or desc")] string direction = "desc", CancellationToken cancellationToken = default) { var pagingInfo = new PagingInfo(page, perPage); @@ -110,9 +144,15 @@ public async Task GetAuditMessagesByConversation( }, McpJsonOptions.Default); } - [McpServerTool, Description("Get the body content of a specific audit message by its message ID.")] + [McpServerTool, Description( + "Use this tool to inspect the actual payload of a processed message. " + + "Good for questions like: 'show me the message body', 'what data was in this message?', or 'let me see the content of message X'. " + + "Returns the serialized message body content, typically JSON. " + + "You need a message ID, which you can get from any audit message query result. " + + "Use this when the user wants to see what data was actually sent, not just message metadata." + )] public async Task GetAuditMessageBody( - [Description("The message ID")] string messageId, + [Description("The message ID from a previous audit message query result")] string messageId, CancellationToken cancellationToken = default) { var result = await store.GetMessageBody(messageId, cancellationToken); diff --git a/src/ServiceControl.Audit/Mcp/EndpointTools.cs b/src/ServiceControl.Audit/Mcp/EndpointTools.cs index 705a88fbb2..74a4c00ad0 100644 --- a/src/ServiceControl.Audit/Mcp/EndpointTools.cs +++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs @@ -9,10 +9,20 @@ namespace ServiceControl.Audit.Mcp; using ModelContextProtocol.Server; using Persistence; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for discovering and inspecting NServiceBus endpoints.\n\n" + + "Agent guidance:\n" + + "1. Use GetKnownEndpoints to discover endpoint names before calling endpoint-specific tools.\n" + + "2. Use GetEndpointAuditCounts to spot throughput trends, traffic spikes, or drops in activity." +)] public class EndpointTools(IAuditDataStore store) { - [McpServerTool, Description("Get a list of all known endpoints that have sent or received audit messages.")] + [McpServerTool, Description( + "Use this tool to discover what NServiceBus endpoints exist in the system. " + + "Good for questions like: 'what endpoints do we have?', 'what services are running?', or 'list all endpoints'. " + + "Returns all endpoints that have processed audit messages, including their name and host information. " + + "This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts." + )] public async Task GetKnownEndpoints(CancellationToken cancellationToken = default) { var results = await store.QueryKnownEndpoints(cancellationToken); @@ -24,9 +34,14 @@ public async Task GetKnownEndpoints(CancellationToken cancellationToken }, McpJsonOptions.Default); } - [McpServerTool, Description("Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.")] + [McpServerTool, Description( + "Use this tool to see daily message volume trends for a specific endpoint. " + + "Good for questions like: 'how much traffic does Sales handle?', 'has throughput changed recently?', or 'show me message counts for this endpoint'. " + + "Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. " + + "You need an endpoint name — use GetKnownEndpoints first if you do not have one." + )] public async Task GetEndpointAuditCounts( - [Description("The name of the endpoint")] string endpointName, + [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, CancellationToken cancellationToken = default) { var results = await store.QueryAuditCounts(endpointName, cancellationToken); diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs index 86abe21de0..f86b2bc56f 100644 --- a/src/ServiceControl/Mcp/ArchiveTools.cs +++ b/src/ServiceControl/Mcp/ArchiveTools.cs @@ -10,20 +10,38 @@ namespace ServiceControl.Mcp; using Persistence.Recoverability; using ServiceControl.Recoverability; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for archiving and unarchiving failed messages.\n\n" + + "Agent guidance:\n" + + "1. Archiving dismisses a failed message — it moves out of the unresolved list and no longer counts as an active problem.\n" + + "2. Unarchiving restores a previously archived message back to the unresolved list so it can be retried.\n" + + "3. Prefer ArchiveFailureGroup or UnarchiveFailureGroup when acting on an entire failure group — it is more efficient than archiving messages individually.\n" + + "4. Use ArchiveFailedMessages or UnarchiveFailedMessages when you have a specific set of message IDs.\n" + + "5. All operations are asynchronous — they return Accepted immediately and complete in the background." +)] 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.")] + [McpServerTool, Description( + "Use this tool to dismiss a single failed message that does not need to be retried. " + + "Good for questions like: 'archive this message', 'dismiss this failure', or 'I do not need to retry this one'. " + + "Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. " + + "This is an asynchronous operation — the message will be archived shortly after the request is accepted. " + + "If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead." + )] public async Task ArchiveFailedMessage( - [Description("The unique ID of the failed message to archive")] string failedMessageId) + [Description("The unique message ID from a previous query result")] 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.")] + [McpServerTool, Description( + "Use this tool to dismiss multiple failed messages at once that do not need to be retried. " + + "Good for questions like: 'archive these messages', 'dismiss these failures', or 'archive messages msg-1, msg-2, msg-3'. " + + "Prefer ArchiveFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to archive." + )] public async Task ArchiveFailedMessages( - [Description("Array of unique message IDs to archive")] string[] messageIds) + [Description("The unique message IDs from a previous query result")] string[] messageIds) { if (messageIds.Any(string.IsNullOrEmpty)) { @@ -37,9 +55,15 @@ public async Task ArchiveFailedMessages( 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.")] + [McpServerTool, Description( + "Use this tool to dismiss an entire failure group — all messages that failed with the same exception type and stack trace. " + + "Good for questions like: 'archive this failure group', 'dismiss all NullReferenceException failures', or 'archive the whole group'. " + + "This is the most efficient way to archive many related failures at once. " + + "You need a group ID, which you can get from GetFailureGroups. " + + "Returns InProgress if an archive operation is already running for this group." + )] public async Task ArchiveFailureGroup( - [Description("The ID of the failure group to archive")] string groupId) + [Description("The failure group ID from get_failure_groups results")] string groupId) { if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { @@ -52,17 +76,26 @@ public async Task ArchiveFailureGroup( 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.")] + [McpServerTool, Description( + "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. " + + "Good for questions like: 'unarchive this message', 'restore this failure', or 'I need to retry this archived message'. " + + "Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. " + + "If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead." + )] public async Task UnarchiveFailedMessage( - [Description("The unique ID of the failed message to unarchive")] string failedMessageId) + [Description("The unique message ID to restore")] 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.")] + [McpServerTool, Description( + "Use this tool to restore multiple previously archived failed messages back to the unresolved list. " + + "Good for questions like: 'unarchive these messages', 'restore these failures', or 'unarchive messages msg-1, msg-2, msg-3'. " + + "Prefer UnarchiveFailureGroup when restoring an entire group — use this tool when you have a specific set of message IDs." + )] public async Task UnarchiveFailedMessages( - [Description("Array of unique message IDs to unarchive")] string[] messageIds) + [Description("The unique message IDs to restore")] string[] messageIds) { if (messageIds.Any(string.IsNullOrEmpty)) { @@ -73,9 +106,15 @@ public async Task UnarchiveFailedMessages( 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.")] + [McpServerTool, Description( + "Use this tool to restore an entire archived failure group back to the unresolved list. " + + "Good for questions like: 'unarchive this failure group', 'restore all archived NullReferenceException failures', or 'unarchive the whole group'. " + + "All messages that were archived together under this group will become available for retry again. " + + "You need a group ID, which you can get from GetFailureGroups. " + + "Returns InProgress if an unarchive operation is already running for this group." + )] public async Task UnarchiveFailureGroup( - [Description("The ID of the failure group to unarchive")] string groupId) + [Description("The failure group ID from get_failure_groups results")] string groupId) { if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs index 79c6d47e96..4776469a35 100644 --- a/src/ServiceControl/Mcp/FailedMessageTools.cs +++ b/src/ServiceControl/Mcp/FailedMessageTools.cs @@ -10,18 +10,33 @@ namespace ServiceControl.Mcp; using Persistence; using Persistence.Infrastructure; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for investigating failed messages.\n\n" + + "Agent guidance:\n" + + "1. Start with GetErrorsSummary to get a quick health check of failure counts by status.\n" + + "2. Use GetFailureGroups (from FailureGroupTools) to see failures grouped by root cause before drilling into individual messages.\n" + + "3. Use GetFailedMessages for broad listing, or GetFailedMessagesByEndpoint when you already know the endpoint.\n" + + "4. Use GetFailedMessageById for full details including all processing attempts, or GetFailedMessageLastAttempt for just the most recent failure.\n" + + "5. Keep page=1 unless the user asks for more results.\n" + + "6. Only change sorting when the user explicitly asks for it." +)] public class FailedMessageTools(IErrorMessageDataStore store) { - [McpServerTool, Description("Get a list of failed messages. Supports filtering by status (unresolved, resolved, archived, retryissued), modified date, and queue address. Returns paged results.")] + [McpServerTool, Description( + "Use this tool to browse failed messages when the user wants to see what is failing. " + + "Good for questions like: 'what messages are currently failing?', 'are there failures in a specific queue?', or 'what failed recently?'. " + + "Returns a paged list of failed messages with their status, exception details, and queue information. " + + "For broad requests, call with no parameters to get the most recent failures — only add filters when you need to narrow down results. " + + "Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint." + )] 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") + [Description("Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.")] string? status = null, + [Description("Only return messages modified after this date (ISO 8601). Useful for checking recent failures.")] string? modified = null, + [Description("Only return messages from this queue address, e.g. 'Sales@machine'. Use when investigating a specific queue.")] string? queueAddress = null, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", + [Description("Sort direction: asc or desc")] string direction = "desc") { var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); @@ -35,9 +50,14 @@ public async Task GetFailedMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description("Get details of a specific failed message by its unique ID.")] + [McpServerTool, Description( + "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. " + + "Good for questions like: 'show me details for this failed message', 'what exception caused this failure?', or 'how many times has this message failed?'. " + + "You need the message's unique ID, which you can get from GetFailedMessages or GetFailureGroups results. " + + "If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead — it returns less data." + )] public async Task GetFailedMessageById( - [Description("The unique ID of the failed message")] string failedMessageId) + [Description("The unique message ID from a previous query result")] string failedMessageId) { var result = await store.ErrorBy(failedMessageId); @@ -49,9 +69,14 @@ public async Task GetFailedMessageById( return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description("Get the last processing attempt for a specific failed message.")] + [McpServerTool, Description( + "Use this tool to see how a specific message failed most recently. " + + "Good for questions like: 'what was the last error for this message?', 'show me the latest exception', or 'what happened on the last attempt?'. " + + "Returns the latest processing attempt with its exception, stack trace, and headers. " + + "Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history." + )] public async Task GetFailedMessageLastAttempt( - [Description("The unique ID of the failed message")] string failedMessageId) + [Description("The unique message ID from a previous query result")] string failedMessageId) { var result = await store.ErrorLastBy(failedMessageId); @@ -63,22 +88,32 @@ public async Task GetFailedMessageLastAttempt( return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description("Get a summary of error counts grouped by status (unresolved, archived, resolved, retryissued).")] + [McpServerTool, Description( + "Use this tool as a quick health check to see how many messages are in each failure state. " + + "Good for questions like: 'how many errors are there?', 'what is the error situation?', or 'are there unresolved failures?'. " + + "Returns counts for unresolved, archived, resolved, and retryissued statuses. " + + "This is a good first tool to call when asked about the overall error situation before drilling into specific messages." + )] public async Task GetErrorsSummary() { var result = await store.ErrorsSummary(); return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description("Get failed messages for a specific endpoint.")] + [McpServerTool, Description( + "Use this tool to see failed messages for a specific NServiceBus endpoint. " + + "Good for questions like: 'what is failing in the Sales endpoint?', 'show errors for Shipping', or 'are there failures in this endpoint?'. " + + "Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. " + + "Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name." + )] 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") + [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, + [Description("Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.")] string? status = null, + [Description("Only return messages modified after this date (ISO 8601)")] string? modified = null, + [Description("Page number, 1-based")] int page = 1, + [Description("Results per page")] int perPage = 50, + [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", + [Description("Sort direction: asc or desc")] string direction = "desc") { var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs index ec311f4ff8..9e9bf17372 100644 --- a/src/ServiceControl/Mcp/FailureGroupTools.cs +++ b/src/ServiceControl/Mcp/FailureGroupTools.cs @@ -9,19 +9,36 @@ namespace ServiceControl.Mcp; using Persistence; using Recoverability; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for inspecting failure groups and retry history.\n\n" + + "Agent guidance:\n" + + "1. GetFailureGroups is usually the best starting point for diagnosing production issues — call it before drilling into individual messages.\n" + + "2. Call GetFailureGroups with no parameters to use the default grouping by exception type and stack trace.\n" + + "3. Use GetRetryHistory to check whether someone has already retried a group before retrying it again." +)] public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore) { - [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.")] + [McpServerTool, Description( + "Use this tool to understand why messages are failing by seeing failures grouped by root cause. " + + "Good for questions like: 'why are messages failing?', 'what errors are happening?', 'group failures by exception', or 'what are the top failure causes?'. " + + "Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. " + + "This is usually the best starting point for diagnosing production issues — call it before drilling into individual messages. " + + "Call with no parameters to use the default grouping by exception type and stack trace." + )] 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) + [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace", + [Description("Only include groups matching this filter text")] 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.")] + [McpServerTool, Description( + "Use this tool to check the history of retry operations. " + + "Good for questions like: 'has someone already retried these?', 'what happened the last time we retried this group?', 'show retry history', or 'were any retries attempted today?'. " + + "Returns which groups were retried, when, and whether the retries succeeded or failed. " + + "Use this before retrying a group to avoid duplicate retry attempts." + )] public async Task GetRetryHistory() { var retryHistory = await retryStore.GetRetryHistory(); diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs index 7d41f9d2f2..2b8ab058a8 100644 --- a/src/ServiceControl/Mcp/RetryTools.cs +++ b/src/ServiceControl/Mcp/RetryTools.cs @@ -11,20 +11,38 @@ namespace ServiceControl.Mcp; using Recoverability; using Persistence; -[McpServerToolType] +[McpServerToolType, Description( + "Tools for retrying failed messages.\n\n" + + "Agent guidance:\n" + + "1. Retrying sends a failed message back to its original queue for reprocessing. Only retry after the underlying issue has been resolved.\n" + + "2. Prefer RetryFailureGroup when all messages share the same root cause — it is the most targeted approach.\n" + + "3. Use RetryAllFailedMessagesByEndpoint when a bug in one endpoint has been fixed.\n" + + "4. Use RetryFailedMessagesByQueue when a queue's consumer was down and is now back.\n" + + "5. Use RetryAllFailedMessages only as a last resort — it retries everything.\n" + + "6. All operations are asynchronous — they return Accepted immediately and complete in the background." +)] public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager) { - [McpServerTool, Description("Retry a single failed message by its unique ID. The message will be sent back to its original queue for reprocessing.")] + [McpServerTool, Description( + "Use this tool to reprocess a single failed message by sending it back to its original queue. " + + "Good for questions like: 'retry this message', 'reprocess this failure', or 'send this message back for processing'. " + + "The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. " + + "If you need to retry many messages with the same root cause, use RetryFailureGroup instead." + )] public async Task RetryFailedMessage( - [Description("The unique ID of the failed message to retry")] string failedMessageId) + [Description("The unique message ID from a previous query result")] 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.")] + [McpServerTool, Description( + "Use this tool to reprocess multiple specific failed messages at once. " + + "Good for questions like: 'retry these messages', 'reprocess messages msg-1, msg-2, msg-3', or 'retry this batch'. " + + "Prefer RetryFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to retry." + )] public async Task RetryFailedMessages( - [Description("Array of unique message IDs to retry")] string[] messageIds) + [Description("The unique message IDs from a previous query result")] string[] messageIds) { if (messageIds.Any(string.IsNullOrEmpty)) { @@ -35,9 +53,13 @@ public async Task RetryFailedMessages( 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.")] + [McpServerTool, Description( + "Use this tool to retry all unresolved failed messages from a specific queue. " + + "Good for questions like: 'retry all failures in the Sales queue', 'reprocess everything from this queue', or 'the queue consumer is back, retry its failures'. " + + "Useful when a queue's consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status." + )] public async Task RetryFailedMessagesByQueue( - [Description("The queue address to retry all failed messages from")] string queueAddress) + [Description("The full queue address including machine name, e.g. 'Sales@machine'")] string queueAddress) { await messageSession.SendLocal(m => { @@ -47,24 +69,38 @@ await messageSession.SendLocal(m => 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.")] + [McpServerTool, Description( + "Use this tool to retry every unresolved failed message across all queues and endpoints. " + + "Good for questions like: 'retry everything', 'reprocess all failures', or 'retry all failed messages'. " + + "This is a broad operation — prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly." + )] 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.")] + [McpServerTool, Description( + "Use this tool to retry all failed messages for a specific NServiceBus endpoint. " + + "Good for questions like: 'retry all failures in the Sales endpoint', 'the bug in Shipping is fixed, retry its failures', or 'reprocess all errors for this endpoint'. " + + "Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed." + )] public async Task RetryAllFailedMessagesByEndpoint( - [Description("The name of the endpoint to retry all failed messages for")] string endpointName) + [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] 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.")] + [McpServerTool, Description( + "Use this tool to retry all failed messages that share the same exception type and stack trace. " + + "Good for questions like: 'retry this failure group', 'the bug causing these NullReferenceExceptions is fixed, retry them', or 'retry all messages in this group'. " + + "This is the most targeted way to retry related failures after fixing a specific bug. " + + "You need a group ID, which you can get from GetFailureGroups. " + + "Returns InProgress if a retry is already running for this group." + )] public async Task RetryFailureGroup( - [Description("The ID of the failure group to retry")] string groupId) + [Description("The failure group ID from get_failure_groups results")] string groupId) { if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { From 97b03b6cfcd9e9bcff245b2525ccc25253137871 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 11:52:16 +0200 Subject: [PATCH 20/29] Add approval file --- ...d_list_primary_instance_tools.approved.txt | 386 +++++++++--------- 1 file changed, 193 insertions(+), 193 deletions(-) rename src/{ServiceControl.AcceptanceTests => ServiceControl.AcceptanceTests.RavenDB}/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt (99%) diff --git a/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt similarity index 99% rename from src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt rename to src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index da2019581d..a90d70788a 100644 --- a/src/ServiceControl.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -1,79 +1,67 @@ [ { - "name": "get_failed_messages", - "description": "Use this tool to browse failed messages when the user wants to see what is failing. Good for questions like: \u0027what messages are currently failing?\u0027, \u0027are there failures in a specific queue?\u0027, or \u0027what failed recently?\u0027. Returns a paged list of failed messages with their status, exception details, and queue information. For broad requests, call with no parameters to get the most recent failures \u2014 only add filters when you need to narrow down results. Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint.", + "name": "unarchive_failure_group", + "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { - "status": { - "description": "Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.", - "type": [ - "string", - "null" - ], - "default": null - }, - "modified": { - "description": "Only return messages modified after this date (ISO 8601). Useful for checking recent failures.", - "type": [ - "string", - "null" - ], - "default": null - }, - "queueAddress": { - "description": "Only return messages from this queue address, e.g. \u0027Sales@machine\u0027. Use when investigating a specific queue.", - "type": [ - "string", - "null" - ], - "default": null - }, - "page": { - "description": "Page number, 1-based", - "type": "integer", - "default": 1 - }, - "perPage": { - "description": "Results per page", - "type": "integer", - "default": 50 - }, - "sort": { - "description": "Sort by: time_sent, message_type, or time_of_failure", - "type": "string", - "default": "time_of_failure" - }, - "direction": { - "description": "Sort direction: asc or desc", - "type": "string", - "default": "desc" + "groupId": { + "description": "The failure group ID from get_failure_groups results", + "type": "string" } - } + }, + "required": [ + "groupId" + ] }, "execution": { "taskSupport": "optional" } }, { - "name": "get_failed_message_by_id", - "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", + "name": "get_errors_summary", + "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "unarchive_failed_messages", + "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", "inputSchema": { "type": "object", "properties": { - "failedMessageId": { - "description": "The unique message ID from a previous query result", - "type": "string" + "messageIds": { + "description": "The unique message IDs to restore", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "failedMessageId" + "messageIds" ] }, "execution": { "taskSupport": "optional" } }, + { + "name": "get_retry_history", + "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, { "name": "get_failed_message_last_attempt", "description": "Use this tool to see how a specific message failed most recently. Good for questions like: \u0027what was the last error for this message?\u0027, \u0027show me the latest exception\u0027, or \u0027what happened on the last attempt?\u0027. Returns the latest processing attempt with its exception, stack trace, and headers. Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history.", @@ -94,61 +82,14 @@ } }, { - "name": "get_errors_summary", - "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "get_failed_messages_by_endpoint", - "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", + "name": "retry_all_failed_messages_by_endpoint", + "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", "inputSchema": { "type": "object", "properties": { "endpointName": { "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" - }, - "status": { - "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", - "type": [ - "string", - "null" - ], - "default": null - }, - "modified": { - "description": "Only return messages modified after this date (ISO 8601)", - "type": [ - "string", - "null" - ], - "default": null - }, - "page": { - "description": "Page number, 1-based", - "type": "integer", - "default": 1 - }, - "perPage": { - "description": "Results per page", - "type": "integer", - "default": 50 - }, - "sort": { - "description": "Sort by: time_sent, message_type, or time_of_failure", - "type": "string", - "default": "time_of_failure" - }, - "direction": { - "description": "Sort direction: asc or desc", - "type": "string", - "default": "desc" } }, "required": [ @@ -160,8 +101,8 @@ } }, { - "name": "archive_failed_message", - "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", + "name": "retry_failed_message", + "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", "inputSchema": { "type": "object", "properties": { @@ -179,27 +120,35 @@ } }, { - "name": "archive_failed_messages", - "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", + "name": "retry_failure_group", + "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", "inputSchema": { "type": "object", "properties": { - "messageIds": { - "description": "The unique message IDs from a previous query result", - "type": "array", - "items": { - "type": "string" - } + "groupId": { + "description": "The failure group ID from get_failure_groups results", + "type": "string" } }, "required": [ - "messageIds" + "groupId" ] }, "execution": { "taskSupport": "optional" } }, + { + "name": "retry_all_failed_messages", + "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, { "name": "archive_failure_group", "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", @@ -239,49 +188,89 @@ } }, { - "name": "unarchive_failed_messages", - "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", + "name": "get_failed_messages", + "description": "Use this tool to browse failed messages when the user wants to see what is failing. Good for questions like: \u0027what messages are currently failing?\u0027, \u0027are there failures in a specific queue?\u0027, or \u0027what failed recently?\u0027. Returns a paged list of failed messages with their status, exception details, and queue information. For broad requests, call with no parameters to get the most recent failures \u2014 only add filters when you need to narrow down results. Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint.", "inputSchema": { "type": "object", "properties": { - "messageIds": { - "description": "The unique message IDs to restore", - "type": "array", - "items": { - "type": "string" - } + "status": { + "description": "Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.", + "type": [ + "string", + "null" + ], + "default": null + }, + "modified": { + "description": "Only return messages modified after this date (ISO 8601). Useful for checking recent failures.", + "type": [ + "string", + "null" + ], + "default": null + }, + "queueAddress": { + "description": "Only return messages from this queue address, e.g. \u0027Sales@machine\u0027. Use when investigating a specific queue.", + "type": [ + "string", + "null" + ], + "default": null + }, + "page": { + "description": "Page number, 1-based", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Results per page", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort by: time_sent, message_type, or time_of_failure", + "type": "string", + "default": "time_of_failure" + }, + "direction": { + "description": "Sort direction: asc or desc", + "type": "string", + "default": "desc" } - }, - "required": [ - "messageIds" - ] + } }, "execution": { "taskSupport": "optional" } }, { - "name": "unarchive_failure_group", - "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", + "name": "get_failure_groups", + "description": "Use this tool to understand why messages are failing by seeing failures grouped by root cause. Good for questions like: \u0027why are messages failing?\u0027, \u0027what errors are happening?\u0027, \u0027group failures by exception\u0027, or \u0027what are the top failure causes?\u0027. Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. This is usually the best starting point for diagnosing production issues \u2014 call it before drilling into individual messages. Call with no parameters to use the default grouping by exception type and stack trace.", "inputSchema": { "type": "object", "properties": { - "groupId": { - "description": "The failure group ID from get_failure_groups results", - "type": "string" + "classifier": { + "description": "How to group failures. The default \u0027Exception Type and Stack Trace\u0027 is almost always what you want. Use \u0027Message Type\u0027 to group by the NServiceBus message type instead.", + "type": "string", + "default": "Exception Type and Stack Trace" + }, + "classifierFilter": { + "description": "Only include groups matching this filter text", + "type": [ + "string", + "null" + ], + "default": null } - }, - "required": [ - "groupId" - ] + } }, "execution": { "taskSupport": "optional" } }, { - "name": "retry_failed_message", - "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", + "name": "archive_failed_message", + "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { @@ -298,28 +287,6 @@ "taskSupport": "optional" } }, - { - "name": "retry_failed_messages", - "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", - "inputSchema": { - "type": "object", - "properties": { - "messageIds": { - "description": "The unique message IDs from a previous query result", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "messageIds" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, { "name": "retry_failed_messages_by_queue", "description": "Use this tool to retry all unresolved failed messages from a specific queue. Good for questions like: \u0027retry all failures in the Sales queue\u0027, \u0027reprocess everything from this queue\u0027, or \u0027the queue consumer is back, retry its failures\u0027. Useful when a queue\u0027s consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status.", @@ -340,29 +307,40 @@ } }, { - "name": "retry_all_failed_messages", - "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", + "name": "get_failed_message_by_id", + "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", "inputSchema": { "type": "object", - "properties": {} + "properties": { + "failedMessageId": { + "description": "The unique message ID from a previous query result", + "type": "string" + } + }, + "required": [ + "failedMessageId" + ] }, "execution": { "taskSupport": "optional" } }, { - "name": "retry_all_failed_messages_by_endpoint", - "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", + "name": "retry_failed_messages", + "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", "inputSchema": { "type": "object", "properties": { - "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", - "type": "string" + "messageIds": { + "description": "The unique message IDs from a previous query result", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "endpointName" + "messageIds" ] }, "execution": { @@ -370,18 +348,21 @@ } }, { - "name": "retry_failure_group", - "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", + "name": "archive_failed_messages", + "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", "inputSchema": { "type": "object", "properties": { - "groupId": { - "description": "The failure group ID from get_failure_groups results", - "type": "string" + "messageIds": { + "description": "The unique message IDs from a previous query result", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "groupId" + "messageIds" ] }, "execution": { @@ -389,39 +370,58 @@ } }, { - "name": "get_failure_groups", - "description": "Use this tool to understand why messages are failing by seeing failures grouped by root cause. Good for questions like: \u0027why are messages failing?\u0027, \u0027what errors are happening?\u0027, \u0027group failures by exception\u0027, or \u0027what are the top failure causes?\u0027. Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. This is usually the best starting point for diagnosing production issues \u2014 call it before drilling into individual messages. Call with no parameters to use the default grouping by exception type and stack trace.", + "name": "get_failed_messages_by_endpoint", + "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", "inputSchema": { "type": "object", "properties": { - "classifier": { - "description": "How to group failures. The default \u0027Exception Type and Stack Trace\u0027 is almost always what you want. Use \u0027Message Type\u0027 to group by the NServiceBus message type instead.", - "type": "string", - "default": "Exception Type and Stack Trace" + "endpointName": { + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "type": "string" }, - "classifierFilter": { - "description": "Only include groups matching this filter text", + "status": { + "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", + "type": [ + "string", + "null" + ], + "default": null + }, + "modified": { + "description": "Only return messages modified after this date (ISO 8601)", "type": [ "string", "null" ], "default": null + }, + "page": { + "description": "Page number, 1-based", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Results per page", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort by: time_sent, message_type, or time_of_failure", + "type": "string", + "default": "time_of_failure" + }, + "direction": { + "description": "Sort direction: asc or desc", + "type": "string", + "default": "desc" } - } - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "get_retry_history", - "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", - "inputSchema": { - "type": "object", - "properties": {} + }, + "required": [ + "endpointName" + ] }, "execution": { "taskSupport": "optional" } } -] +] \ No newline at end of file From a12b13496531e4e6b920c92fcf05012af282ed33 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 12:13:57 +0200 Subject: [PATCH 21/29] Update audit approval files --- ...rver_is_enabled.Should_list_audit_message_tools.approved.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index b83a49c307..7d8942a3d0 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -263,4 +263,4 @@ "taskSupport": "optional" } } -] +] \ No newline at end of file From 92a46249d01db3df810c41a798469c4c031ec660 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 23 Mar 2026 12:14:47 +0200 Subject: [PATCH 22/29] Update raven audit approval --- ...ould_list_audit_message_tools.approved.txt | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 635537c09e..7d8942a3d0 100644 --- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,36 +1,36 @@ [ { "name": "search_audit_messages", - "description": "Search audit messages by a keyword or phrase. Searches across message content and metadata.", + "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", "inputSchema": { "type": "object", "properties": { "query": { - "description": "The search query string", + "description": "Free-text search query \u2014 matches against message body, headers, and metadata", "type": "string" }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -38,7 +38,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" @@ -56,31 +56,31 @@ }, { "name": "get_audit_messages_by_conversation", - "description": "Get all audit messages that belong to a specific conversation. A conversation groups related messages that were triggered by the same initial message.", + "description": "Use this tool to trace the full chain of messages triggered by an initial message. Good for questions like: \u0027what happened after this message was sent?\u0027, \u0027show me the full message flow\u0027, or \u0027trace this conversation\u0027. A conversation groups all related messages together \u2014 the original command and every event, reply, or saga message it caused. You need a conversation ID, which you can get from any audit message query result. Essential for understanding message flow and debugging cascading issues.", "inputSchema": { "type": "object", "properties": { "conversationId": { - "description": "The conversation ID to filter by", + "description": "The conversation ID from a previous audit message query result", "type": "string" }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" } @@ -95,12 +95,12 @@ }, { "name": "get_audit_message_body", - "description": "Get the body content of a specific audit message by its message ID.", + "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", "inputSchema": { "type": "object", "properties": { "messageId": { - "description": "The message ID", + "description": "The message ID from a previous audit message query result", "type": "string" } }, @@ -114,16 +114,16 @@ }, { "name": "get_audit_messages_by_endpoint", - "description": "Get audit messages received by a specific endpoint. Can optionally filter by keyword.", + "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the receiving endpoint", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" }, "keyword": { - "description": "Optional keyword to filter messages", + "description": "Optional keyword to search within this endpoint\u0027s messages", "type": [ "string", "null" @@ -131,32 +131,32 @@ "default": null }, "includeSystemMessages": { - "description": "Whether to include system messages in results. Default is false", + "description": "Set to true to include NServiceBus infrastructure messages", "type": "boolean", "default": false }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -164,7 +164,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" @@ -182,12 +182,12 @@ }, { "name": "get_endpoint_audit_counts", - "description": "Get audit message counts per day for a specific endpoint. Useful for understanding message throughput.", + "description": "Use this tool to see daily message volume trends for a specific endpoint. Good for questions like: \u0027how much traffic does Sales handle?\u0027, \u0027has throughput changed recently?\u0027, or \u0027show me message counts for this endpoint\u0027. Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. You need an endpoint name \u2014 use GetKnownEndpoints first if you do not have one.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The name of the endpoint", + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", "type": "string" } }, @@ -201,7 +201,7 @@ }, { "name": "get_known_endpoints", - "description": "Get a list of all known endpoints that have sent or received audit messages.", + "description": "Use this tool to discover what NServiceBus endpoints exist in the system. Good for questions like: \u0027what endpoints do we have?\u0027, \u0027what services are running?\u0027, or \u0027list all endpoints\u0027. Returns all endpoints that have processed audit messages, including their name and host information. This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts.", "inputSchema": { "type": "object", "properties": {} @@ -212,37 +212,37 @@ }, { "name": "get_audit_messages", - "description": "Get a list of successfully processed audit messages. Supports paging and sorting. Returns message metadata including endpoints, timing information, and message type.", + "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", "inputSchema": { "type": "object", "properties": { "includeSystemMessages": { - "description": "Whether to include system messages in results. Default is false", + "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", "type": "boolean", "default": false }, "page": { - "description": "Page number (1-based). Default is 1", + "description": "Page number, 1-based", "type": "integer", "default": 1 }, "perPage": { - "description": "Number of results per page. Default is 50", + "description": "Results per page", "type": "integer", "default": 50 }, "sort": { - "description": "Sort field: time_sent, processed_at, message_type, critical_time, delivery_time, processing_time. Default is time_sent", + "description": "Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time", "type": "string", "default": "time_sent" }, "direction": { - "description": "Sort direction: asc or desc. Default is desc", + "description": "Sort direction: asc or desc", "type": "string", "default": "desc" }, "timeSentFrom": { - "description": "Filter by time sent start (ISO 8601 format)", + "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", "type": [ "string", "null" @@ -250,7 +250,7 @@ "default": null }, "timeSentTo": { - "description": "Filter by time sent end (ISO 8601 format)", + "description": "Only return messages sent before this time (ISO 8601)", "type": [ "string", "null" From 23165d48393779f3c2b6793d68ee4ed235b20937 Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Mon, 23 Mar 2026 15:50:41 +0200 Subject: [PATCH 23/29] Order approval files --- ...d_list_primary_instance_tools.approved.txt | 318 +++++++++--------- .../Mcp/When_mcp_server_is_enabled.cs | 3 +- ...ould_list_audit_message_tools.approved.txt | 72 ++-- ...ould_list_audit_message_tools.approved.txt | 72 ++-- .../Mcp/When_mcp_server_is_enabled.cs | 3 +- .../Infrastructure/EventSourceCreator.cs | 8 +- 6 files changed, 239 insertions(+), 237 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index a90d70788a..d3f82a86b8 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -1,17 +1,17 @@ [ { - "name": "unarchive_failure_group", - "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", + "name": "archive_failed_message", + "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { - "groupId": { - "description": "The failure group ID from get_failure_groups results", + "failedMessageId": { + "description": "The unique message ID from a previous query result", "type": "string" } }, "required": [ - "groupId" + "failedMessageId" ] }, "execution": { @@ -19,24 +19,13 @@ } }, { - "name": "get_errors_summary", - "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "unarchive_failed_messages", - "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", + "name": "archive_failed_messages", + "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "The unique message IDs to restore", + "description": "The unique message IDs from a previous query result", "type": "array", "items": { "type": "string" @@ -52,76 +41,8 @@ } }, { - "name": "get_retry_history", - "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "get_failed_message_last_attempt", - "description": "Use this tool to see how a specific message failed most recently. Good for questions like: \u0027what was the last error for this message?\u0027, \u0027show me the latest exception\u0027, or \u0027what happened on the last attempt?\u0027. Returns the latest processing attempt with its exception, stack trace, and headers. Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history.", - "inputSchema": { - "type": "object", - "properties": { - "failedMessageId": { - "description": "The unique message ID from a previous query result", - "type": "string" - } - }, - "required": [ - "failedMessageId" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "retry_all_failed_messages_by_endpoint", - "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", - "inputSchema": { - "type": "object", - "properties": { - "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", - "type": "string" - } - }, - "required": [ - "endpointName" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "retry_failed_message", - "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", - "inputSchema": { - "type": "object", - "properties": { - "failedMessageId": { - "description": "The unique message ID from a previous query result", - "type": "string" - } - }, - "required": [ - "failedMessageId" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, - { - "name": "retry_failure_group", - "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", + "name": "archive_failure_group", + "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { @@ -139,8 +60,8 @@ } }, { - "name": "retry_all_failed_messages", - "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", + "name": "get_errors_summary", + "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", "inputSchema": { "type": "object", "properties": {} @@ -150,18 +71,18 @@ } }, { - "name": "archive_failure_group", - "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", + "name": "get_failed_message_by_id", + "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", "inputSchema": { "type": "object", "properties": { - "groupId": { - "description": "The failure group ID from get_failure_groups results", + "failedMessageId": { + "description": "The unique message ID from a previous query result", "type": "string" } }, "required": [ - "groupId" + "failedMessageId" ] }, "execution": { @@ -169,13 +90,13 @@ } }, { - "name": "unarchive_failed_message", - "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.", + "name": "get_failed_message_last_attempt", + "description": "Use this tool to see how a specific message failed most recently. Good for questions like: \u0027what was the last error for this message?\u0027, \u0027show me the latest exception\u0027, or \u0027what happened on the last attempt?\u0027. Returns the latest processing attempt with its exception, stack trace, and headers. Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID to restore", + "description": "The unique message ID from a previous query result", "type": "string" } }, @@ -243,6 +164,61 @@ "taskSupport": "optional" } }, + { + "name": "get_failed_messages_by_endpoint", + "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "type": "string" + }, + "status": { + "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", + "type": [ + "string", + "null" + ], + "default": null + }, + "modified": { + "description": "Only return messages modified after this date (ISO 8601)", + "type": [ + "string", + "null" + ], + "default": null + }, + "page": { + "description": "Page number, 1-based", + "type": "integer", + "default": 1 + }, + "perPage": { + "description": "Results per page", + "type": "integer", + "default": 50 + }, + "sort": { + "description": "Sort by: time_sent, message_type, or time_of_failure", + "type": "string", + "default": "time_of_failure" + }, + "direction": { + "description": "Sort direction: asc or desc", + "type": "string", + "default": "desc" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, { "name": "get_failure_groups", "description": "Use this tool to understand why messages are failing by seeing failures grouped by root cause. Good for questions like: \u0027why are messages failing?\u0027, \u0027what errors are happening?\u0027, \u0027group failures by exception\u0027, or \u0027what are the top failure causes?\u0027. Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. This is usually the best starting point for diagnosing production issues \u2014 call it before drilling into individual messages. Call with no parameters to use the default grouping by exception type and stack trace.", @@ -269,8 +245,49 @@ } }, { - "name": "archive_failed_message", - "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", + "name": "get_retry_history", + "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_all_failed_messages", + "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_all_failed_messages_by_endpoint", + "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", + "inputSchema": { + "type": "object", + "properties": { + "endpointName": { + "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "type": "string" + } + }, + "required": [ + "endpointName" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "retry_failed_message", + "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", "inputSchema": { "type": "object", "properties": { @@ -287,6 +304,28 @@ "taskSupport": "optional" } }, + { + "name": "retry_failed_messages", + "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", + "inputSchema": { + "type": "object", + "properties": { + "messageIds": { + "description": "The unique message IDs from a previous query result", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageIds" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, { "name": "retry_failed_messages_by_queue", "description": "Use this tool to retry all unresolved failed messages from a specific queue. Good for questions like: \u0027retry all failures in the Sales queue\u0027, \u0027reprocess everything from this queue\u0027, or \u0027the queue consumer is back, retry its failures\u0027. Useful when a queue\u0027s consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status.", @@ -307,18 +346,18 @@ } }, { - "name": "get_failed_message_by_id", - "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", + "name": "retry_failure_group", + "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", "inputSchema": { "type": "object", "properties": { - "failedMessageId": { - "description": "The unique message ID from a previous query result", + "groupId": { + "description": "The failure group ID from get_failure_groups results", "type": "string" } }, "required": [ - "failedMessageId" + "groupId" ] }, "execution": { @@ -326,21 +365,18 @@ } }, { - "name": "retry_failed_messages", - "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", + "name": "unarchive_failed_message", + "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { - "messageIds": { - "description": "The unique message IDs from a previous query result", - "type": "array", - "items": { - "type": "string" - } + "failedMessageId": { + "description": "The unique message ID to restore", + "type": "string" } }, "required": [ - "messageIds" + "failedMessageId" ] }, "execution": { @@ -348,13 +384,13 @@ } }, { - "name": "archive_failed_messages", - "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", + "name": "unarchive_failed_messages", + "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "The unique message IDs from a previous query result", + "description": "The unique message IDs to restore", "type": "array", "items": { "type": "string" @@ -370,54 +406,18 @@ } }, { - "name": "get_failed_messages_by_endpoint", - "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", + "name": "unarchive_failure_group", + "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { - "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "groupId": { + "description": "The failure group ID from get_failure_groups results", "type": "string" - }, - "status": { - "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", - "type": [ - "string", - "null" - ], - "default": null - }, - "modified": { - "description": "Only return messages modified after this date (ISO 8601)", - "type": [ - "string", - "null" - ], - "default": null - }, - "page": { - "description": "Page number, 1-based", - "type": "integer", - "default": 1 - }, - "perPage": { - "description": "Results per page", - "type": "integer", - "default": 50 - }, - "sort": { - "description": "Sort by: time_sent, message_type, or time_of_failure", - "type": "string", - "default": "time_of_failure" - }, - "direction": { - "description": "Sort direction: asc or desc", - "type": "string", - "default": "desc" } }, "required": [ - "endpointName" + "groupId" ] }, "execution": { diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 6516e84bc1..c3928caa07 100644 --- a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -57,7 +57,8 @@ await Define() Assert.That(toolsJson, Is.Not.Null); var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; - var formattedTools = JsonSerializer.Serialize(mcpResponse.Result.Tools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); + var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Approver.Verify(formattedTools); } diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 7d8942a3d0..017bd25122 100644 --- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,13 +1,33 @@ [ { - "name": "search_audit_messages", - "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", + "name": "get_audit_message_body", + "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", "inputSchema": { "type": "object", "properties": { - "query": { - "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "messageId": { + "description": "The message ID from a previous audit message query result", "type": "string" + } + }, + "required": [ + "messageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages", + "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "inputSchema": { + "type": "object", + "properties": { + "includeSystemMessages": { + "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", + "type": "boolean", + "default": false }, "page": { "description": "Page number, 1-based", @@ -30,7 +50,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", "type": [ "string", "null" @@ -45,10 +65,7 @@ ], "default": null } - }, - "required": [ - "query" - ] + } }, "execution": { "taskSupport": "optional" @@ -93,25 +110,6 @@ "taskSupport": "optional" } }, - { - "name": "get_audit_message_body", - "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", - "inputSchema": { - "type": "object", - "properties": { - "messageId": { - "description": "The message ID from a previous audit message query result", - "type": "string" - } - }, - "required": [ - "messageId" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, { "name": "get_audit_messages_by_endpoint", "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", @@ -211,15 +209,14 @@ } }, { - "name": "get_audit_messages", - "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "name": "search_audit_messages", + "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", "inputSchema": { "type": "object", "properties": { - "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", - "type": "boolean", - "default": false + "query": { + "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "type": "string" }, "page": { "description": "Page number, 1-based", @@ -242,7 +239,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -257,7 +254,10 @@ ], "default": null } - } + }, + "required": [ + "query" + ] }, "execution": { "taskSupport": "optional" diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 7d8942a3d0..017bd25122 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,13 +1,33 @@ [ { - "name": "search_audit_messages", - "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", + "name": "get_audit_message_body", + "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", "inputSchema": { "type": "object", "properties": { - "query": { - "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "messageId": { + "description": "The message ID from a previous audit message query result", "type": "string" + } + }, + "required": [ + "messageId" + ] + }, + "execution": { + "taskSupport": "optional" + } + }, + { + "name": "get_audit_messages", + "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "inputSchema": { + "type": "object", + "properties": { + "includeSystemMessages": { + "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", + "type": "boolean", + "default": false }, "page": { "description": "Page number, 1-based", @@ -30,7 +50,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", "type": [ "string", "null" @@ -45,10 +65,7 @@ ], "default": null } - }, - "required": [ - "query" - ] + } }, "execution": { "taskSupport": "optional" @@ -93,25 +110,6 @@ "taskSupport": "optional" } }, - { - "name": "get_audit_message_body", - "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", - "inputSchema": { - "type": "object", - "properties": { - "messageId": { - "description": "The message ID from a previous audit message query result", - "type": "string" - } - }, - "required": [ - "messageId" - ] - }, - "execution": { - "taskSupport": "optional" - } - }, { "name": "get_audit_messages_by_endpoint", "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", @@ -211,15 +209,14 @@ } }, { - "name": "get_audit_messages", - "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "name": "search_audit_messages", + "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", "inputSchema": { "type": "object", "properties": { - "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", - "type": "boolean", - "default": false + "query": { + "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "type": "string" }, "page": { "description": "Page number, 1-based", @@ -242,7 +239,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", + "description": "Only return messages sent after this time (ISO 8601)", "type": [ "string", "null" @@ -257,7 +254,10 @@ ], "default": null } - } + }, + "required": [ + "query" + ] }, "execution": { "taskSupport": "optional" diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index aaa176077f..2c90c8b89b 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -61,7 +61,8 @@ await Define() Assert.That(toolsJson, Is.Not.Null); var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; - var formattedTools = JsonSerializer.Serialize(mcpResponse.Result.Tools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); + var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Approver.Verify(formattedTools); } diff --git a/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs b/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs index 004fd541e4..ea5d898744 100644 --- a/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs +++ b/src/ServiceControl.Audit/Infrastructure/EventSourceCreator.cs @@ -8,10 +8,10 @@ static class EventSourceCreator [SupportedOSPlatform("windows")] public static void Create() { - if (!EventLog.SourceExists(SourceName)) - { - EventLog.CreateEventSource(SourceName, null); - } + //if (!EventLog.SourceExists(SourceName)) + //{ + // EventLog.CreateEventSource(SourceName, null); + //} } public const string SourceName = "ServiceControl.Audit"; From 2a920c8727c7cf802c72759e83c296df04359ec2 Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Mon, 23 Mar 2026 20:03:11 +0200 Subject: [PATCH 24/29] Add logging --- .../Mcp/AuditMessageMcpToolsTests.cs | 3 ++- .../Mcp/EndpointMcpToolsTests.cs | 3 ++- .../Mcp/AuditMessageTools.cs | 23 ++++++++++++++++++- src/ServiceControl.Audit/Mcp/EndpointTools.cs | 11 ++++++++- .../Mcp/ArchiveMcpToolsTests.cs | 3 ++- .../Mcp/FailedMessageMcpToolsTests.cs | 3 ++- .../Mcp/FailureGroupMcpToolsTests.cs | 2 +- .../Mcp/RetryMcpToolsTests.cs | 2 +- src/ServiceControl/Mcp/ArchiveTools.cs | 19 ++++++++++++++- src/ServiceControl/Mcp/FailedMessageTools.cs | 19 ++++++++++++++- src/ServiceControl/Mcp/FailureGroupTools.cs | 10 +++++++- src/ServiceControl/Mcp/RetryTools.cs | 17 +++++++++++++- 12 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs index 3bc8c18cc8..6405534165 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs @@ -13,6 +13,7 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using Audit.Mcp; using Audit.Monitoring; using Audit.Persistence; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using ServiceControl.SagaAudit; @@ -26,7 +27,7 @@ class AuditMessageMcpToolsTests public void SetUp() { store = new StubAuditDataStore(); - tools = new AuditMessageTools(store); + tools = new AuditMessageTools(store, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs index 567528290a..e1c1b0eaf0 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs @@ -13,6 +13,7 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using Audit.Mcp; using Audit.Monitoring; using Audit.Persistence; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using ServiceControl.SagaAudit; @@ -27,7 +28,7 @@ class EndpointMcpToolsTests public void SetUp() { store = new StubAuditDataStore(); - tools = new EndpointTools(store); + tools = new EndpointTools(store, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs index c6fd4a3e0d..f6caa32422 100644 --- a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs +++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Audit.Mcp; using System.Threading; using System.Threading.Tasks; using Infrastructure; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; @@ -20,7 +21,7 @@ namespace ServiceControl.Audit.Mcp; "5. Use time filters when the user mentions a date or time window like 'today' or 'last hour'.\n" + "6. Only change sorting when the user explicitly asks for it." )] -public class AuditMessageTools(IAuditDataStore store) +public class AuditMessageTools(IAuditDataStore store, ILogger logger) { [McpServerTool, Description( "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. " + @@ -40,12 +41,16 @@ public async Task GetAuditMessages( [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetAuditMessages invoked (page={Page}, includeSystemMessages={IncludeSystem})", page, includeSystemMessages); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo); var results = await store.GetMessages(includeSystemMessages, pagingInfo, sortInfo, timeSentRange, cancellationToken); + logger.LogInformation("MCP GetAuditMessages returned {Count} results", results.QueryStats.TotalCount); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -70,12 +75,16 @@ public async Task SearchAuditMessages( [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { + logger.LogInformation("MCP SearchAuditMessages invoked (query={Query}, page={Page})", query, page); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo); var results = await store.QueryMessages(query, pagingInfo, sortInfo, timeSentRange, cancellationToken); + logger.LogInformation("MCP SearchAuditMessages returned {Count} results", results.QueryStats.TotalCount); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -102,6 +111,8 @@ public async Task GetAuditMessagesByEndpoint( [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetAuditMessagesByEndpoint invoked (endpoint={EndpointName}, keyword={Keyword}, page={Page})", endpointName, keyword, page); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo); @@ -110,6 +121,8 @@ public async Task GetAuditMessagesByEndpoint( ? await store.QueryMessagesByReceivingEndpointAndKeyword(endpointName, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken) : await store.QueryMessagesByReceivingEndpoint(includeSystemMessages, endpointName, pagingInfo, sortInfo, timeSentRange, cancellationToken); + logger.LogInformation("MCP GetAuditMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -132,11 +145,15 @@ public async Task GetAuditMessagesByConversation( [Description("Sort direction: asc or desc")] string direction = "desc", CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetAuditMessagesByConversation invoked (conversationId={ConversationId}, page={Page})", conversationId, page); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var results = await store.QueryMessagesByConversationId(conversationId, pagingInfo, sortInfo, cancellationToken); + logger.LogInformation("MCP GetAuditMessagesByConversation returned {Count} results", results.QueryStats.TotalCount); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -155,15 +172,19 @@ public async Task GetAuditMessageBody( [Description("The message ID from a previous audit message query result")] string messageId, CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetAuditMessageBody invoked (messageId={MessageId})", messageId); + var result = await store.GetMessageBody(messageId, cancellationToken); if (!result.Found) { + logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' not found", messageId); return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' not found." }, McpJsonOptions.Default); } if (!result.HasContent) { + logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' has no body content", messageId); return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' has no body content." }, McpJsonOptions.Default); } diff --git a/src/ServiceControl.Audit/Mcp/EndpointTools.cs b/src/ServiceControl.Audit/Mcp/EndpointTools.cs index 74a4c00ad0..cc15e03b43 100644 --- a/src/ServiceControl.Audit/Mcp/EndpointTools.cs +++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Audit.Mcp; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; @@ -15,7 +16,7 @@ namespace ServiceControl.Audit.Mcp; "1. Use GetKnownEndpoints to discover endpoint names before calling endpoint-specific tools.\n" + "2. Use GetEndpointAuditCounts to spot throughput trends, traffic spikes, or drops in activity." )] -public class EndpointTools(IAuditDataStore store) +public class EndpointTools(IAuditDataStore store, ILogger logger) { [McpServerTool, Description( "Use this tool to discover what NServiceBus endpoints exist in the system. " + @@ -25,8 +26,12 @@ public class EndpointTools(IAuditDataStore store) )] public async Task GetKnownEndpoints(CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetKnownEndpoints invoked"); + var results = await store.QueryKnownEndpoints(cancellationToken); + logger.LogInformation("MCP GetKnownEndpoints returned {Count} endpoints", results.QueryStats.TotalCount); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -44,8 +49,12 @@ public async Task GetEndpointAuditCounts( [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, CancellationToken cancellationToken = default) { + logger.LogInformation("MCP GetEndpointAuditCounts invoked (endpoint={EndpointName})", endpointName); + var results = await store.QueryAuditCounts(endpointName, cancellationToken); + logger.LogInformation("MCP GetEndpointAuditCounts returned {Count} entries for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, diff --git a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs index 5fa584043f..84d98811d2 100644 --- a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs @@ -5,6 +5,7 @@ namespace ServiceControl.UnitTests.Mcp; using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; using NServiceBus.Testing; using NUnit.Framework; using ServiceControl.Mcp; @@ -24,7 +25,7 @@ public void SetUp() { messageSession = new TestableMessageSession(); archiver = new StubArchiveMessages(); - tools = new ArchiveTools(messageSession, archiver); + tools = new ArchiveTools(messageSession, archiver, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs index 0a3115e812..2a9c0b8c94 100644 --- a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs @@ -7,6 +7,7 @@ namespace ServiceControl.UnitTests.Mcp; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using ServiceControl.CompositeViews.Messages; using ServiceControl.EventLog; @@ -29,7 +30,7 @@ class FailedMessageMcpToolsTests public void SetUp() { store = new StubErrorMessageDataStore(); - tools = new FailedMessageTools(store); + tools = new FailedMessageTools(store, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs index 44e8fc131f..5745ee095a 100644 --- a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs @@ -30,7 +30,7 @@ public void SetUp() var retryingManager = new RetryingManager(domainEvents, NullLogger.Instance); var archiver = new StubArchiveMessages(); var fetcher = new GroupFetcher(groupsStore, retryStore, retryingManager, archiver); - tools = new FailureGroupTools(fetcher, retryStore); + tools = new FailureGroupTools(fetcher, retryStore, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs index e6e6a3e1ef..62ed5eaa7d 100644 --- a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs @@ -24,7 +24,7 @@ public void SetUp() { messageSession = new TestableMessageSession(); retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); - tools = new RetryTools(messageSession, retryingManager); + tools = new RetryTools(messageSession, retryingManager, NullLogger.Instance); } [Test] diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs index f86b2bc56f..2312145b8f 100644 --- a/src/ServiceControl/Mcp/ArchiveTools.cs +++ b/src/ServiceControl/Mcp/ArchiveTools.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Mcp; using System.Text.Json; using System.Threading.Tasks; using MessageFailures.InternalMessages; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using NServiceBus; using Persistence.Recoverability; @@ -19,7 +20,7 @@ namespace ServiceControl.Mcp; "4. Use ArchiveFailedMessages or UnarchiveFailedMessages when you have a specific set of message IDs.\n" + "5. All operations are asynchronous — they return Accepted immediately and complete in the background." )] -public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver) +public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver, ILogger logger) { [McpServerTool, Description( "Use this tool to dismiss a single failed message that does not need to be retried. " + @@ -31,6 +32,8 @@ public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archi public async Task ArchiveFailedMessage( [Description("The unique message ID from a previous query result")] string failedMessageId) { + logger.LogInformation("MCP ArchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); + await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } @@ -43,8 +46,11 @@ public async Task ArchiveFailedMessage( public async Task ArchiveFailedMessages( [Description("The unique message IDs from a previous query result")] string[] messageIds) { + logger.LogInformation("MCP ArchiveFailedMessages invoked (count={Count})", messageIds.Length); + if (messageIds.Any(string.IsNullOrEmpty)) { + logger.LogWarning("MCP ArchiveFailedMessages: rejected due to empty message IDs"); return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); } @@ -65,8 +71,11 @@ public async Task ArchiveFailedMessages( public async Task ArchiveFailureGroup( [Description("The failure group ID from get_failure_groups results")] string groupId) { + logger.LogInformation("MCP ArchiveFailureGroup invoked (groupId={GroupId})", groupId); + if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { + logger.LogInformation("MCP ArchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId); return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); } @@ -85,6 +94,8 @@ public async Task ArchiveFailureGroup( public async Task UnarchiveFailedMessage( [Description("The unique message ID to restore")] string failedMessageId) { + logger.LogInformation("MCP UnarchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); + await messageSession.SendLocal(m => m.FailedMessageIds = [failedMessageId]); return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } @@ -97,8 +108,11 @@ public async Task UnarchiveFailedMessage( public async Task UnarchiveFailedMessages( [Description("The unique message IDs to restore")] string[] messageIds) { + logger.LogInformation("MCP UnarchiveFailedMessages invoked (count={Count})", messageIds.Length); + if (messageIds.Any(string.IsNullOrEmpty)) { + logger.LogWarning("MCP UnarchiveFailedMessages: rejected due to empty message IDs"); return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); } @@ -116,8 +130,11 @@ public async Task UnarchiveFailedMessages( public async Task UnarchiveFailureGroup( [Description("The failure group ID from get_failure_groups results")] string groupId) { + logger.LogInformation("MCP UnarchiveFailureGroup invoked (groupId={GroupId})", groupId); + if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { + logger.LogInformation("MCP UnarchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId); return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); } diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs index 4776469a35..57c30fe11d 100644 --- a/src/ServiceControl/Mcp/FailedMessageTools.cs +++ b/src/ServiceControl/Mcp/FailedMessageTools.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Mcp; using System.Text.Json; using System.Threading.Tasks; using MessageFailures.Api; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; using Persistence.Infrastructure; @@ -20,7 +21,7 @@ namespace ServiceControl.Mcp; "5. Keep page=1 unless the user asks for more results.\n" + "6. Only change sorting when the user explicitly asks for it." )] -public class FailedMessageTools(IErrorMessageDataStore store) +public class FailedMessageTools(IErrorMessageDataStore store, ILogger logger) { [McpServerTool, Description( "Use this tool to browse failed messages when the user wants to see what is failing. " + @@ -38,11 +39,15 @@ public async Task GetFailedMessages( [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", [Description("Sort direction: asc or desc")] string direction = "desc") { + logger.LogInformation("MCP GetFailedMessages invoked (status={Status}, queueAddress={QueueAddress}, page={Page})", status, queueAddress, page); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var results = await store.ErrorGet(status, modified, queueAddress, pagingInfo, sortInfo); + logger.LogInformation("MCP GetFailedMessages returned {Count} results", results.QueryStats.TotalCount); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, @@ -59,10 +64,13 @@ public async Task GetFailedMessages( public async Task GetFailedMessageById( [Description("The unique message ID from a previous query result")] string failedMessageId) { + logger.LogInformation("MCP GetFailedMessageById invoked (failedMessageId={FailedMessageId})", failedMessageId); + var result = await store.ErrorBy(failedMessageId); if (result == null) { + logger.LogWarning("MCP GetFailedMessageById: message '{FailedMessageId}' not found", failedMessageId); return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default); } @@ -78,10 +86,13 @@ public async Task GetFailedMessageById( public async Task GetFailedMessageLastAttempt( [Description("The unique message ID from a previous query result")] string failedMessageId) { + logger.LogInformation("MCP GetFailedMessageLastAttempt invoked (failedMessageId={FailedMessageId})", failedMessageId); + var result = await store.ErrorLastBy(failedMessageId); if (result == null) { + logger.LogWarning("MCP GetFailedMessageLastAttempt: message '{FailedMessageId}' not found", failedMessageId); return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default); } @@ -96,6 +107,8 @@ public async Task GetFailedMessageLastAttempt( )] public async Task GetErrorsSummary() { + logger.LogInformation("MCP GetErrorsSummary invoked"); + var result = await store.ErrorsSummary(); return JsonSerializer.Serialize(result, McpJsonOptions.Default); } @@ -115,11 +128,15 @@ public async Task GetFailedMessagesByEndpoint( [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", [Description("Sort direction: asc or desc")] string direction = "desc") { + logger.LogInformation("MCP GetFailedMessagesByEndpoint invoked (endpoint={EndpointName}, status={Status}, page={Page})", endpointName, status, page); + var pagingInfo = new PagingInfo(page, perPage); var sortInfo = new SortInfo(sort, direction); var results = await store.ErrorsByEndpointName(status, endpointName, modified, pagingInfo, sortInfo); + logger.LogInformation("MCP GetFailedMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); + return JsonSerializer.Serialize(new { results.QueryStats.TotalCount, diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs index 9e9bf17372..4fce32514f 100644 --- a/src/ServiceControl/Mcp/FailureGroupTools.cs +++ b/src/ServiceControl/Mcp/FailureGroupTools.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Mcp; using System.ComponentModel; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; using Recoverability; @@ -16,7 +17,7 @@ namespace ServiceControl.Mcp; "2. Call GetFailureGroups with no parameters to use the default grouping by exception type and stack trace.\n" + "3. Use GetRetryHistory to check whether someone has already retried a group before retrying it again." )] -public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore) +public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore, ILogger logger) { [McpServerTool, Description( "Use this tool to understand why messages are failing by seeing failures grouped by root cause. " + @@ -29,7 +30,12 @@ public async Task GetFailureGroups( [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace", [Description("Only include groups matching this filter text")] string? classifierFilter = null) { + logger.LogInformation("MCP GetFailureGroups invoked (classifier={Classifier})", classifier); + var results = await fetcher.GetGroups(classifier, classifierFilter); + + logger.LogInformation("MCP GetFailureGroups returned {Count} groups", results.Length); + return JsonSerializer.Serialize(results, McpJsonOptions.Default); } @@ -41,6 +47,8 @@ public async Task GetFailureGroups( )] public async Task GetRetryHistory() { + logger.LogInformation("MCP GetRetryHistory invoked"); + var retryHistory = await retryStore.GetRetryHistory(); return JsonSerializer.Serialize(retryHistory, McpJsonOptions.Default); } diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs index 2b8ab058a8..6edd34a9d4 100644 --- a/src/ServiceControl/Mcp/RetryTools.cs +++ b/src/ServiceControl/Mcp/RetryTools.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Mcp; using System.Threading.Tasks; using MessageFailures; using MessageFailures.InternalMessages; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using NServiceBus; using Recoverability; @@ -21,7 +22,7 @@ namespace ServiceControl.Mcp; "5. Use RetryAllFailedMessages only as a last resort — it retries everything.\n" + "6. All operations are asynchronous — they return Accepted immediately and complete in the background." )] -public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager) +public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager, ILogger logger) { [McpServerTool, Description( "Use this tool to reprocess a single failed message by sending it back to its original queue. " + @@ -32,6 +33,8 @@ public class RetryTools(IMessageSession messageSession, RetryingManager retrying public async Task RetryFailedMessage( [Description("The unique message ID from a previous query result")] string failedMessageId) { + logger.LogInformation("MCP RetryFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); + await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } @@ -44,8 +47,11 @@ public async Task RetryFailedMessage( public async Task RetryFailedMessages( [Description("The unique message IDs from a previous query result")] string[] messageIds) { + logger.LogInformation("MCP RetryFailedMessages invoked (count={Count})", messageIds.Length); + if (messageIds.Any(string.IsNullOrEmpty)) { + logger.LogWarning("MCP RetryFailedMessages: rejected due to empty message IDs"); return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); } @@ -61,6 +67,8 @@ public async Task RetryFailedMessages( public async Task RetryFailedMessagesByQueue( [Description("The full queue address including machine name, e.g. 'Sales@machine'")] string queueAddress) { + logger.LogInformation("MCP RetryFailedMessagesByQueue invoked (queueAddress={QueueAddress})", queueAddress); + await messageSession.SendLocal(m => { m.QueueAddress = queueAddress; @@ -76,6 +84,8 @@ await messageSession.SendLocal(m => )] public async Task RetryAllFailedMessages() { + logger.LogInformation("MCP RetryAllFailedMessages invoked"); + await messageSession.SendLocal(new RequestRetryAll()); return JsonSerializer.Serialize(new { Status = "Accepted", Message = "Retry requested for all failed messages." }, McpJsonOptions.Default); } @@ -88,6 +98,8 @@ public async Task RetryAllFailedMessages() public async Task RetryAllFailedMessagesByEndpoint( [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName) { + logger.LogInformation("MCP RetryAllFailedMessagesByEndpoint invoked (endpoint={EndpointName})", 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); } @@ -102,8 +114,11 @@ public async Task RetryAllFailedMessagesByEndpoint( public async Task RetryFailureGroup( [Description("The failure group ID from get_failure_groups results")] string groupId) { + logger.LogInformation("MCP RetryFailureGroup invoked (groupId={GroupId})", groupId); + if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { + logger.LogInformation("MCP RetryFailureGroup: operation already in progress for group '{GroupId}'", groupId); return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"A retry operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); } From a788827edae4ad8675a37fed193acdbe8edec209 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Mar 2026 16:39:10 +0100 Subject: [PATCH 25/29] Improve MCP metadata guidance for AI clients (#5402) * Improve MCP metadata guidance for AI clients * Make it clearer when many entities are affected * Tighten MCP safety metadata wording * Add scenario-guided MCP prompt validation * Remove guides --------- Co-authored-by: Daniel Marbach --- ...d_list_primary_instance_tools.approved.txt | 192 ++++++++++++++---- ...ould_list_audit_message_tools.approved.txt | 84 ++++++-- ...ould_list_audit_message_tools.approved.txt | 84 ++++++-- .../Mcp/McpMetadataDescriptionsTests.cs | 77 +++++++ .../Mcp/AuditMessageTools.cs | 84 ++++---- src/ServiceControl.Audit/Mcp/EndpointTools.cs | 23 +-- .../Mcp/McpMetadataDescriptionsTests.cs | 150 ++++++++++++++ src/ServiceControl/Mcp/ArchiveTools.cs | 49 +++-- src/ServiceControl/Mcp/FailedMessageTools.cs | 65 +++--- src/ServiceControl/Mcp/FailureGroupTools.cs | 19 +- src/ServiceControl/Mcp/RetryTools.cs | 71 ++++--- 11 files changed, 671 insertions(+), 227 deletions(-) create mode 100644 src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs create mode 100644 src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index d3f82a86b8..69d0e2b635 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -1,12 +1,12 @@ [ { "name": "archive_failed_message", - "description": "Use this tool to dismiss a single failed message that does not need to be retried. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", + "description": "Use this tool to dismiss a single failed message that does not need to be retried. This operation changes system state. Good for questions like: \u0027archive this message\u0027, \u0027dismiss this failure\u0027, or \u0027I do not need to retry this one\u0027. Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. This is an asynchronous operation \u2014 the message will be archived shortly after the request is accepted. If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID from a previous query result", + "description": "The failed message ID from a previous failed-message query result.", "type": "string" } }, @@ -14,18 +14,24 @@ "failedMessageId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "archive_failed_messages", - "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", + "description": "Use this tool to dismiss multiple failed messages at once that do not need to be retried. This operation changes system state. It may affect many messages. Good for questions like: \u0027archive these messages\u0027, \u0027dismiss these failures\u0027, or \u0027archive messages msg-1, msg-2, msg-3\u0027. Prefer ArchiveFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to archive.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "The unique message IDs from a previous query result", + "description": "The failed message IDs from previous failed-message query results.", "type": "array", "items": { "type": "string" @@ -36,18 +42,24 @@ "messageIds" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "archive_failure_group", - "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", + "description": "Use this tool to dismiss an entire failure group \u2014 all messages that failed with the same exception type and stack trace. This operation changes system state. It may affect many messages. Good for questions like: \u0027archive this failure group\u0027, \u0027dismiss all NullReferenceException failures\u0027, or \u0027archive the whole group\u0027. This is the most efficient way to archive many related failures at once. You need a failure group ID, which you can get from GetFailureGroups. Returns InProgress if an archive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The failure group ID from get_failure_groups results", + "description": "The failure group ID from previous GetFailureGroups results.", "type": "string" } }, @@ -55,29 +67,41 @@ "groupId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "get_errors_summary", - "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", + "description": "Read-only. Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", "inputSchema": { "type": "object", "properties": {} }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_failed_message_by_id", - "description": "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. Good for questions like: \u0027show me details for this failed message\u0027, \u0027what exception caused this failure?\u0027, or \u0027how many times has this message failed?\u0027. You need the message\u0027s unique ID, which you can get from GetFailedMessages or GetFailureGroups results. If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead \u2014 it returns less data.", + "description": "Get detailed information about a specific failed message. Use this when you already know the failed message ID and need to inspect its contents or failure details. Use GetFailedMessages or GetFailureGroups to locate relevant messages before calling this tool. Read-only.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID from a previous query result", + "description": "The failed message ID from a previous failed-message query result.", "type": "string" } }, @@ -85,18 +109,24 @@ "failedMessageId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_failed_message_last_attempt", - "description": "Use this tool to see how a specific message failed most recently. Good for questions like: \u0027what was the last error for this message?\u0027, \u0027show me the latest exception\u0027, or \u0027what happened on the last attempt?\u0027. Returns the latest processing attempt with its exception, stack trace, and headers. Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history.", + "description": "Retrieve the last processing attempt for a failed message. Use this to understand the most recent failure behavior, including exception details and processing context. Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. Read-only.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID from a previous query result", + "description": "The failed message ID from a previous failed-message query result.", "type": "string" } }, @@ -104,18 +134,24 @@ "failedMessageId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_failed_messages", - "description": "Use this tool to browse failed messages when the user wants to see what is failing. Good for questions like: \u0027what messages are currently failing?\u0027, \u0027are there failures in a specific queue?\u0027, or \u0027what failed recently?\u0027. Returns a paged list of failed messages with their status, exception details, and queue information. For broad requests, call with no parameters to get the most recent failures \u2014 only add filters when you need to narrow down results. Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint.", + "description": "Retrieve failed messages for investigation. Use this when exploring recent failures or narrowing down failures by queue, status, or time range. Prefer GetFailureGroups when starting root-cause analysis across many failures. Use GetFailedMessageById when inspecting a specific failed message. Read-only.", "inputSchema": { "type": "object", "properties": { "status": { - "description": "Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.", + "description": "Filter failed messages by status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit this filter to include all statuses.", "type": [ "string", "null" @@ -123,7 +159,7 @@ "default": null }, "modified": { - "description": "Only return messages modified after this date (ISO 8601). Useful for checking recent failures.", + "description": "Restricts failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -131,7 +167,7 @@ "default": null }, "queueAddress": { - "description": "Only return messages from this queue address, e.g. \u0027Sales@machine\u0027. Use when investigating a specific queue.", + "description": "Filter failed messages to a specific queue address, for example \u0027Sales@machine\u0027. Omit this filter to include all queues.", "type": [ "string", "null" @@ -160,22 +196,28 @@ } } }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_failed_messages_by_endpoint", - "description": "Use this tool to see failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027what is failing in the Sales endpoint?\u0027, \u0027show errors for Shipping\u0027, or \u0027are there failures in this endpoint?\u0027. Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name.", + "description": "Retrieve failed messages for a specific endpoint. Use this when investigating failures in a named endpoint such as Billing or Sales. Prefer GetFailureGroups when you need root-cause analysis across many failures. Use GetFailedMessageLastAttempt after this when you need the most recent failure details for a specific message. Read-only.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The endpoint name that owns the failed messages. Use values obtained from endpoint-aware failed-message results.", "type": "string" }, "status": { - "description": "Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.", + "description": "Filter failed messages by status: unresolved, resolved, archived, or retryissued. Omit this filter to include all statuses for the endpoint.", "type": [ "string", "null" @@ -183,7 +225,7 @@ "default": null }, "modified": { - "description": "Only return messages modified after this date (ISO 8601)", + "description": "Restricts endpoint failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -215,13 +257,19 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_failure_groups", - "description": "Use this tool to understand why messages are failing by seeing failures grouped by root cause. Good for questions like: \u0027why are messages failing?\u0027, \u0027what errors are happening?\u0027, \u0027group failures by exception\u0027, or \u0027what are the top failure causes?\u0027. Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. This is usually the best starting point for diagnosing production issues \u2014 call it before drilling into individual messages. Call with no parameters to use the default grouping by exception type and stack trace.", + "description": "Retrieve failure groups, where failed messages are grouped by exception type and stack trace. Use this as the first step when analyzing large numbers of failures to identify dominant root causes. Prefer GetFailedMessages when you need individual message details. Read-only.", "inputSchema": { "type": "object", "properties": { @@ -231,7 +279,7 @@ "default": "Exception Type and Stack Trace" }, "classifierFilter": { - "description": "Only include groups matching this filter text", + "description": "Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.", "type": [ "string", "null" @@ -240,40 +288,58 @@ } } }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_retry_history", - "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", + "description": "Read-only. Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", "inputSchema": { "type": "object", "properties": {} }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_all_failed_messages", - "description": "Use this tool to retry every unresolved failed message across all queues and endpoints. Good for questions like: \u0027retry everything\u0027, \u0027reprocess all failures\u0027, or \u0027retry all failed messages\u0027. This is a broad operation \u2014 prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly.", + "description": "Retry all currently failed messages across all queues. Use only when the user explicitly requests a broad retry operation. Prefer narrower retry tools such as RetryFailureGroup or RetryFailedMessages when possible. This operation changes system state. It may affect many messages. It affects all unresolved failed messages across the instance and may affect a large number of messages.", "inputSchema": { "type": "object", "properties": {} }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_all_failed_messages_by_endpoint", - "description": "Use this tool to retry all failed messages for a specific NServiceBus endpoint. Good for questions like: \u0027retry all failures in the Sales endpoint\u0027, \u0027the bug in Shipping is fixed, retry its failures\u0027, or \u0027reprocess all errors for this endpoint\u0027. Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed.", + "description": "Retry all failed messages for a specific endpoint. Use this when the user explicitly wants an endpoint-scoped retry after an endpoint-specific issue is fixed. Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. This operation changes system state. It may affect many messages. Use the endpoint name from failed-message results.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The endpoint name whose failed messages should be retried. Use values obtained from failed-message results.", "type": "string" } }, @@ -281,18 +347,24 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_failed_message", - "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", + "description": "Use this tool to reprocess a single failed message by sending it back to its original queue. This operation changes system state. Good for questions like: \u0027retry this message\u0027, \u0027reprocess this failure\u0027, or \u0027send this message back for processing\u0027. The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. If you need to retry many messages with the same root cause, use RetryFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID from a previous query result", + "description": "The failed message ID from a previous failed-message query result.", "type": "string" } }, @@ -300,18 +372,24 @@ "failedMessageId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_failed_messages", - "description": "Use this tool to reprocess multiple specific failed messages at once. Good for questions like: \u0027retry these messages\u0027, \u0027reprocess messages msg-1, msg-2, msg-3\u0027, or \u0027retry this batch\u0027. Prefer RetryFailureGroup when all messages share the same failure cause \u2014 use this tool when you have a specific set of message IDs to retry.", + "description": "Retry a selected set of failed messages by their IDs. Use this when the user explicitly wants to retry specific known messages. Prefer RetryFailureGroup when retrying all messages with the same root cause. This operation changes system state. It may affect many messages. Use values obtained from failed-message investigation tools.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "The unique message IDs from a previous query result", + "description": "The failed message IDs from previous failed-message query results.", "type": "array", "items": { "type": "string" @@ -322,18 +400,24 @@ "messageIds" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_failed_messages_by_queue", - "description": "Use this tool to retry all unresolved failed messages from a specific queue. Good for questions like: \u0027retry all failures in the Sales queue\u0027, \u0027reprocess everything from this queue\u0027, or \u0027the queue consumer is back, retry its failures\u0027. Useful when a queue\u0027s consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status.", + "description": "Retry all unresolved failed messages from a specific queue. Use this when the user explicitly wants a queue-scoped retry after a queue or consumer issue is fixed. Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. This operation changes system state. It may affect many messages. Use the queue address from failed-message results.", "inputSchema": { "type": "object", "properties": { "queueAddress": { - "description": "The full queue address including machine name, e.g. \u0027Sales@machine\u0027", + "description": "Queue address whose unresolved failed messages should be retried. Use values obtained from failed-message results.", "type": "string" } }, @@ -341,18 +425,24 @@ "queueAddress" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "retry_failure_group", - "description": "Use this tool to retry all failed messages that share the same exception type and stack trace. Good for questions like: \u0027retry this failure group\u0027, \u0027the bug causing these NullReferenceExceptions is fixed, retry them\u0027, or \u0027retry all messages in this group\u0027. This is the most targeted way to retry related failures after fixing a specific bug. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if a retry is already running for this group.", + "description": "Retry all failed messages in a failure group that share the same root cause. Use this when multiple failures are caused by the same issue and can be retried together. Prefer RetryFailedMessages for more granular control. This operation changes system state. It may affect many messages. Use the failure group ID from GetFailureGroups. Returns InProgress if a retry is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The failure group ID from get_failure_groups results", + "description": "The failure group ID from previous GetFailureGroups results.", "type": "string" } }, @@ -360,18 +450,24 @@ "groupId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "unarchive_failed_message", - "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.", + "description": "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. This operation changes system state. Good for questions like: \u0027unarchive this message\u0027, \u0027restore this failure\u0027, or \u0027I need to retry this archived message\u0027. Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead.", "inputSchema": { "type": "object", "properties": { "failedMessageId": { - "description": "The unique message ID to restore", + "description": "The failed message ID to restore from the archived state.", "type": "string" } }, @@ -379,18 +475,24 @@ "failedMessageId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "unarchive_failed_messages", - "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", + "description": "Use this tool to restore multiple previously archived failed messages back to the unresolved list. This operation changes system state. It may affect many messages. Good for questions like: \u0027unarchive these messages\u0027, \u0027restore these failures\u0027, or \u0027unarchive messages msg-1, msg-2, msg-3\u0027. Prefer UnarchiveFailureGroup when restoring an entire group \u2014 use this tool when you have a specific set of message IDs.", "inputSchema": { "type": "object", "properties": { "messageIds": { - "description": "The unique message IDs to restore", + "description": "The failed message IDs to restore from the archived state.", "type": "array", "items": { "type": "string" @@ -401,18 +503,24 @@ "messageIds" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } }, { "name": "unarchive_failure_group", - "description": "Use this tool to restore an entire archived failure group back to the unresolved list. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", + "description": "Use this tool to restore an entire archived failure group back to the unresolved list. This operation changes system state. It may affect many messages. Good for questions like: \u0027unarchive this failure group\u0027, \u0027restore all archived NullReferenceException failures\u0027, or \u0027unarchive the whole group\u0027. All messages that were archived together under this group will become available for retry again. You need a failure group ID, which you can get from GetFailureGroups. Returns InProgress if an unarchive operation is already running for this group.", "inputSchema": { "type": "object", "properties": { "groupId": { - "description": "The failure group ID from get_failure_groups results", + "description": "The failure group ID from previous GetFailureGroups results.", "type": "string" } }, @@ -420,6 +528,12 @@ "groupId" ] }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, "execution": { "taskSupport": "optional" } diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 017bd25122..f7706a55d0 100644 --- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,12 +1,12 @@ [ { "name": "get_audit_message_body", - "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", + "description": "Retrieve the body content of a specific audit message. Use this when you need to inspect message payload or data for debugging. Typically used after locating a message via search or browsing tools. Read-only.", "inputSchema": { "type": "object", "properties": { "messageId": { - "description": "The message ID from a previous audit message query result", + "description": "The audit message ID from a previous audit message query result.", "type": "string" } }, @@ -14,18 +14,24 @@ "messageId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages", - "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "description": "Retrieve audit messages with paging and sorting. Use this to browse recent message activity or explore message flow over time. Prefer SearchAuditMessages when looking for specific keywords or content. Read-only.", "inputSchema": { "type": "object", "properties": { "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", + "description": "Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.", "type": "boolean", "default": false }, @@ -50,7 +56,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", + "description": "Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -58,7 +64,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -67,18 +73,24 @@ } } }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages_by_conversation", - "description": "Use this tool to trace the full chain of messages triggered by an initial message. Good for questions like: \u0027what happened after this message was sent?\u0027, \u0027show me the full message flow\u0027, or \u0027trace this conversation\u0027. A conversation groups all related messages together \u2014 the original command and every event, reply, or saga message it caused. You need a conversation ID, which you can get from any audit message query result. Essential for understanding message flow and debugging cascading issues.", + "description": "Retrieve all audit messages belonging to a conversation. Use this to trace the full flow of a message or business process across multiple endpoints. Prefer this tool when you already have a conversation ID. Read-only.", "inputSchema": { "type": "object", "properties": { "conversationId": { - "description": "The conversation ID from a previous audit message query result", + "description": "The conversation ID from a previous audit message query result.", "type": "string" }, "page": { @@ -106,22 +118,28 @@ "conversationId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages_by_endpoint", - "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", + "description": "Retrieve audit messages processed by a specific endpoint. Use this to understand activity and behavior of a single endpoint. Prefer GetAuditMessagesByConversation when tracing a specific message flow. Read-only.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.", "type": "string" }, "keyword": { - "description": "Optional keyword to search within this endpoint\u0027s messages", + "description": "Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.", "type": [ "string", "null" @@ -129,7 +147,7 @@ "default": null }, "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages", + "description": "Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.", "type": "boolean", "default": false }, @@ -154,7 +172,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -162,7 +180,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -174,18 +192,24 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_endpoint_audit_counts", - "description": "Use this tool to see daily message volume trends for a specific endpoint. Good for questions like: \u0027how much traffic does Sales handle?\u0027, \u0027has throughput changed recently?\u0027, or \u0027show me message counts for this endpoint\u0027. Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. You need an endpoint name \u2014 use GetKnownEndpoints first if you do not have one.", + "description": "Retrieve daily audit-message counts for a specific endpoint. Use this when checking throughput or activity trends for one endpoint. Prefer GetKnownEndpoints when you do not already know the endpoint name. Read-only.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.", "type": "string" } }, @@ -193,29 +217,41 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_known_endpoints", - "description": "Use this tool to discover what NServiceBus endpoints exist in the system. Good for questions like: \u0027what endpoints do we have?\u0027, \u0027what services are running?\u0027, or \u0027list all endpoints\u0027. Returns all endpoints that have processed audit messages, including their name and host information. This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts.", + "description": "List all known endpoints that have sent or received audit messages. Use this as a starting point to discover available endpoints before exploring their activity. Read-only.", "inputSchema": { "type": "object", "properties": {} }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "search_audit_messages", - "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", + "description": "Search audit messages by keyword across message content and metadata. Use this when trying to locate messages related to a specific business identifier or text. Prefer GetAuditMessages for general browsing or timeline exploration. Read-only.", "inputSchema": { "type": "object", "properties": { "query": { - "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "description": "The free-text search query to match against audit message body content, headers, and metadata.", "type": "string" }, "page": { @@ -239,7 +275,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -247,7 +283,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -259,6 +295,12 @@ "query" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index 017bd25122..f7706a55d0 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -1,12 +1,12 @@ [ { "name": "get_audit_message_body", - "description": "Use this tool to inspect the actual payload of a processed message. Good for questions like: \u0027show me the message body\u0027, \u0027what data was in this message?\u0027, or \u0027let me see the content of message X\u0027. Returns the serialized message body content, typically JSON. You need a message ID, which you can get from any audit message query result. Use this when the user wants to see what data was actually sent, not just message metadata.", + "description": "Retrieve the body content of a specific audit message. Use this when you need to inspect message payload or data for debugging. Typically used after locating a message via search or browsing tools. Read-only.", "inputSchema": { "type": "object", "properties": { "messageId": { - "description": "The message ID from a previous audit message query result", + "description": "The audit message ID from a previous audit message query result.", "type": "string" } }, @@ -14,18 +14,24 @@ "messageId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages", - "description": "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. Good for questions like: \u0027show recent audit messages\u0027, \u0027what messages were processed today?\u0027, \u0027list messages from endpoint X\u0027, or \u0027show slow messages\u0027. Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. For broad requests, use the default paging and sorting. Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead.", + "description": "Retrieve audit messages with paging and sorting. Use this to browse recent message activity or explore message flow over time. Prefer SearchAuditMessages when looking for specific keywords or content. Read-only.", "inputSchema": { "type": "object", "properties": { "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.", + "description": "Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.", "type": "boolean", "default": false }, @@ -50,7 +56,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.", + "description": "Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -58,7 +64,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -67,18 +73,24 @@ } } }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages_by_conversation", - "description": "Use this tool to trace the full chain of messages triggered by an initial message. Good for questions like: \u0027what happened after this message was sent?\u0027, \u0027show me the full message flow\u0027, or \u0027trace this conversation\u0027. A conversation groups all related messages together \u2014 the original command and every event, reply, or saga message it caused. You need a conversation ID, which you can get from any audit message query result. Essential for understanding message flow and debugging cascading issues.", + "description": "Retrieve all audit messages belonging to a conversation. Use this to trace the full flow of a message or business process across multiple endpoints. Prefer this tool when you already have a conversation ID. Read-only.", "inputSchema": { "type": "object", "properties": { "conversationId": { - "description": "The conversation ID from a previous audit message query result", + "description": "The conversation ID from a previous audit message query result.", "type": "string" }, "page": { @@ -106,22 +118,28 @@ "conversationId" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_audit_messages_by_endpoint", - "description": "Use this tool to see what messages a specific NServiceBus endpoint has processed. Good for questions like: \u0027what messages did Sales process?\u0027, \u0027show messages handled by Shipping\u0027, or \u0027find OrderPlaced messages in the Billing endpoint\u0027. Returns the same metadata as GetAuditMessages but scoped to one endpoint. Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. Optionally pass a keyword to search within that endpoint\u0027s messages.", + "description": "Retrieve audit messages processed by a specific endpoint. Use this to understand activity and behavior of a single endpoint. Prefer GetAuditMessagesByConversation when tracing a specific message flow. Read-only.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.", "type": "string" }, "keyword": { - "description": "Optional keyword to search within this endpoint\u0027s messages", + "description": "Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.", "type": [ "string", "null" @@ -129,7 +147,7 @@ "default": null }, "includeSystemMessages": { - "description": "Set to true to include NServiceBus infrastructure messages", + "description": "Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.", "type": "boolean", "default": false }, @@ -154,7 +172,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -162,7 +180,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -174,18 +192,24 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_endpoint_audit_counts", - "description": "Use this tool to see daily message volume trends for a specific endpoint. Good for questions like: \u0027how much traffic does Sales handle?\u0027, \u0027has throughput changed recently?\u0027, or \u0027show me message counts for this endpoint\u0027. Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. You need an endpoint name \u2014 use GetKnownEndpoints first if you do not have one.", + "description": "Retrieve daily audit-message counts for a specific endpoint. Use this when checking throughput or activity trends for one endpoint. Prefer GetKnownEndpoints when you do not already know the endpoint name. Read-only.", "inputSchema": { "type": "object", "properties": { "endpointName": { - "description": "The NServiceBus endpoint name, e.g. \u0027Sales\u0027 or \u0027Shipping.MessageHandler\u0027", + "description": "The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.", "type": "string" } }, @@ -193,29 +217,41 @@ "endpointName" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "get_known_endpoints", - "description": "Use this tool to discover what NServiceBus endpoints exist in the system. Good for questions like: \u0027what endpoints do we have?\u0027, \u0027what services are running?\u0027, or \u0027list all endpoints\u0027. Returns all endpoints that have processed audit messages, including their name and host information. This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts.", + "description": "List all known endpoints that have sent or received audit messages. Use this as a starting point to discover available endpoints before exploring their activity. Read-only.", "inputSchema": { "type": "object", "properties": {} }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } }, { "name": "search_audit_messages", - "description": "Use this tool to find audit messages by a keyword or phrase. Good for questions like: \u0027find messages containing order 12345\u0027, \u0027search for CustomerCreated messages\u0027, or \u0027look for messages mentioning this ID\u0027. Searches across message body content, headers, and metadata using full-text search. Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. If the user just wants to browse recent messages without a search term, use GetAuditMessages instead.", + "description": "Search audit messages by keyword across message content and metadata. Use this when trying to locate messages related to a specific business identifier or text. Prefer GetAuditMessages for general browsing or timeline exploration. Read-only.", "inputSchema": { "type": "object", "properties": { "query": { - "description": "Free-text search query \u2014 matches against message body, headers, and metadata", + "description": "The free-text search query to match against audit message body content, headers, and metadata.", "type": "string" }, "page": { @@ -239,7 +275,7 @@ "default": "desc" }, "timeSentFrom": { - "description": "Only return messages sent after this time (ISO 8601)", + "description": "Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -247,7 +283,7 @@ "default": null }, "timeSentTo": { - "description": "Only return messages sent before this time (ISO 8601)", + "description": "Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.", "type": [ "string", "null" @@ -259,6 +295,12 @@ "query" ] }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, "execution": { "taskSupport": "optional" } diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs new file mode 100644 index 0000000000..42e6a6ff88 --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -0,0 +1,77 @@ +#nullable enable + +namespace ServiceControl.Audit.UnitTests.Mcp; + +using System; +using System.Linq; +using System.Reflection; +using Audit.Mcp; +using NUnit.Framework; +using DescriptionAttribute = System.ComponentModel.DescriptionAttribute; + +[TestFixture] +class McpMetadataDescriptionsTests +{ + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages), "Read-only")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages), "Read-only")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint), "Read-only")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation), "Read-only")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody), "Read-only")] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints), "Read-only")] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts), "Read-only")] + public void Audit_query_tools_are_described_as_read_only(Type toolType, string methodName, string expectedPhrase) + { + var description = GetMethodDescription(toolType, methodName); + + Assert.That(description, Does.Contain(expectedPhrase)); + } + + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody), "messageId", "audit message ID")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation), "conversationId", "conversation ID")] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts), "endpointName", "NServiceBus endpoint name")] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint), "endpointName", "endpoint name")] + public void Key_audit_tool_parameters_identify_the_entity_type(Type toolType, string methodName, string parameterName, string expectedPhrase) + { + var description = GetParameterDescription(toolType, methodName, parameterName); + + Assert.That(description, Does.Contain(expectedPhrase)); + } + + [Test] + public void Audit_tools_distinguish_browse_search_trace_and_payload_scenarios() + { + var browse = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages)); + var search = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages)); + var conversation = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation)); + var endpoint = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint)); + var body = GetMethodDescription(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody)); + var knownEndpoints = GetMethodDescription(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints)); + + Assert.Multiple(() => + { + Assert.That(browse, Does.Contain("browse recent message activity").And.Contain("SearchAuditMessages")); + + Assert.That(search, Does.Contain("specific business identifier or text").And.Contain("GetAuditMessages")); + + Assert.That(conversation, Does.Contain("conversation").And.Contain("multiple endpoints")); + + Assert.That(endpoint, Does.Contain("single endpoint").And.Contain("GetAuditMessagesByConversation")); + + Assert.That(body, Does.Contain("message payload").And.Contain("search or browsing tools")); + + Assert.That(knownEndpoints, Does.Contain("starting point").And.Contain("available endpoints")); + }); + } + + static string GetMethodDescription(Type toolType, string methodName) + => toolType.GetMethod(methodName)! + .GetCustomAttribute()! + .Description; + + static string GetParameterDescription(Type toolType, string methodName, string parameterName) + => toolType.GetMethod(methodName)! + .GetParameters() + .Single(p => p.Name == parameterName) + .GetCustomAttribute()! + .Description; +} diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs index f6caa32422..6fb5548891 100644 --- a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs +++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Audit.Mcp; using Persistence; [McpServerToolType, Description( - "Tools for exploring audit messages.\n\n" + + "Read-only tools for exploring audit messages.\n\n" + "Agent guidance:\n" + "1. For broad requests like 'show recent messages', start with GetAuditMessages using defaults.\n" + "2. For requests containing a concrete text term, identifier, or phrase, use SearchAuditMessages.\n" + @@ -23,22 +23,20 @@ namespace ServiceControl.Audit.Mcp; )] public class AuditMessageTools(IAuditDataStore store, ILogger logger) { - [McpServerTool, Description( - "Use this tool to browse successfully processed audit messages when the user wants an overview rather than a text search. " + - "Good for questions like: 'show recent audit messages', 'what messages were processed today?', 'list messages from endpoint X', or 'show slow messages'. " + - "Returns message metadata such as message type, endpoints, sent time, processed time, and timing metrics. " + - "For broad requests, use the default paging and sorting. " + - "Prefer this tool over SearchAuditMessages when the user does not provide a specific keyword or phrase. " + - "If the user is looking for a specific term, id, or text fragment, use SearchAuditMessages instead." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve audit messages with paging and sorting. " + + "Use this to browse recent message activity or explore message flow over time. " + + "Prefer SearchAuditMessages when looking for specific keywords or content. " + + "Read-only." )] public async Task GetAuditMessages( - [Description("Set to true to include NServiceBus infrastructure messages. Usually leave as false to see only business messages.")] bool includeSystemMessages = false, + [Description("Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.")] bool includeSystemMessages = false, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", [Description("Sort direction: asc or desc")] string direction = "desc", - [Description("Only return messages sent after this time (ISO 8601). Use with timeSentTo to query a specific time window.")] string? timeSentFrom = null, - [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, + [Description("Restricts audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null, + [Description("Restricts audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null, CancellationToken cancellationToken = default) { logger.LogInformation("MCP GetAuditMessages invoked (page={Page}, includeSystemMessages={IncludeSystem})", page, includeSystemMessages); @@ -58,21 +56,20 @@ public async Task GetAuditMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to find audit messages by a keyword or phrase. " + - "Good for questions like: 'find messages containing order 12345', 'search for CustomerCreated messages', or 'look for messages mentioning this ID'. " + - "Searches across message body content, headers, and metadata using full-text search. " + - "Prefer this tool over GetAuditMessages when the user provides a specific term, identifier, or phrase to search for. " + - "If the user just wants to browse recent messages without a search term, use GetAuditMessages instead." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Search audit messages by keyword across message content and metadata. " + + "Use this when trying to locate messages related to a specific business identifier or text. " + + "Prefer GetAuditMessages for general browsing or timeline exploration. " + + "Read-only." )] public async Task SearchAuditMessages( - [Description("Free-text search query — matches against message body, headers, and metadata")] string query, + [Description("The free-text search query to match against audit message body content, headers, and metadata.")] string query, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", [Description("Sort direction: asc or desc")] string direction = "desc", - [Description("Only return messages sent after this time (ISO 8601)")] string? timeSentFrom = null, - [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, + [Description("Restricts audit search results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null, + [Description("Restricts audit search results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null, CancellationToken cancellationToken = default) { logger.LogInformation("MCP SearchAuditMessages invoked (query={Query}, page={Page})", query, page); @@ -92,23 +89,22 @@ public async Task SearchAuditMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to see what messages a specific NServiceBus endpoint has processed. " + - "Good for questions like: 'what messages did Sales process?', 'show messages handled by Shipping', or 'find OrderPlaced messages in the Billing endpoint'. " + - "Returns the same metadata as GetAuditMessages but scoped to one endpoint. " + - "Prefer this tool over GetAuditMessages when the user mentions a specific endpoint name. " + - "Optionally pass a keyword to search within that endpoint's messages." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve audit messages processed by a specific endpoint. " + + "Use this to understand activity and behavior of a single endpoint. " + + "Prefer GetAuditMessagesByConversation when tracing a specific message flow. " + + "Read-only." )] public async Task GetAuditMessagesByEndpoint( - [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, - [Description("Optional keyword to search within this endpoint's messages")] string? keyword = null, - [Description("Set to true to include NServiceBus infrastructure messages")] bool includeSystemMessages = false, + [Description("The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.")] string endpointName, + [Description("Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.")] string? keyword = null, + [Description("Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.")] bool includeSystemMessages = false, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", [Description("Sort direction: asc or desc")] string direction = "desc", - [Description("Only return messages sent after this time (ISO 8601)")] string? timeSentFrom = null, - [Description("Only return messages sent before this time (ISO 8601)")] string? timeSentTo = null, + [Description("Restricts endpoint audit-message results to messages sent after this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentFrom = null, + [Description("Restricts endpoint audit-message results to messages sent before this ISO 8601 date/time. Omitting this may return a large result set.")] string? timeSentTo = null, CancellationToken cancellationToken = default) { logger.LogInformation("MCP GetAuditMessagesByEndpoint invoked (endpoint={EndpointName}, keyword={Keyword}, page={Page})", endpointName, keyword, page); @@ -130,15 +126,14 @@ public async Task GetAuditMessagesByEndpoint( }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to trace the full chain of messages triggered by an initial message. " + - "Good for questions like: 'what happened after this message was sent?', 'show me the full message flow', or 'trace this conversation'. " + - "A conversation groups all related messages together — the original command and every event, reply, or saga message it caused. " + - "You need a conversation ID, which you can get from any audit message query result. " + - "Essential for understanding message flow and debugging cascading issues." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve all audit messages belonging to a conversation. " + + "Use this to trace the full flow of a message or business process across multiple endpoints. " + + "Prefer this tool when you already have a conversation ID. " + + "Read-only." )] public async Task GetAuditMessagesByConversation( - [Description("The conversation ID from a previous audit message query result")] string conversationId, + [Description("The conversation ID from a previous audit message query result.")] string conversationId, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, processed_at, message_type, critical_time, delivery_time, or processing_time")] string sort = "time_sent", @@ -161,15 +156,14 @@ public async Task GetAuditMessagesByConversation( }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to inspect the actual payload of a processed message. " + - "Good for questions like: 'show me the message body', 'what data was in this message?', or 'let me see the content of message X'. " + - "Returns the serialized message body content, typically JSON. " + - "You need a message ID, which you can get from any audit message query result. " + - "Use this when the user wants to see what data was actually sent, not just message metadata." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve the body content of a specific audit message. " + + "Use this when you need to inspect message payload or data for debugging. " + + "Typically used after locating a message via search or browsing tools. " + + "Read-only." )] public async Task GetAuditMessageBody( - [Description("The message ID from a previous audit message query result")] string messageId, + [Description("The audit message ID from a previous audit message query result.")] string messageId, CancellationToken cancellationToken = default) { logger.LogInformation("MCP GetAuditMessageBody invoked (messageId={MessageId})", messageId); diff --git a/src/ServiceControl.Audit/Mcp/EndpointTools.cs b/src/ServiceControl.Audit/Mcp/EndpointTools.cs index cc15e03b43..00b952db9b 100644 --- a/src/ServiceControl.Audit/Mcp/EndpointTools.cs +++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs @@ -11,18 +11,17 @@ namespace ServiceControl.Audit.Mcp; using Persistence; [McpServerToolType, Description( - "Tools for discovering and inspecting NServiceBus endpoints.\n\n" + + "Read-only tools for discovering and inspecting NServiceBus endpoints.\n\n" + "Agent guidance:\n" + "1. Use GetKnownEndpoints to discover endpoint names before calling endpoint-specific tools.\n" + "2. Use GetEndpointAuditCounts to spot throughput trends, traffic spikes, or drops in activity." )] public class EndpointTools(IAuditDataStore store, ILogger logger) { - [McpServerTool, Description( - "Use this tool to discover what NServiceBus endpoints exist in the system. " + - "Good for questions like: 'what endpoints do we have?', 'what services are running?', or 'list all endpoints'. " + - "Returns all endpoints that have processed audit messages, including their name and host information. " + - "This is a good starting point when you need an endpoint name for other tools like GetAuditMessagesByEndpoint or GetEndpointAuditCounts." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "List all known endpoints that have sent or received audit messages. " + + "Use this as a starting point to discover available endpoints before exploring their activity. " + + "Read-only." )] public async Task GetKnownEndpoints(CancellationToken cancellationToken = default) { @@ -39,14 +38,14 @@ public async Task GetKnownEndpoints(CancellationToken cancellationToken }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to see daily message volume trends for a specific endpoint. " + - "Good for questions like: 'how much traffic does Sales handle?', 'has throughput changed recently?', or 'show me message counts for this endpoint'. " + - "Returns message counts per day, which helps identify throughput changes, traffic spikes, or drops in activity that might indicate problems. " + - "You need an endpoint name — use GetKnownEndpoints first if you do not have one." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve daily audit-message counts for a specific endpoint. " + + "Use this when checking throughput or activity trends for one endpoint. " + + "Prefer GetKnownEndpoints when you do not already know the endpoint name. " + + "Read-only." )] public async Task GetEndpointAuditCounts( - [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, + [Description("The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.")] string endpointName, CancellationToken cancellationToken = default) { logger.LogInformation("MCP GetEndpointAuditCounts invoked (endpoint={EndpointName})", endpointName); diff --git a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs new file mode 100644 index 0000000000..e8c03df00b --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -0,0 +1,150 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using ServiceControl.Mcp; +using DescriptionAttribute = System.ComponentModel.DescriptionAttribute; + +[TestFixture] +class McpMetadataDescriptionsTests +{ + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))] + public void Mutating_tools_explicitly_warn_that_they_change_system_state(Type toolType, string methodName) + { + var description = GetMethodDescription(toolType, methodName); + + Assert.That(description, Does.Contain("changes system state")); + } + + [Test] + public void Retry_all_failed_messages_warns_that_it_affects_all_unresolved_failed_messages() + { + var description = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages)); + + Assert.That(description, Does.Contain("all unresolved failed messages across the instance")); + } + + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))] + public void Bulk_mutating_tools_warn_that_they_may_affect_many_messages(Type toolType, string methodName) + { + var description = GetMethodDescription(toolType, methodName); + + Assert.That(description, Does.Contain("may affect many messages")); + } + + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById), "failedMessageId", "failed message ID")] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt), "failedMessageId", "failed message ID")] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage), "failedMessageId", "failed message ID")] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages), "messageIds", "failed message IDs")] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint), "endpointName", "endpoint name")] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint), "endpointName", "endpoint name")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup), "groupId", "failure group ID")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup), "groupId", "failure group ID")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage), "failedMessageId", "failed message ID")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages), "messageIds", "failed message IDs")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage), "failedMessageId", "failed message ID")] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages), "messageIds", "failed message IDs")] + public void Key_error_tool_parameters_identify_the_entity_type(Type toolType, string methodName, string parameterName, string expectedPhrase) + { + var description = GetParameterDescription(toolType, methodName, parameterName); + + Assert.That(description, Does.Contain(expectedPhrase)); + } + + [Test] + public void Get_failed_messages_guides_agents_toward_groups_first_and_details_second() + { + var description = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages)); + + Assert.Multiple(() => + { + Assert.That(description, Does.Contain("Retrieve failed messages")); + Assert.That(description, Does.Contain("root-cause analysis")); + Assert.That(description, Does.Contain("GetFailureGroups")); + Assert.That(description, Does.Contain("GetFailedMessageById")); + }); + } + + [Test] + public void Get_failure_groups_is_positioned_as_root_cause_starting_point() + { + var description = GetMethodDescription(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups)); + + Assert.Multiple(() => + { + Assert.That(description, Does.Contain("Retrieve failure groups")); + Assert.That(description, Does.Contain("first step")); + Assert.That(description, Does.Contain("root cause")); + Assert.That(description, Does.Contain("GetFailedMessages")); + }); + } + + [Test] + public void Failed_message_detail_tools_reference_the_expected_workflow() + { + var byId = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById)); + var lastAttempt = GetMethodDescription(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt)); + + Assert.Multiple(() => + { + Assert.That(byId, Does.Contain("failed message ID")); + Assert.That(byId, Does.Contain("GetFailedMessages").Or.Contain("GetFailureGroups")); + + Assert.That(lastAttempt, Does.Contain("last processing attempt").Or.Contain("most recent failure")); + Assert.That(lastAttempt, Does.Contain("GetFailedMessages").Or.Contain("GetFailedMessageById")); + }); + } + + [Test] + public void Retry_tools_describe_targeted_group_and_broad_scenarios() + { + var retryByIds = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages)); + var retryGroup = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup)); + var retryAll = GetMethodDescription(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages)); + + Assert.Multiple(() => + { + Assert.That(retryByIds, Does.Contain("specific").And.Contain("RetryFailureGroup")); + + Assert.That(retryGroup, Does.Contain("root cause").And.Contain("RetryFailedMessages")); + + Assert.That(retryAll, Does.Contain("explicitly requests").And.Contain("narrower retry tools")); + Assert.That(retryAll, Does.Contain("large number of messages")); + }); + } + + static string GetMethodDescription(Type toolType, string methodName) + => toolType.GetMethod(methodName)! + .GetCustomAttribute()! + .Description; + + static string GetParameterDescription(Type toolType, string methodName, string parameterName) + => toolType.GetMethod(methodName)! + .GetParameters() + .Single(p => p.Name == parameterName) + .GetCustomAttribute()! + .Description; +} diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs index 2312145b8f..78f124d95d 100644 --- a/src/ServiceControl/Mcp/ArchiveTools.cs +++ b/src/ServiceControl/Mcp/ArchiveTools.cs @@ -14,23 +14,25 @@ namespace ServiceControl.Mcp; [McpServerToolType, Description( "Tools for archiving and unarchiving failed messages.\n\n" + "Agent guidance:\n" + - "1. Archiving dismisses a failed message — it moves out of the unresolved list and no longer counts as an active problem.\n" + - "2. Unarchiving restores a previously archived message back to the unresolved list so it can be retried.\n" + - "3. Prefer ArchiveFailureGroup or UnarchiveFailureGroup when acting on an entire failure group — it is more efficient than archiving messages individually.\n" + - "4. Use ArchiveFailedMessages or UnarchiveFailedMessages when you have a specific set of message IDs.\n" + - "5. All operations are asynchronous — they return Accepted immediately and complete in the background." + "1. Every tool in this group changes system state by archiving or restoring failed messages.\n" + + "2. Archiving dismisses a failed message — it moves out of the unresolved list and no longer counts as an active problem.\n" + + "3. Unarchiving restores a previously archived message back to the unresolved list so it can be retried.\n" + + "4. Prefer ArchiveFailureGroup or UnarchiveFailureGroup when acting on an entire failure group — it is more efficient than archiving messages individually.\n" + + "5. Use ArchiveFailedMessages or UnarchiveFailedMessages when you have a specific set of message IDs.\n" + + "6. All operations are asynchronous — they return Accepted immediately and complete in the background." )] public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver, ILogger logger) { - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to dismiss a single failed message that does not need to be retried. " + + "This operation changes system state. " + "Good for questions like: 'archive this message', 'dismiss this failure', or 'I do not need to retry this one'. " + "Archiving moves the message out of the unresolved list so it no longer shows up as an active problem. " + "This is an asynchronous operation — the message will be archived shortly after the request is accepted. " + "If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead." )] public async Task ArchiveFailedMessage( - [Description("The unique message ID from a previous query result")] string failedMessageId) + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP ArchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -38,13 +40,15 @@ public async Task ArchiveFailedMessage( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to dismiss multiple failed messages at once that do not need to be retried. " + + "This operation changes system state. " + + "It may affect many messages. " + "Good for questions like: 'archive these messages', 'dismiss these failures', or 'archive messages msg-1, msg-2, msg-3'. " + "Prefer ArchiveFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to archive." )] public async Task ArchiveFailedMessages( - [Description("The unique message IDs from a previous query result")] string[] messageIds) + [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds) { logger.LogInformation("MCP ArchiveFailedMessages invoked (count={Count})", messageIds.Length); @@ -61,15 +65,17 @@ public async Task ArchiveFailedMessages( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for {messageIds.Length} messages." }, McpJsonOptions.Default); } - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to dismiss an entire failure group — all messages that failed with the same exception type and stack trace. " + + "This operation changes system state. " + + "It may affect many messages. " + "Good for questions like: 'archive this failure group', 'dismiss all NullReferenceException failures', or 'archive the whole group'. " + "This is the most efficient way to archive many related failures at once. " + - "You need a group ID, which you can get from GetFailureGroups. " + + "You need a failure group ID, which you can get from GetFailureGroups. " + "Returns InProgress if an archive operation is already running for this group." )] public async Task ArchiveFailureGroup( - [Description("The failure group ID from get_failure_groups results")] string groupId) + [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP ArchiveFailureGroup invoked (groupId={GroupId})", groupId); @@ -85,14 +91,15 @@ public async Task ArchiveFailureGroup( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. " + + "This operation changes system state. " + "Good for questions like: 'unarchive this message', 'restore this failure', or 'I need to retry this archived message'. " + "Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. " + "If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead." )] public async Task UnarchiveFailedMessage( - [Description("The unique message ID to restore")] string failedMessageId) + [Description("The failed message ID to restore from the archived state.")] string failedMessageId) { logger.LogInformation("MCP UnarchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -100,13 +107,15 @@ public async Task UnarchiveFailedMessage( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to restore multiple previously archived failed messages back to the unresolved list. " + + "This operation changes system state. " + + "It may affect many messages. " + "Good for questions like: 'unarchive these messages', 'restore these failures', or 'unarchive messages msg-1, msg-2, msg-3'. " + "Prefer UnarchiveFailureGroup when restoring an entire group — use this tool when you have a specific set of message IDs." )] public async Task UnarchiveFailedMessages( - [Description("The unique message IDs to restore")] string[] messageIds) + [Description("The failed message IDs to restore from the archived state.")] string[] messageIds) { logger.LogInformation("MCP UnarchiveFailedMessages invoked (count={Count})", messageIds.Length); @@ -120,15 +129,17 @@ public async Task UnarchiveFailedMessages( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for {messageIds.Length} messages." }, McpJsonOptions.Default); } - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to restore an entire archived failure group back to the unresolved list. " + + "This operation changes system state. " + + "It may affect many messages. " + "Good for questions like: 'unarchive this failure group', 'restore all archived NullReferenceException failures', or 'unarchive the whole group'. " + "All messages that were archived together under this group will become available for retry again. " + - "You need a group ID, which you can get from GetFailureGroups. " + + "You need a failure group ID, which you can get from GetFailureGroups. " + "Returns InProgress if an unarchive operation is already running for this group." )] public async Task UnarchiveFailureGroup( - [Description("The failure group ID from get_failure_groups results")] string groupId) + [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP UnarchiveFailureGroup invoked (groupId={GroupId})", groupId); diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs index 57c30fe11d..a2c722dcd8 100644 --- a/src/ServiceControl/Mcp/FailedMessageTools.cs +++ b/src/ServiceControl/Mcp/FailedMessageTools.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Mcp; using Persistence.Infrastructure; [McpServerToolType, Description( - "Tools for investigating failed messages.\n\n" + + "Read-only tools for investigating failed messages.\n\n" + "Agent guidance:\n" + "1. Start with GetErrorsSummary to get a quick health check of failure counts by status.\n" + "2. Use GetFailureGroups (from FailureGroupTools) to see failures grouped by root cause before drilling into individual messages.\n" + @@ -23,17 +23,17 @@ namespace ServiceControl.Mcp; )] public class FailedMessageTools(IErrorMessageDataStore store, ILogger logger) { - [McpServerTool, Description( - "Use this tool to browse failed messages when the user wants to see what is failing. " + - "Good for questions like: 'what messages are currently failing?', 'are there failures in a specific queue?', or 'what failed recently?'. " + - "Returns a paged list of failed messages with their status, exception details, and queue information. " + - "For broad requests, call with no parameters to get the most recent failures — only add filters when you need to narrow down results. " + - "Prefer GetFailedMessagesByEndpoint when the user mentions a specific endpoint." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve failed messages for investigation. " + + "Use this when exploring recent failures or narrowing down failures by queue, status, or time range. " + + "Prefer GetFailureGroups when starting root-cause analysis across many failures. " + + "Use GetFailedMessageById when inspecting a specific failed message. " + + "Read-only." )] public async Task GetFailedMessages( - [Description("Narrow results to a specific status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit to include all statuses.")] string? status = null, - [Description("Only return messages modified after this date (ISO 8601). Useful for checking recent failures.")] string? modified = null, - [Description("Only return messages from this queue address, e.g. 'Sales@machine'. Use when investigating a specific queue.")] string? queueAddress = null, + [Description("Filter failed messages by status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit this filter to include all statuses.")] string? status = null, + [Description("Restricts failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null, + [Description("Filter failed messages to a specific queue address, for example 'Sales@machine'. Omit this filter to include all queues.")] string? queueAddress = null, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", @@ -55,14 +55,14 @@ public async Task GetFailedMessages( }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to get the full details of a specific failed message, including all processing attempts and exception information. " + - "Good for questions like: 'show me details for this failed message', 'what exception caused this failure?', or 'how many times has this message failed?'. " + - "You need the message's unique ID, which you can get from GetFailedMessages or GetFailureGroups results. " + - "If you only need the most recent failure attempt, use GetFailedMessageLastAttempt instead — it returns less data." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Get detailed information about a specific failed message. " + + "Use this when you already know the failed message ID and need to inspect its contents or failure details. " + + "Use GetFailedMessages or GetFailureGroups to locate relevant messages before calling this tool. " + + "Read-only." )] public async Task GetFailedMessageById( - [Description("The unique message ID from a previous query result")] string failedMessageId) + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP GetFailedMessageById invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -77,14 +77,14 @@ public async Task GetFailedMessageById( return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to see how a specific message failed most recently. " + - "Good for questions like: 'what was the last error for this message?', 'show me the latest exception', or 'what happened on the last attempt?'. " + - "Returns the latest processing attempt with its exception, stack trace, and headers. " + - "Lighter than GetFailedMessageById when you only care about the most recent failure rather than the full history." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve the last processing attempt for a failed message. " + + "Use this to understand the most recent failure behavior, including exception details and processing context. " + + "Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. " + + "Read-only." )] public async Task GetFailedMessageLastAttempt( - [Description("The unique message ID from a previous query result")] string failedMessageId) + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP GetFailedMessageLastAttempt invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -99,8 +99,8 @@ public async Task GetFailedMessageLastAttempt( return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool as a quick health check to see how many messages are in each failure state. " + + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Read-only. Use this tool as a quick health check to see how many messages are in each failure state. " + "Good for questions like: 'how many errors are there?', 'what is the error situation?', or 'are there unresolved failures?'. " + "Returns counts for unresolved, archived, resolved, and retryissued statuses. " + "This is a good first tool to call when asked about the overall error situation before drilling into specific messages." @@ -113,16 +113,17 @@ public async Task GetErrorsSummary() return JsonSerializer.Serialize(result, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to see failed messages for a specific NServiceBus endpoint. " + - "Good for questions like: 'what is failing in the Sales endpoint?', 'show errors for Shipping', or 'are there failures in this endpoint?'. " + - "Returns the same paged failure data as GetFailedMessages but scoped to one endpoint. " + - "Prefer this tool over GetFailedMessages when the user mentions a specific endpoint name." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve failed messages for a specific endpoint. " + + "Use this when investigating failures in a named endpoint such as Billing or Sales. " + + "Prefer GetFailureGroups when you need root-cause analysis across many failures. " + + "Use GetFailedMessageLastAttempt after this when you need the most recent failure details for a specific message. " + + "Read-only." )] public async Task GetFailedMessagesByEndpoint( - [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName, - [Description("Narrow results to a specific status: unresolved, resolved, archived, or retryissued. Omit to include all.")] string? status = null, - [Description("Only return messages modified after this date (ISO 8601)")] string? modified = null, + [Description("The endpoint name that owns the failed messages. Use values obtained from endpoint-aware failed-message results.")] string endpointName, + [Description("Filter failed messages by status: unresolved, resolved, archived, or retryissued. Omit this filter to include all statuses for the endpoint.")] string? status = null, + [Description("Restricts endpoint failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, [Description("Sort by: time_sent, message_type, or time_of_failure")] string sort = "time_of_failure", diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs index 4fce32514f..3c10a8fe38 100644 --- a/src/ServiceControl/Mcp/FailureGroupTools.cs +++ b/src/ServiceControl/Mcp/FailureGroupTools.cs @@ -11,7 +11,7 @@ namespace ServiceControl.Mcp; using Recoverability; [McpServerToolType, Description( - "Tools for inspecting failure groups and retry history.\n\n" + + "Read-only tools for inspecting failure groups and retry history.\n\n" + "Agent guidance:\n" + "1. GetFailureGroups is usually the best starting point for diagnosing production issues — call it before drilling into individual messages.\n" + "2. Call GetFailureGroups with no parameters to use the default grouping by exception type and stack trace.\n" + @@ -19,16 +19,15 @@ namespace ServiceControl.Mcp; )] public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore, ILogger logger) { - [McpServerTool, Description( - "Use this tool to understand why messages are failing by seeing failures grouped by root cause. " + - "Good for questions like: 'why are messages failing?', 'what errors are happening?', 'group failures by exception', or 'what are the top failure causes?'. " + - "Each group represents a distinct exception type and stack trace, showing how many messages are affected and when failures started and last occurred. " + - "This is usually the best starting point for diagnosing production issues — call it before drilling into individual messages. " + - "Call with no parameters to use the default grouping by exception type and stack trace." + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Retrieve failure groups, where failed messages are grouped by exception type and stack trace. " + + "Use this as the first step when analyzing large numbers of failures to identify dominant root causes. " + + "Prefer GetFailedMessages when you need individual message details. " + + "Read-only." )] public async Task GetFailureGroups( [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace", - [Description("Only include groups matching this filter text")] string? classifierFilter = null) + [Description("Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.")] string? classifierFilter = null) { logger.LogInformation("MCP GetFailureGroups invoked (classifier={Classifier})", classifier); @@ -39,8 +38,8 @@ public async Task GetFailureGroups( return JsonSerializer.Serialize(results, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to check the history of retry operations. " + + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + "Read-only. Use this tool to check the history of retry operations. " + "Good for questions like: 'has someone already retried these?', 'what happened the last time we retried this group?', 'show retry history', or 'were any retries attempted today?'. " + "Returns which groups were retried, when, and whether the retries succeeded or failed. " + "Use this before retrying a group to avoid duplicate retry attempts." diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs index 6edd34a9d4..dafb0a4634 100644 --- a/src/ServiceControl/Mcp/RetryTools.cs +++ b/src/ServiceControl/Mcp/RetryTools.cs @@ -15,7 +15,7 @@ namespace ServiceControl.Mcp; [McpServerToolType, Description( "Tools for retrying failed messages.\n\n" + "Agent guidance:\n" + - "1. Retrying sends a failed message back to its original queue for reprocessing. Only retry after the underlying issue has been resolved.\n" + + "1. Every tool in this group changes system state by sending failed messages back for reprocessing. Only retry after the underlying issue has been resolved.\n" + "2. Prefer RetryFailureGroup when all messages share the same root cause — it is the most targeted approach.\n" + "3. Use RetryAllFailedMessagesByEndpoint when a bug in one endpoint has been fixed.\n" + "4. Use RetryFailedMessagesByQueue when a queue's consumer was down and is now back.\n" + @@ -24,14 +24,15 @@ namespace ServiceControl.Mcp; )] public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager, ILogger logger) { - [McpServerTool, Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( "Use this tool to reprocess a single failed message by sending it back to its original queue. " + + "This operation changes system state. " + "Good for questions like: 'retry this message', 'reprocess this failure', or 'send this message back for processing'. " + "The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. " + "If you need to retry many messages with the same root cause, use RetryFailureGroup instead." )] public async Task RetryFailedMessage( - [Description("The unique message ID from a previous query result")] string failedMessageId) + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP RetryFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -39,13 +40,16 @@ public async Task RetryFailedMessage( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for message '{failedMessageId}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to reprocess multiple specific failed messages at once. " + - "Good for questions like: 'retry these messages', 'reprocess messages msg-1, msg-2, msg-3', or 'retry this batch'. " + - "Prefer RetryFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to retry." + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + "Retry a selected set of failed messages by their IDs. " + + "Use this when the user explicitly wants to retry specific known messages. " + + "Prefer RetryFailureGroup when retrying all messages with the same root cause. " + + "This operation changes system state. " + + "It may affect many messages. " + + "Use values obtained from failed-message investigation tools." )] public async Task RetryFailedMessages( - [Description("The unique message IDs from a previous query result")] string[] messageIds) + [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds) { logger.LogInformation("MCP RetryFailedMessages invoked (count={Count})", messageIds.Length); @@ -59,13 +63,16 @@ public async Task RetryFailedMessages( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for {messageIds.Length} messages." }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to retry all unresolved failed messages from a specific queue. " + - "Good for questions like: 'retry all failures in the Sales queue', 'reprocess everything from this queue', or 'the queue consumer is back, retry its failures'. " + - "Useful when a queue's consumer was down or misconfigured and is now fixed. Only retries messages with unresolved status." + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + "Retry all unresolved failed messages from a specific queue. " + + "Use this when the user explicitly wants a queue-scoped retry after a queue or consumer issue is fixed. " + + "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " + + "This operation changes system state. " + + "It may affect many messages. " + + "Use the queue address from failed-message results." )] public async Task RetryFailedMessagesByQueue( - [Description("The full queue address including machine name, e.g. 'Sales@machine'")] string queueAddress) + [Description("Queue address whose unresolved failed messages should be retried. Use values obtained from failed-message results.")] string queueAddress) { logger.LogInformation("MCP RetryFailedMessagesByQueue invoked (queueAddress={QueueAddress})", queueAddress); @@ -77,10 +84,13 @@ await messageSession.SendLocal(m => return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all failed messages in queue '{queueAddress}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to retry every unresolved failed message across all queues and endpoints. " + - "Good for questions like: 'retry everything', 'reprocess all failures', or 'retry all failed messages'. " + - "This is a broad operation — prefer RetryFailedMessagesByQueue, RetryAllFailedMessagesByEndpoint, or RetryFailureGroup when you can scope the retry more narrowly." + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + "Retry all currently failed messages across all queues. " + + "Use only when the user explicitly requests a broad retry operation. " + + "Prefer narrower retry tools such as RetryFailureGroup or RetryFailedMessages when possible. " + + "This operation changes system state. " + + "It may affect many messages. " + + "It affects all unresolved failed messages across the instance and may affect a large number of messages." )] public async Task RetryAllFailedMessages() { @@ -90,13 +100,16 @@ public async Task RetryAllFailedMessages() return JsonSerializer.Serialize(new { Status = "Accepted", Message = "Retry requested for all failed messages." }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to retry all failed messages for a specific NServiceBus endpoint. " + - "Good for questions like: 'retry all failures in the Sales endpoint', 'the bug in Shipping is fixed, retry its failures', or 'reprocess all errors for this endpoint'. " + - "Useful when a bug in one endpoint has been fixed and all its failures should be reprocessed." + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + "Retry all failed messages for a specific endpoint. " + + "Use this when the user explicitly wants an endpoint-scoped retry after an endpoint-specific issue is fixed. " + + "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " + + "This operation changes system state. " + + "It may affect many messages. " + + "Use the endpoint name from failed-message results." )] public async Task RetryAllFailedMessagesByEndpoint( - [Description("The NServiceBus endpoint name, e.g. 'Sales' or 'Shipping.MessageHandler'")] string endpointName) + [Description("The endpoint name whose failed messages should be retried. Use values obtained from failed-message results.")] string endpointName) { logger.LogInformation("MCP RetryAllFailedMessagesByEndpoint invoked (endpoint={EndpointName})", endpointName); @@ -104,15 +117,17 @@ public async Task RetryAllFailedMessagesByEndpoint( return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all failed messages in endpoint '{endpointName}'." }, McpJsonOptions.Default); } - [McpServerTool, Description( - "Use this tool to retry all failed messages that share the same exception type and stack trace. " + - "Good for questions like: 'retry this failure group', 'the bug causing these NullReferenceExceptions is fixed, retry them', or 'retry all messages in this group'. " + - "This is the most targeted way to retry related failures after fixing a specific bug. " + - "You need a group ID, which you can get from GetFailureGroups. " + + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + "Retry all failed messages in a failure group that share the same root cause. " + + "Use this when multiple failures are caused by the same issue and can be retried together. " + + "Prefer RetryFailedMessages for more granular control. " + + "This operation changes system state. " + + "It may affect many messages. " + + "Use the failure group ID from GetFailureGroups. " + "Returns InProgress if a retry is already running for this group." )] public async Task RetryFailureGroup( - [Description("The failure group ID from get_failure_groups results")] string groupId) + [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP RetryFailureGroup invoked (groupId={GroupId})", groupId); From 06d9e45e60e4e012c97b3273ecf1f1bac576dd83 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 25 Mar 2026 16:54:43 +0100 Subject: [PATCH 26/29] Align wording order --- ...Should_list_primary_instance_tools.approved.txt | 4 ++-- .../Mcp/McpMetadataDescriptionsTests.cs | 14 ++++++++++++++ .../Mcp/McpMetadataDescriptionsTests.cs | 14 ++++++++++++++ src/ServiceControl/Mcp/FailedMessageTools.cs | 5 +++-- src/ServiceControl/Mcp/FailureGroupTools.cs | 5 +++-- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index 69d0e2b635..a93f0fa0aa 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -79,7 +79,7 @@ }, { "name": "get_errors_summary", - "description": "Read-only. Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages.", + "description": "Use this tool as a quick health check to see how many messages are in each failure state. Good for questions like: \u0027how many errors are there?\u0027, \u0027what is the error situation?\u0027, or \u0027are there unresolved failures?\u0027. Returns counts for unresolved, archived, resolved, and retryissued statuses. This is a good first tool to call when asked about the overall error situation before drilling into specific messages. Read-only.", "inputSchema": { "type": "object", "properties": {} @@ -300,7 +300,7 @@ }, { "name": "get_retry_history", - "description": "Read-only. Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts.", + "description": "Use this tool to check the history of retry operations. Good for questions like: \u0027has someone already retried these?\u0027, \u0027what happened the last time we retried this group?\u0027, \u0027show retry history\u0027, or \u0027were any retries attempted today?\u0027. Returns which groups were retried, when, and whether the retries succeeded or failed. Use this before retrying a group to avoid duplicate retry attempts. Read-only.", "inputSchema": { "type": "object", "properties": {} diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs index 42e6a6ff88..22c6063f87 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -26,6 +26,20 @@ public void Audit_query_tools_are_described_as_read_only(Type toolType, string m Assert.That(description, Does.Contain(expectedPhrase)); } + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody))] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints))] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts))] + public void Read_only_audit_tools_end_with_read_only_sentence(Type toolType, string methodName) + { + var description = GetMethodDescription(toolType, methodName); + + Assert.That(description, Does.EndWith("Read-only.")); + } + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody), "messageId", "audit message ID")] [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation), "conversationId", "conversation ID")] [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts), "endpointName", "NServiceBus endpoint name")] diff --git a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs index e8c03df00b..e4c283b93b 100644 --- a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -12,6 +12,20 @@ namespace ServiceControl.UnitTests.Mcp; [TestFixture] class McpMetadataDescriptionsTests { + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetErrorsSummary))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint))] + [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups))] + [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetRetryHistory))] + public void Read_only_primary_tools_end_with_read_only_sentence(Type toolType, string methodName) + { + var description = GetMethodDescription(toolType, methodName); + + Assert.That(description, Does.EndWith("Read-only.")); + } + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage))] [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))] [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))] diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs index a2c722dcd8..a3ecdbd57e 100644 --- a/src/ServiceControl/Mcp/FailedMessageTools.cs +++ b/src/ServiceControl/Mcp/FailedMessageTools.cs @@ -100,10 +100,11 @@ public async Task GetFailedMessageLastAttempt( } [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( - "Read-only. Use this tool as a quick health check to see how many messages are in each failure state. " + + "Use this tool as a quick health check to see how many messages are in each failure state. " + "Good for questions like: 'how many errors are there?', 'what is the error situation?', or 'are there unresolved failures?'. " + "Returns counts for unresolved, archived, resolved, and retryissued statuses. " + - "This is a good first tool to call when asked about the overall error situation before drilling into specific messages." + "This is a good first tool to call when asked about the overall error situation before drilling into specific messages. " + + "Read-only." )] public async Task GetErrorsSummary() { diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs index 3c10a8fe38..3f0d0e342f 100644 --- a/src/ServiceControl/Mcp/FailureGroupTools.cs +++ b/src/ServiceControl/Mcp/FailureGroupTools.cs @@ -39,10 +39,11 @@ public async Task GetFailureGroups( } [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( - "Read-only. Use this tool to check the history of retry operations. " + + "Use this tool to check the history of retry operations. " + "Good for questions like: 'has someone already retried these?', 'what happened the last time we retried this group?', 'show retry history', or 'were any retries attempted today?'. " + "Returns which groups were retried, when, and whether the retries succeeded or failed. " + - "Use this before retrying a group to avoid duplicate retry attempts." + "Use this before retrying a group to avoid duplicate retry attempts. " + + "Read-only." )] public async Task GetRetryHistory() { From 5a430987f295cdb9e12d799455f4b3559242184d Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 26 Mar 2026 09:21:01 +0100 Subject: [PATCH 27/29] Return typed structured content for MCP tools while preserving text compatibility for clients and inspectors. Update tests and approvals to verify output schemas, structured payloads, and text fallbacks end to end." This reverts commit 18faeecb09fd48951d2b0d4eafe248c9178f5b09. Add structured MCP responses and schemas Return typed structured content for MCP tools while preserving text compatibility for clients and inspectors. Update tests and approvals to verify output schemas, structured payloads, and text fallbacks end to end. --- .../Mcp/McpAcceptanceTestSupport.cs | 193 +++ .../ServiceControl.AcceptanceTesting.csproj | 3 +- ...d_list_primary_instance_tools.approved.txt | 1137 +++++++++++++++++ .../Mcp/When_mcp_server_is_enabled.cs | 127 +- ...ould_list_audit_message_tools.approved.txt | 1025 +++++++++++++++ ...ould_list_audit_message_tools.approved.txt | 1025 +++++++++++++++ .../Mcp/When_mcp_server_is_enabled.cs | 137 +- .../InMemoryAuditDataStore.cs | 7 +- .../RavenAuditDataStore.cs | 6 +- .../AuditTests.cs | 26 +- .../IAuditDataStore.cs | 3 +- .../Mcp/AuditMessageMcpToolsTests.cs | 61 +- .../Mcp/EndpointMcpToolsTests.cs | 28 +- .../Mcp/McpMetadataDescriptionsTests.cs | 21 +- .../Mcp/McpStructuredOutputReadinessTests.cs | 37 + .../HostApplicationBuilderExtensions.cs | 5 +- .../Mcp/AuditMessageTools.cs | 84 +- src/ServiceControl.Audit/Mcp/EndpointTools.cs | 29 +- .../Mcp/McpArchiveOperationResult.cs | 12 + .../Mcp/McpAuditMessageBodyResult.cs | 11 + .../Mcp/McpCollectionResult.cs | 9 + .../Mcp/McpErrorResult.cs | 6 + .../Mcp/McpErrorsSummaryResult.cs | 31 + .../Mcp/McpOperationResult.cs | 38 + .../Mcp/McpRetryOperationResult.cs | 12 + .../ErrorMessagesDataStore.cs | 45 +- .../ErrorMessageDataStoreTests.cs | 44 +- .../Mcp/ArchiveMcpToolsTests.cs | 65 +- .../Mcp/FailedMessageMcpToolsTests.cs | 131 +- .../Mcp/FailureGroupMcpToolsTests.cs | 26 +- .../Mcp/McpMetadataDescriptionsTests.cs | 7 +- .../Mcp/McpStructuredOutputReadinessTests.cs | 49 + .../Mcp/RetryMcpToolsTests.cs | 55 +- .../HostApplicationBuilderExtensions.cs | 7 +- src/ServiceControl/Mcp/ArchiveTools.cs | 46 +- src/ServiceControl/Mcp/FailedMessageTools.cs | 57 +- src/ServiceControl/Mcp/FailureGroupTools.cs | 14 +- .../Mcp/McpFailedMessageResult.cs | 132 ++ .../Mcp/McpFailedMessageViewResult.cs | 51 + src/ServiceControl/Mcp/RetryTools.cs | 42 +- 40 files changed, 4340 insertions(+), 504 deletions(-) create mode 100644 src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs create mode 100644 src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs create mode 100644 src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs create mode 100644 src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs create mode 100644 src/ServiceControl/Mcp/McpFailedMessageResult.cs create mode 100644 src/ServiceControl/Mcp/McpFailedMessageViewResult.cs diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs new file mode 100644 index 0000000000..2fd690ba5b --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs @@ -0,0 +1,193 @@ +namespace ServiceControl.AcceptanceTesting.Mcp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using NUnit.Framework; + +public static class McpAcceptanceTestSupport +{ + const string RequestedProtocolVersion = "2025-11-25"; + + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public static async Task InitializeMcpSession(HttpClient httpClient) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = RequestedProtocolVersion, + capabilities = new { }, + clientInfo = new { name = "test-client", version = "1.0" } + } + }) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Headers.Add("MCP-Protocol-Version", RequestedProtocolVersion); + return await httpClient.SendAsync(request); + } + + public static async Task InitializeAndGetSessionInfo(HttpClient httpClient) + { + var response = await InitializeMcpSession(httpClient); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var initializeResponse = JsonSerializer.Deserialize(await ReadMcpResponseJson(response), JsonOptions)!; + var protocolVersion = initializeResponse.Result.ProtocolVersion; + + if (!response.Headers.TryGetValues("mcp-session-id", out var values)) + { + return null; + } + + var sessionId = values.FirstOrDefault(); + if (sessionId == null) + { + return null; + } + + var initializedResponse = await SendInitializedNotification(httpClient, sessionId, protocolVersion); + if (!initializedResponse.IsSuccessStatusCode) + { + return null; + } + + return new McpSessionInfo(sessionId, protocolVersion); + } + + public static async Task SendMcpRequest(HttpClient httpClient, McpSessionInfo sessionInfo, string method, object @params) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 2, + method, + @params + }) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Headers.Add("mcp-session-id", sessionInfo.SessionId); + request.Headers.Add("MCP-Protocol-Version", sessionInfo.ProtocolVersion); + return await httpClient.SendAsync(request); + } + + public static async Task ReadMcpResponseJson(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (contentType == "text/event-stream") + { + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data: ")) + { + return line.Substring("data: ".Length); + } + } + } + + return body; + } + + public static McpListToolsResponse DeserializeListToolsResponse(string toolsJson) => + JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + + public static McpCallToolResponse DeserializeCallToolResponse(string toolResult) => + JsonSerializer.Deserialize(toolResult, JsonOptions)!; + + public static void AssertToolsHaveOutputSchema(IEnumerable tools) + { + foreach (var tool in tools) + { + Assert.That(tool.TryGetProperty("outputSchema", out var outputSchema), Is.True, $"Tool '{tool.GetProperty("name").GetString()}' should expose outputSchema."); + Assert.That(outputSchema.ValueKind, Is.EqualTo(JsonValueKind.Object), $"Tool '{tool.GetProperty("name").GetString()}' should expose object outputSchema."); + } + } + + public static void AssertStructuredToolResponse(string rawResponse, JsonElement structuredContent, IReadOnlyList content, Action assertStructuredContent) + { + Assert.That(structuredContent.ValueKind, Is.EqualTo(JsonValueKind.Object), rawResponse); + assertStructuredContent(structuredContent); + + Assert.That(content, Has.Count.GreaterThanOrEqualTo(1), rawResponse); + Assert.That(content[0].Type, Is.EqualTo("text"), rawResponse); + Assert.That(content[0].Text, Is.Not.Null.And.Not.Empty, rawResponse); + + using var textPayload = JsonDocument.Parse(content[0].Text); + Assert.That(JsonElement.DeepEquals(structuredContent, textPayload.RootElement), Is.True, $"text content should serialize the structured payload. Raw response: {rawResponse}"); + } + + static async Task SendInitializedNotification(HttpClient httpClient, string sessionId, string protocolVersion) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + method = "notifications/initialized" + }) + }; + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Headers.Add("mcp-session-id", sessionId); + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + return await httpClient.SendAsync(request); + } +} + +public record McpSessionInfo(string SessionId, string ProtocolVersion); + +public class McpListToolsResponse +{ + public McpListToolsResult Result { get; set; } +} + +public class McpListToolsResult +{ + public List Tools { get; set; } = []; +} + +public class McpCallToolResponse +{ + public McpCallToolResult Result { get; set; } +} + +public class McpCallToolResult +{ + public JsonElement StructuredContent { get; set; } + public List Content { get; set; } = []; +} + +public class McpContent +{ + public string Type { get; set; } + public string Text { get; set; } +} + +class McpInitializeResponse +{ + public McpInitializeResult Result { get; set; } +} + +class McpInitializeResult +{ + public string ProtocolVersion { get; set; } +} diff --git a/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj b/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj index 2f7909ed54..72118063e9 100644 --- a/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj +++ b/src/ServiceControl.AcceptanceTesting/ServiceControl.AcceptanceTesting.csproj @@ -18,7 +18,8 @@ + - \ No newline at end of file + diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt index a93f0fa0aa..c86d7ae23f 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_primary_instance_tools.approved.txt @@ -14,6 +14,31 @@ "failedMessageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -42,6 +67,31 @@ "messageIds" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -67,6 +117,31 @@ "groupId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -84,6 +159,23 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "unresolved": { + "type": "integer" + }, + "archived": { + "type": "integer" + }, + "resolved": { + "type": "integer" + }, + "retryIssued": { + "type": "integer" + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -109,6 +201,143 @@ "failedMessageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "processingAttempts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "messageMetadata": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + } + } + } + }, + "failureDetails": { + "type": [ + "object", + "null" + ], + "properties": { + "addressOfFailingEndpoint": { + "type": [ + "string", + "null" + ] + }, + "timeOfFailure": { + "type": "string", + "format": "date-time" + }, + "exception": { + "type": [ + "object", + "null" + ], + "properties": { + "exceptionType": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "stackTrace": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "attemptedAt": { + "type": "string", + "format": "date-time" + }, + "messageId": { + "type": "string" + }, + "body": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "failureGroups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "uniqueMessageId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "Unresolved", + "Resolved", + "RetryIssued", + "Archived" + ] + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -134,6 +363,145 @@ "failedMessageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "messageType": { + "type": "string" + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "isSystemMessage": { + "type": "boolean" + }, + "exception": { + "type": [ + "object", + "null" + ], + "properties": { + "exceptionType": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "stackTrace": { + "type": [ + "string", + "null" + ] + } + } + }, + "messageId": { + "type": "string" + }, + "numberOfProcessingAttempts": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "Unresolved", + "Resolved", + "RetryIssued", + "Archived" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "queueAddress": { + "type": "string" + }, + "timeOfFailure": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "edited": { + "type": "boolean" + }, + "editOf": { + "type": "string" + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -196,6 +564,168 @@ } } }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "isSystemMessage": { + "type": "boolean" + }, + "exception": { + "type": [ + "object", + "null" + ], + "properties": { + "exceptionType": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "stackTrace": { + "type": [ + "string", + "null" + ] + } + } + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "numberOfProcessingAttempts": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "Unresolved", + "Resolved", + "RetryIssued", + "Archived" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "queueAddress": { + "type": [ + "string", + "null" + ] + }, + "timeOfFailure": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "edited": { + "type": "boolean" + }, + "editOf": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -257,6 +787,168 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "isSystemMessage": { + "type": "boolean" + }, + "exception": { + "type": [ + "object", + "null" + ], + "properties": { + "exceptionType": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "stackTrace": { + "type": [ + "string", + "null" + ] + } + } + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "numberOfProcessingAttempts": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "Unresolved", + "Resolved", + "RetryIssued", + "Archived" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "queueAddress": { + "type": [ + "string", + "null" + ] + }, + "timeOfFailure": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "edited": { + "type": "boolean" + }, + "editOf": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -288,6 +980,107 @@ } } }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "count": { + "type": "integer" + }, + "operationMessagesCompletedCount": { + "type": [ + "integer", + "null" + ] + }, + "comment": { + "type": [ + "string", + "null" + ] + }, + "first": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "operationStatus": { + "type": [ + "string", + "null" + ] + }, + "operationFailed": { + "type": [ + "boolean", + "null" + ] + }, + "operationProgress": { + "type": "number" + }, + "operationRemainingCount": { + "type": [ + "integer", + "null" + ] + }, + "operationStartTime": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "operationCompletionTime": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "needUserAcknowledgement": { + "type": "boolean" + } + } + } + } + }, + "required": [ + "result" + ] + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -305,6 +1098,125 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "historicOperations": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "requestId": { + "type": [ + "string", + "null" + ] + }, + "retryType": { + "type": "string", + "enum": [ + "Unknown", + "SingleMessage", + "FailureGroup", + "MultipleMessages", + "AllForEndpoint", + "All", + "ByQueueAddress" + ] + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "completionTime": { + "type": "string", + "format": "date-time" + }, + "originator": { + "type": [ + "string", + "null" + ] + }, + "failed": { + "type": "boolean" + }, + "numberOfMessagesProcessed": { + "type": "integer" + } + } + } + }, + "unacknowledgedOperations": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "requestId": { + "type": [ + "string", + "null" + ] + }, + "retryType": { + "type": "string", + "enum": [ + "Unknown", + "SingleMessage", + "FailureGroup", + "MultipleMessages", + "AllForEndpoint", + "All", + "ByQueueAddress" + ] + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "completionTime": { + "type": "string", + "format": "date-time" + }, + "last": { + "type": "string", + "format": "date-time" + }, + "originator": { + "type": [ + "string", + "null" + ] + }, + "classifier": { + "type": [ + "string", + "null" + ] + }, + "failed": { + "type": "boolean" + }, + "numberOfMessagesProcessed": { + "type": "integer" + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -322,6 +1234,31 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -347,6 +1284,31 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -372,6 +1334,31 @@ "failedMessageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -400,6 +1387,31 @@ "messageIds" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -425,6 +1437,31 @@ "queueAddress" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -450,6 +1487,31 @@ "groupId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -475,6 +1537,31 @@ "failedMessageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -503,6 +1590,31 @@ "messageIds" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, @@ -528,6 +1640,31 @@ "groupId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "Accepted", + "InProgress", + "ValidationError" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": true, "idempotentHint": false, diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index c3928caa07..82cdb50c3b 100644 --- a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -4,13 +4,13 @@ namespace ServiceControl.AcceptanceTests.Mcp; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; using AcceptanceTesting; using NServiceBus.AcceptanceTesting; using NUnit.Framework; using Particular.Approvals; +using ServiceControl.AcceptanceTesting.Mcp; [TestFixture] class When_mcp_server_is_enabled : AcceptanceTest @@ -38,13 +38,13 @@ public async Task Should_list_primary_instance_tools() await Define() .Done(async _ => { - var sessionId = await InitializeAndGetSessionId(); - if (sessionId == null) + var session = await InitializeAndGetSessionInfo(); + if (session == null) { return false; } - var response = await SendMcpRequest(sessionId, "tools/list", new { }); + var response = await SendMcpRequest(session, "tools/list", new { }); if (response == null) { return false; @@ -56,96 +56,73 @@ await Define() .Run(); Assert.That(toolsJson, Is.Not.Null); - var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(toolsJson); var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); + AssertPrimaryTools(sortedTools); + McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools); var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Approver.Verify(formattedTools); } - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpListToolsResponse + [Test] + public async Task Should_call_get_errors_summary_tool() { - public McpListToolsResult Result { get; set; } - } + string toolResult = null; - class McpListToolsResult - { - public List Tools { get; set; } = []; - } - - async Task InitializeMcpSession() - { - var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") - { - Content = JsonContent.Create(new + await Define() + .Done(async _ => { - jsonrpc = "2.0", - id = 1, - method = "initialize", - @params = new + var session = await InitializeAndGetSessionInfo(); + if (session == null) + { + return false; + } + + var response = await SendMcpRequest(session, "tools/call", new + { + name = "get_errors_summary", + arguments = new { } + }); + + if (response == null || response.StatusCode != HttpStatusCode.OK) { - protocolVersion = "2025-03-26", - capabilities = new { }, - clientInfo = new { name = "test-client", version = "1.0" } + return false; } + + toolResult = await ReadMcpResponseJson(response); + return true; }) - }; - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); - return await HttpClient.SendAsync(request); + .Run(); + + Assert.That(toolResult, Is.Not.Null); + var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult); + McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent => + { + Assert.That(structuredContent.GetProperty("unresolved").GetInt32(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("archived").GetInt32(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("resolved").GetInt32(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("retryIssued").GetInt32(), Is.GreaterThanOrEqualTo(0)); + }); } - async Task InitializeAndGetSessionId() + static void AssertPrimaryTools(IReadOnlyCollection tools) { - var response = await InitializeMcpSession(); - if (response.StatusCode != HttpStatusCode.OK) - { - return null; - } + Assert.That(tools, Has.Count.EqualTo(19)); - if (response.Headers.TryGetValues("mcp-session-id", out var values)) - { - return values.FirstOrDefault(); - } + var names = tools.Select(tool => tool.GetProperty("name").GetString()).ToArray(); - return null; + Assert.That(names, Does.Contain("get_errors_summary")); + Assert.That(names, Does.Contain("get_failed_messages")); + Assert.That(names, Does.Contain("get_failure_groups")); + Assert.That(names, Does.Contain("retry_failed_messages")); + Assert.That(names, Does.Contain("archive_failed_messages")); } - static async Task ReadMcpResponseJson(HttpResponseMessage response) - { - var body = await response.Content.ReadAsStringAsync(); - var contentType = response.Content.Headers.ContentType?.MediaType; + Task InitializeMcpSession() => McpAcceptanceTestSupport.InitializeMcpSession(HttpClient); - if (contentType == "text/event-stream") - { - foreach (var line in body.Split('\n')) - { - if (line.StartsWith("data: ")) - { - return line.Substring("data: ".Length); - } - } - } + Task InitializeAndGetSessionInfo() => McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); - return body; - } + Task SendMcpRequest(McpSessionInfo sessionInfo, string method, object @params) => McpAcceptanceTestSupport.SendMcpRequest(HttpClient, sessionInfo, method, @params); - async Task SendMcpRequest(string sessionId, string method, object @params) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") - { - Content = JsonContent.Create(new - { - jsonrpc = "2.0", - id = 2, - method, - @params - }) - }; - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); - request.Headers.Add("mcp-session-id", sessionId); - return await HttpClient.SendAsync(request); - } + static Task ReadMcpResponseJson(HttpResponseMessage response) => McpAcceptanceTestSupport.ReadMcpResponseJson(response); } diff --git a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index f7706a55d0..727a791909 100644 --- a/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests.RavenDB/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -14,6 +14,32 @@ "messageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "contentType": { + "type": [ + "string", + "null" + ] + }, + "contentLength": { + "type": "integer" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -73,6 +99,236 @@ } } }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -118,6 +374,236 @@ "conversationId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -192,6 +678,236 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -217,6 +933,32 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "utcDate": { + "type": "string", + "format": "date-time" + }, + "count": { + "type": "integer" + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -234,6 +976,59 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "endpointDetails": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "hostDisplayName": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -295,6 +1090,236 @@ "query" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, diff --git a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt index f7706a55d0..727a791909 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt +++ b/src/ServiceControl.Audit.AcceptanceTests/ApprovalFiles/When_mcp_server_is_enabled.Should_list_audit_message_tools.approved.txt @@ -14,6 +14,32 @@ "messageId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "contentType": { + "type": [ + "string", + "null" + ] + }, + "contentLength": { + "type": "integer" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -73,6 +99,236 @@ } } }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -118,6 +374,236 @@ "conversationId" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -192,6 +678,236 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -217,6 +933,32 @@ "endpointName" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "utcDate": { + "type": "string", + "format": "date-time" + }, + "count": { + "type": "integer" + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -234,6 +976,59 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "endpointDetails": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "hostDisplayName": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, @@ -295,6 +1090,236 @@ "query" ] }, + "outputSchema": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer" + }, + "results": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "messageId": { + "type": [ + "string", + "null" + ] + }, + "messageType": { + "type": [ + "string", + "null" + ] + }, + "sendingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "receivingEndpoint": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "hostId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": [ + "string", + "null" + ] + } + } + }, + "timeSent": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "processedAt": { + "type": "string", + "format": "date-time" + }, + "criticalTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "processingTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "deliveryTime": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "isSystemMessage": { + "type": "boolean" + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "value" + ] + } + }, + "status": { + "type": "string", + "enum": [ + "Successful", + "ResolvedSuccessfully" + ] + }, + "messageIntent": { + "type": "string", + "enum": [ + "Send", + "Publish", + "Subscribe", + "Unsubscribe", + "Reply" + ] + }, + "bodyUrl": { + "type": [ + "string", + "null" + ] + }, + "bodySize": { + "type": "integer" + }, + "invokedSagas": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "originatesFromSaga": { + "type": [ + "object", + "null" + ], + "properties": { + "changeStatus": { + "type": [ + "string", + "null" + ] + }, + "sagaType": { + "type": [ + "string", + "null" + ] + }, + "sagaId": { + "type": "string", + "format": "uuid" + } + } + }, + "instanceId": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, "annotations": { "destructiveHint": false, "idempotentHint": true, diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 2c90c8b89b..84c593095a 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Audit.AcceptanceTests.Mcp; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; using AcceptanceTesting; @@ -16,6 +15,7 @@ namespace ServiceControl.Audit.AcceptanceTests.Mcp; using NServiceBus.Settings; using NUnit.Framework; using Particular.Approvals; +using ServiceControl.AcceptanceTesting.Mcp; class When_mcp_server_is_enabled : AcceptanceTest { @@ -42,13 +42,13 @@ public async Task Should_list_audit_message_tools() await Define() .Done(async _ => { - var sessionId = await InitializeAndGetSessionId(); - if (sessionId == null) + var session = await InitializeAndGetSessionInfo(); + if (session == null) { return false; } - var response = await SendMcpRequest(sessionId, "tools/list", new { }); + var response = await SendMcpRequest(session, "tools/list", new { }); if (response == null) { return false; @@ -60,8 +60,10 @@ await Define() .Run(); Assert.That(toolsJson, Is.Not.Null); - var mcpResponse = JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(toolsJson); var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); + AssertAuditTools(sortedTools); + McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools); var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); Approver.Verify(formattedTools); } @@ -87,13 +89,13 @@ public async Task Should_call_get_audit_messages_tool() return false; } - var sessionId = await InitializeAndGetSessionId(); - if (sessionId == null) + var session = await InitializeAndGetSessionInfo(); + if (session == null) { return false; } - var response = await SendMcpRequest(sessionId, "tools/call", new + var response = await SendMcpRequest(session, "tools/call", new { name = "get_audit_messages", arguments = new { includeSystemMessages = false, page = 1, perPage = 50 } @@ -110,118 +112,35 @@ public async Task Should_call_get_audit_messages_tool() .Run(); Assert.That(toolResult, Is.Not.Null); - var mcpResponse = JsonSerializer.Deserialize(toolResult, JsonOptions)!; - var textContent = mcpResponse.Result.Content[0].Text; - var messagesResult = JsonSerializer.Deserialize(textContent, JsonOptions)!; - Assert.That(messagesResult.TotalCount, Is.GreaterThanOrEqualTo(1)); - } - - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpListToolsResponse - { - public McpListToolsResult Result { get; set; } - } - - class McpListToolsResult - { - public List Tools { get; set; } = []; - } - - class McpCallToolResponse - { - public McpCallToolResult Result { get; set; } - } - - class McpCallToolResult - { - public List Content { get; set; } = []; - } - - class McpContent - { - public string Text { get; set; } - } - - class McpToolResult - { - public int TotalCount { get; set; } - } - - async Task InitializeMcpSession() - { - var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult); + McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent => { - Content = JsonContent.Create(new - { - jsonrpc = "2.0", - id = 1, - method = "initialize", - @params = new - { - protocolVersion = "2025-03-26", - capabilities = new { }, - clientInfo = new { name = "test-client", version = "1.0" } - } - }) - }; - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); - return await HttpClient.SendAsync(request); + Assert.That(structuredContent.GetProperty("totalCount").GetInt32(), Is.GreaterThanOrEqualTo(1)); + Assert.That(structuredContent.GetProperty("results").ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(structuredContent.GetProperty("results").GetArrayLength(), Is.GreaterThanOrEqualTo(1)); + }); } - async Task InitializeAndGetSessionId() + static void AssertAuditTools(IReadOnlyCollection tools) { - var response = await InitializeMcpSession(); - if (response.StatusCode != HttpStatusCode.OK) - { - return null; - } + Assert.That(tools, Has.Count.EqualTo(7)); - if (response.Headers.TryGetValues("mcp-session-id", out var values)) - { - return values.FirstOrDefault(); - } + var names = tools.Select(tool => tool.GetProperty("name").GetString()).ToArray(); - return null; + Assert.That(names, Does.Contain("get_audit_messages")); + Assert.That(names, Does.Contain("search_audit_messages")); + Assert.That(names, Does.Contain("get_audit_message_body")); + Assert.That(names, Does.Contain("get_known_endpoints")); + Assert.That(names, Does.Contain("get_endpoint_audit_counts")); } - static async Task ReadMcpResponseJson(HttpResponseMessage response) - { - var body = await response.Content.ReadAsStringAsync(); - var contentType = response.Content.Headers.ContentType?.MediaType; + Task InitializeMcpSession() => McpAcceptanceTestSupport.InitializeMcpSession(HttpClient); - if (contentType == "text/event-stream") - { - foreach (var line in body.Split('\n')) - { - if (line.StartsWith("data: ")) - { - return line.Substring("data: ".Length); - } - } - } + Task InitializeAndGetSessionInfo() => McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); - return body; - } + Task SendMcpRequest(McpSessionInfo sessionInfo, string method, object @params) => McpAcceptanceTestSupport.SendMcpRequest(HttpClient, sessionInfo, method, @params); - async Task SendMcpRequest(string sessionId, string method, object @params) - { - var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") - { - Content = JsonContent.Create(new - { - jsonrpc = "2.0", - id = 2, - method, - @params - }) - }; - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); - request.Headers.Add("mcp-session-id", sessionId); - return await HttpClient.SendAsync(request); - } + static Task ReadMcpResponseJson(HttpResponseMessage response) => McpAcceptanceTestSupport.ReadMcpResponseJson(response); public class Sender : EndpointConfigurationBuilder { diff --git a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs index 3632772f1f..6dd3d7f231 100644 --- a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs +++ b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryAuditDataStore.cs @@ -66,10 +66,14 @@ public async Task>> QueryMessages(string keyword } public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken) + => await QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken); + + public async Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken) { var messages = GetMessageIdsMatchingQuery(keyword); var matched = messageViews.Where(w => w.ReceivingEndpoint.Name == endpoint && messages.Contains(w.MessageId) && + (!w.IsSystemMessage || includeSystemMessages) && (timeSentRange == null || !timeSentRange.From.HasValue || w.TimeSent >= timeSentRange.From.Value) && (timeSentRange == null || !timeSentRange.To.HasValue || w.TimeSent <= timeSentRange.To.Value)) .ToList(); @@ -79,6 +83,7 @@ public async Task>> QueryMessagesByReceivingEndp public async Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken) { var matched = messageViews.Where(w => w.ReceivingEndpoint.Name == endpointName && + (!w.IsSystemMessage || includeSystemMessages) && (timeSentRange == null || !timeSentRange.From.HasValue || w.TimeSent >= timeSentRange.From.Value) && (timeSentRange == null || !timeSentRange.To.HasValue || w.TimeSent <= timeSentRange.To.Value)) .ToList(); @@ -270,4 +275,4 @@ object TryGet(Dictionary metadata, string key) List processedMessages; List sagaHistories; } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs index 8a465fb029..a84b0e008e 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenAuditDataStore.cs @@ -62,11 +62,15 @@ public async Task>> QueryMessages(string searchP } public async Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken) + => await QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken); + + public async Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange, CancellationToken cancellationToken) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var results = await session.Query(GetIndexName(isFullTextSearchEnabled)) .Statistics(out var stats) .Search(x => x.Query, keyword) + .IncludeSystemMessagesWhere(includeSystemMessages) .Where(m => m.ReceivingEndpointName == endpoint) .FilterBySentTimeRange(timeSentRange) .Sort(sortInfo) @@ -198,4 +202,4 @@ public async Task>> QueryAuditCounts(string endpoi bool isFullTextSearchEnabled = databaseConfiguration.EnableFullTextSearch; } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs index 52f360ae46..0f5d3e8018 100644 --- a/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests/AuditTests.cs @@ -8,6 +8,7 @@ using NServiceBus; using NUnit.Framework; using ServiceControl.Audit.Infrastructure; + using ServiceControl.Audit.Monitoring; [TestFixture] class AuditTests : PersistenceTestFixture @@ -77,6 +78,23 @@ await IngestProcessedMessagesAudits( Assert.That(queryResult.Results, Has.Count.EqualTo(2)); } + + [Test] + public async Task QueryMessagesByReceivingEndpoint_excludes_system_messages_unless_requested() + { + await IngestProcessedMessagesAudits( + MakeMessage(messageId: "business", processingEndpoint: "Sales", isSystemMessage: false), + MakeMessage(messageId: "system", processingEndpoint: "Sales", isSystemMessage: true) + ); + + var excluded = await DataStore.QueryMessagesByReceivingEndpoint(false, "Sales", new PagingInfo(), new SortInfo("message_id", "asc"), cancellationToken: TestContext.CurrentContext.CancellationToken); + var included = await DataStore.QueryMessagesByReceivingEndpoint(true, "Sales", new PagingInfo(), new SortInfo("message_id", "asc"), cancellationToken: TestContext.CurrentContext.CancellationToken); + + Assert.That(excluded.Results, Has.Count.EqualTo(1)); + Assert.That(excluded.Results[0].MessageId, Is.EqualTo("business")); + Assert.That(included.Results, Has.Count.EqualTo(2)); + } + [Test] public async Task Can_roundtrip_message_body() { @@ -234,7 +252,8 @@ ProcessedMessage MakeMessage( string conversationId = null, string processingEndpoint = null, DateTime? processingStarted = null, - string messageType = null + string messageType = null, + bool isSystemMessage = false ) { messageId ??= Guid.NewGuid().ToString(); @@ -249,10 +268,11 @@ ProcessedMessage MakeMessage( { "CriticalTime", TimeSpan.FromSeconds(5) }, { "ProcessingTime", TimeSpan.FromSeconds(1) }, { "DeliveryTime", TimeSpan.FromSeconds(4) }, - { "IsSystemMessage", false }, + { "IsSystemMessage", isSystemMessage }, { "MessageType", messageType }, { "IsRetried", false }, { "ConversationId", conversationId }, + { "ReceivingEndpoint", new EndpointDetails { Name = processingEndpoint } }, //{ "ContentLength", 10} }; @@ -283,4 +303,4 @@ async Task IngestProcessedMessagesAudits(params ProcessedMessage[] processedMess const int MAX_BODY_SIZE = 20536; } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs b/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs index bc7258ffc3..4dbffa341d 100644 --- a/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs +++ b/src/ServiceControl.Audit.Persistence/IAuditDataStore.cs @@ -16,10 +16,11 @@ public interface IAuditDataStore Task> QuerySagaHistoryById(Guid input, CancellationToken cancellationToken); Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default); Task>> QueryMessages(string searchParam, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default); + Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default); Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default); Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null, CancellationToken cancellationToken = default); Task>> QueryMessagesByConversationId(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, CancellationToken cancellationToken); Task GetMessageBody(string messageId, CancellationToken cancellationToken); Task>> QueryAuditCounts(string endpointName, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs index 6405534165..ee5c35317f 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/AuditMessageMcpToolsTests.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Audit.Auditing; @@ -14,7 +13,9 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using Audit.Monitoring; using Audit.Persistence; using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; using NUnit.Framework; +using ServiceControl.Infrastructure.Mcp; using ServiceControl.SagaAudit; [TestFixture] @@ -38,10 +39,10 @@ public async Task GetAuditMessages_returns_messages() new QueryStatsInfo("etag", 1)); var result = await tools.GetAuditMessages(); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response.TotalCount, Is.EqualTo(1)); - Assert.That(response.Results, Has.Count.EqualTo(1)); + Assert.That(result, Is.TypeOf>()); + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Results, Has.Count.EqualTo(1)); } [Test] @@ -82,6 +83,14 @@ public async Task GetAuditMessagesByEndpoint_with_keyword_uses_keyword_query() Assert.That(store.LastQueryByEndpointAndKeywordKeyword, Is.EqualTo("OrderPlaced")); } + [Test] + public async Task GetAuditMessagesByEndpoint_with_keyword_passes_includeSystemMessages() + { + await tools.GetAuditMessagesByEndpoint("Sales", keyword: "OrderPlaced", includeSystemMessages: true); + + Assert.That(store.LastQueryByEndpointAndKeywordIncludeSystemMessages, Is.True); + } + [Test] public async Task GetAuditMessagesByConversation_queries_by_conversation_id() { @@ -96,10 +105,10 @@ public async Task GetAuditMessageBody_returns_body_content() store.MessageBodyResult = MessageBodyView.FromString("{\"orderId\": 123}", "application/json", 16, "etag"); var result = await tools.GetAuditMessageBody("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.ContentType, Is.EqualTo("application/json")); - Assert.That(response.Body, Is.EqualTo("{\"orderId\": 123}")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.ContentType, Is.EqualTo("application/json")); + Assert.That(result.Body, Is.EqualTo("{\"orderId\": 123}")); } [Test] @@ -108,9 +117,9 @@ public async Task GetAuditMessageBody_returns_error_when_not_found() store.MessageBodyResult = MessageBodyView.NotFound(); var result = await tools.GetAuditMessageBody("msg-missing"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("not found")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.Error, Does.Contain("not found")); } [Test] @@ -119,29 +128,22 @@ public async Task GetAuditMessageBody_returns_error_when_no_content() store.MessageBodyResult = MessageBodyView.NoContent(); var result = await tools.GetAuditMessageBody("msg-empty"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - - Assert.That(response.Error, Does.Contain("no body content")); - } - - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - class McpToolResponse - { - public int TotalCount { get; set; } - public List Results { get; set; } = []; + Assert.That(result, Is.TypeOf()); + Assert.That(result.Error, Does.Contain("no body content")); } - class McpMessageBodyResponse + [TestCase(nameof(AuditMessageTools.GetAuditMessages))] + [TestCase(nameof(AuditMessageTools.SearchAuditMessages))] + [TestCase(nameof(AuditMessageTools.GetAuditMessagesByEndpoint))] + [TestCase(nameof(AuditMessageTools.GetAuditMessagesByConversation))] + [TestCase(nameof(AuditMessageTools.GetAuditMessageBody))] + public void Structured_tools_use_structured_content(string methodName) { - public string? ContentType { get; set; } - public int ContentLength { get; set; } - public string? Body { get; set; } - } + var method = typeof(AuditMessageTools).GetMethod(methodName)!; + var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!; - class McpErrorResponse - { - public string? Error { get; set; } + Assert.That(attribute.UseStructuredContent, Is.True); } class StubAuditDataStore : IAuditDataStore @@ -160,6 +162,7 @@ class StubAuditDataStore : IAuditDataStore public string? LastQueryByEndpointKeyword { get; private set; } public string? LastQueryByEndpointAndKeywordEndpoint { get; private set; } public string? LastQueryByEndpointAndKeywordKeyword { get; private set; } + public bool? LastQueryByEndpointAndKeywordIncludeSystemMessages { get; private set; } public string? LastConversationId { get; private set; } public Task>> GetMessages(bool includeSystemMessages, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) @@ -182,9 +185,13 @@ public Task>> QueryMessagesByReceivingEndpoint(b } public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => QueryMessagesByReceivingEndpointAndKeyword(false, endpoint, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken); + + public Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) { LastQueryByEndpointAndKeywordEndpoint = endpoint; LastQueryByEndpointAndKeywordKeyword = keyword; + LastQueryByEndpointAndKeywordIncludeSystemMessages = includeSystemMessages; return Task.FromResult(MessagesResult ?? EmptyMessagesResult); } diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs index e1c1b0eaf0..299d3c10ae 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/EndpointMcpToolsTests.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Audit.Auditing; @@ -14,7 +13,9 @@ namespace ServiceControl.Audit.UnitTests.Mcp; using Audit.Monitoring; using Audit.Persistence; using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; using NUnit.Framework; +using ServiceControl.Infrastructure.Mcp; using ServiceControl.SagaAudit; @@ -39,10 +40,10 @@ public async Task GetKnownEndpoints_returns_endpoints() new QueryStatsInfo("etag", 1)); var result = await tools.GetKnownEndpoints(); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response.TotalCount, Is.EqualTo(1)); - Assert.That(response.Results, Has.Count.EqualTo(1)); + Assert.That(result, Is.TypeOf>()); + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Results, Has.Count.EqualTo(1)); } [Test] @@ -53,18 +54,20 @@ public async Task GetEndpointAuditCounts_returns_counts() new QueryStatsInfo("etag", 1)); var result = await tools.GetEndpointAuditCounts("Sales"); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(result, Is.TypeOf>()); + Assert.That(result.TotalCount, Is.EqualTo(1)); Assert.That(store.LastAuditCountsEndpointName, Is.EqualTo("Sales")); } - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpToolResponse + [TestCase(nameof(EndpointTools.GetKnownEndpoints))] + [TestCase(nameof(EndpointTools.GetEndpointAuditCounts))] + public void Structured_tools_use_structured_content(string methodName) { - public int TotalCount { get; set; } - public List Results { get; set; } = []; + var method = typeof(EndpointTools).GetMethod(methodName)!; + var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!; + + Assert.That(attribute.UseStructuredContent, Is.True); } class StubAuditDataStore : IAuditDataStore @@ -91,6 +94,9 @@ public Task>> QueryMessages(string searchParam, public Task>> QueryMessagesByReceivingEndpoint(bool includeSystemMessages, string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + public Task>> QueryMessagesByReceivingEndpointAndKeyword(bool includeSystemMessages, string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + public Task>> QueryMessagesByReceivingEndpointAndKeyword(string endpoint, string keyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null, CancellationToken cancellationToken = default) => Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs index 22c6063f87..e24b8b4668 100644 --- a/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs +++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -12,20 +12,6 @@ namespace ServiceControl.Audit.UnitTests.Mcp; [TestFixture] class McpMetadataDescriptionsTests { - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages), "Read-only")] - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages), "Read-only")] - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint), "Read-only")] - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation), "Read-only")] - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody), "Read-only")] - [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints), "Read-only")] - [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts), "Read-only")] - public void Audit_query_tools_are_described_as_read_only(Type toolType, string methodName, string expectedPhrase) - { - var description = GetMethodDescription(toolType, methodName); - - Assert.That(description, Does.Contain(expectedPhrase)); - } - [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages))] [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages))] [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint))] @@ -77,13 +63,16 @@ public void Audit_tools_distinguish_browse_search_trace_and_payload_scenarios() }); } + static MethodInfo GetMethod(Type toolType, string methodName) + => toolType.GetMethod(methodName)!; + static string GetMethodDescription(Type toolType, string methodName) - => toolType.GetMethod(methodName)! + => GetMethod(toolType, methodName) .GetCustomAttribute()! .Description; static string GetParameterDescription(Type toolType, string methodName, string parameterName) - => toolType.GetMethod(methodName)! + => GetMethod(toolType, methodName) .GetParameters() .Single(p => p.Name == parameterName) .GetCustomAttribute()! diff --git a/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs b/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs new file mode 100644 index 0000000000..99299646a4 --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs @@ -0,0 +1,37 @@ +#nullable enable + +namespace ServiceControl.Audit.UnitTests.Mcp; + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Audit.Mcp; +using ModelContextProtocol.Server; +using NUnit.Framework; + +[TestFixture] +class McpStructuredOutputReadinessTests +{ + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessages))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.SearchAuditMessages))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByEndpoint))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessagesByConversation))] + [TestCase(typeof(AuditMessageTools), nameof(AuditMessageTools.GetAuditMessageBody))] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetKnownEndpoints))] + [TestCase(typeof(EndpointTools), nameof(EndpointTools.GetEndpointAuditCounts))] + public void Migrated_audit_mcp_tools_opt_into_structured_content_and_do_not_return_task_of_string(Type toolType, string methodName) + { + var method = GetMethod(toolType, methodName); + var attribute = method.GetCustomAttribute(); + + Assert.Multiple(() => + { + Assert.That(attribute, Is.Not.Null, $"Expected {toolType.Name}.{methodName} to have an {nameof(McpServerToolAttribute)}."); + Assert.That(attribute!.UseStructuredContent, Is.True); + Assert.That(method.ReturnType, Is.Not.EqualTo(typeof(Task))); + }); + } + + static MethodInfo GetMethod(Type toolType, string methodName) + => toolType.GetMethod(methodName)!; +} diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index f650640314..34d45acbb5 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -13,6 +13,9 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde { if (settings.EnableMcpServer) { + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services .AddMcpServer() .WithHttpTransport() @@ -36,4 +39,4 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs index 6fb5548891..392925f7f5 100644 --- a/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs +++ b/src/ServiceControl.Audit/Mcp/AuditMessageTools.cs @@ -3,13 +3,15 @@ namespace ServiceControl.Audit.Mcp; using System.ComponentModel; -using System.Text.Json; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Auditing.MessagesView; using Infrastructure; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; +using ServiceControl.Infrastructure.Mcp; [McpServerToolType, Description( "Read-only tools for exploring audit messages.\n\n" + @@ -23,13 +25,13 @@ namespace ServiceControl.Audit.Mcp; )] public class AuditMessageTools(IAuditDataStore store, ILogger logger) { - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve audit messages with paging and sorting. " + "Use this to browse recent message activity or explore message flow over time. " + "Prefer SearchAuditMessages when looking for specific keywords or content. " + "Read-only." )] - public async Task GetAuditMessages( + public async Task> GetAuditMessages( [Description("Set to true to include NServiceBus infrastructure messages. Leave this as false for the usual business-message view.")] bool includeSystemMessages = false, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, @@ -49,20 +51,20 @@ public async Task GetAuditMessages( logger.LogInformation("MCP GetAuditMessages returned {Count} results", results.QueryStats.TotalCount); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Search audit messages by keyword across message content and metadata. " + "Use this when trying to locate messages related to a specific business identifier or text. " + "Prefer GetAuditMessages for general browsing or timeline exploration. " + "Read-only." )] - public async Task SearchAuditMessages( + public async Task> SearchAuditMessages( [Description("The free-text search query to match against audit message body content, headers, and metadata.")] string query, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, @@ -82,20 +84,20 @@ public async Task SearchAuditMessages( logger.LogInformation("MCP SearchAuditMessages returned {Count} results", results.QueryStats.TotalCount); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve audit messages processed by a specific endpoint. " + "Use this to understand activity and behavior of a single endpoint. " + "Prefer GetAuditMessagesByConversation when tracing a specific message flow. " + "Read-only." )] - public async Task GetAuditMessagesByEndpoint( + public async Task> GetAuditMessagesByEndpoint( [Description("The endpoint name that processed the audit messages. Use values obtained from GetKnownEndpoints.")] string endpointName, [Description("Optional keyword to narrow results within this endpoint. Omit it to browse the endpoint without full-text filtering.")] string? keyword = null, [Description("Set to true to include NServiceBus infrastructure messages for this endpoint. Leave false for the usual business-message view.")] bool includeSystemMessages = false, @@ -114,25 +116,25 @@ public async Task GetAuditMessagesByEndpoint( var timeSentRange = new DateTimeRange(timeSentFrom, timeSentTo); var results = keyword != null - ? await store.QueryMessagesByReceivingEndpointAndKeyword(endpointName, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken) + ? await store.QueryMessagesByReceivingEndpointAndKeyword(includeSystemMessages, endpointName, keyword, pagingInfo, sortInfo, timeSentRange, cancellationToken) : await store.QueryMessagesByReceivingEndpoint(includeSystemMessages, endpointName, pagingInfo, sortInfo, timeSentRange, cancellationToken); logger.LogInformation("MCP GetAuditMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve all audit messages belonging to a conversation. " + "Use this to trace the full flow of a message or business process across multiple endpoints. " + "Prefer this tool when you already have a conversation ID. " + "Read-only." )] - public async Task GetAuditMessagesByConversation( + public async Task> GetAuditMessagesByConversation( [Description("The conversation ID from a previous audit message query result.")] string conversationId, [Description("Page number, 1-based")] int page = 1, [Description("Results per page")] int perPage = 50, @@ -149,20 +151,20 @@ public async Task GetAuditMessagesByConversation( logger.LogInformation("MCP GetAuditMessagesByConversation returned {Count} results", results.QueryStats.TotalCount); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve the body content of a specific audit message. " + "Use this when you need to inspect message payload or data for debugging. " + "Typically used after locating a message via search or browsing tools. " + "Read-only." )] - public async Task GetAuditMessageBody( + public async Task GetAuditMessageBody( [Description("The audit message ID from a previous audit message query result.")] string messageId, CancellationToken cancellationToken = default) { @@ -173,30 +175,36 @@ public async Task GetAuditMessageBody( if (!result.Found) { logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' not found", messageId); - return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' not found." }, McpJsonOptions.Default); + return new McpAuditMessageBodyResult + { + Error = $"Message '{messageId}' not found." + }; } if (!result.HasContent) { logger.LogWarning("MCP GetAuditMessageBody: message '{MessageId}' has no body content", messageId); - return JsonSerializer.Serialize(new { Error = $"Message '{messageId}' has no body content." }, McpJsonOptions.Default); + return new McpAuditMessageBodyResult + { + Error = $"Message '{messageId}' has no body content." + }; } if (result.StringContent != null) { - return JsonSerializer.Serialize(new + return new McpAuditMessageBodyResult { - result.ContentType, - result.ContentLength, + ContentType = result.ContentType, + ContentLength = result.ContentLength, Body = result.StringContent - }, McpJsonOptions.Default); + }; } - return JsonSerializer.Serialize(new + return new McpAuditMessageBodyResult { - result.ContentType, - result.ContentLength, + ContentType = result.ContentType, + ContentLength = 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 index 00b952db9b..50d6d7da60 100644 --- a/src/ServiceControl.Audit/Mcp/EndpointTools.cs +++ b/src/ServiceControl.Audit/Mcp/EndpointTools.cs @@ -3,12 +3,15 @@ namespace ServiceControl.Audit.Mcp; using System.ComponentModel; -using System.Text.Json; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Auditing; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; +using Monitoring; using Persistence; +using ServiceControl.Infrastructure.Mcp; [McpServerToolType, Description( "Read-only tools for discovering and inspecting NServiceBus endpoints.\n\n" + @@ -18,12 +21,12 @@ namespace ServiceControl.Audit.Mcp; )] public class EndpointTools(IAuditDataStore store, ILogger logger) { - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "List all known endpoints that have sent or received audit messages. " + "Use this as a starting point to discover available endpoints before exploring their activity. " + "Read-only." )] - public async Task GetKnownEndpoints(CancellationToken cancellationToken = default) + public async Task> GetKnownEndpoints(CancellationToken cancellationToken = default) { logger.LogInformation("MCP GetKnownEndpoints invoked"); @@ -31,20 +34,20 @@ public async Task GetKnownEndpoints(CancellationToken cancellationToken logger.LogInformation("MCP GetKnownEndpoints returned {Count} endpoints", results.QueryStats.TotalCount); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve daily audit-message counts for a specific endpoint. " + "Use this when checking throughput or activity trends for one endpoint. " + "Prefer GetKnownEndpoints when you do not already know the endpoint name. " + "Read-only." )] - public async Task GetEndpointAuditCounts( + public async Task> GetEndpointAuditCounts( [Description("The NServiceBus endpoint name whose audit activity should be counted. Use values obtained from GetKnownEndpoints.")] string endpointName, CancellationToken cancellationToken = default) { @@ -54,10 +57,10 @@ public async Task GetEndpointAuditCounts( logger.LogInformation("MCP GetEndpointAuditCounts returned {Count} entries for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } } diff --git a/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs new file mode 100644 index 0000000000..b651381fdd --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpArchiveOperationResult.cs @@ -0,0 +1,12 @@ +#nullable enable + +namespace ServiceControl.Infrastructure.Mcp; + +public class McpArchiveOperationResult : McpOperationResult +{ + public static McpArchiveOperationResult Accepted(string message) => Accepted(message); + + public static McpArchiveOperationResult InProgress(string message) => InProgress(message); + + public static McpArchiveOperationResult ValidationError(string error) => ValidationError(error); +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs new file mode 100644 index 0000000000..dca4439762 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpAuditMessageBodyResult.cs @@ -0,0 +1,11 @@ +#nullable enable + +namespace ServiceControl.Infrastructure.Mcp; + +public class McpAuditMessageBodyResult +{ + public string? ContentType { get; init; } + public int ContentLength { get; init; } + public string? Body { get; init; } + public string? Error { get; init; } +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs new file mode 100644 index 0000000000..2bc5aac95b --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpCollectionResult.cs @@ -0,0 +1,9 @@ +namespace ServiceControl.Infrastructure.Mcp; + +using System.Collections.Generic; + +public class McpCollectionResult +{ + public int TotalCount { get; init; } + public IReadOnlyCollection Results { get; init; } = []; +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs new file mode 100644 index 0000000000..7730515e39 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpErrorResult.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Infrastructure.Mcp; + +public class McpErrorResult +{ + public string Error { get; init; } = string.Empty; +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs new file mode 100644 index 0000000000..48dcf4b1fa --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpErrorsSummaryResult.cs @@ -0,0 +1,31 @@ +#nullable enable + +namespace ServiceControl.Infrastructure.Mcp; + +using System.Collections.Generic; + +public class McpErrorsSummaryResult +{ + public long Unresolved { get; init; } + public long Archived { get; init; } + public long Resolved { get; init; } + public long RetryIssued { get; init; } + + public static McpErrorsSummaryResult From(IDictionary summary) + { + return new McpErrorsSummaryResult + { + Unresolved = GetCount(summary, "unresolved"), + Archived = GetCount(summary, "archived"), + Resolved = GetCount(summary, "resolved"), + RetryIssued = GetCount(summary, "retryissued") + }; + } + + static long GetCount(IDictionary summary, string key) + { + return summary.TryGetValue(key, out var value) + ? System.Convert.ToInt64(value) + : 0L; + } +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs new file mode 100644 index 0000000000..8c8c590ed6 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs @@ -0,0 +1,38 @@ +#nullable enable + +namespace ServiceControl.Infrastructure.Mcp; + +using System.Text.Json.Serialization; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum McpOperationStatus +{ + Accepted, + InProgress, + ValidationError +} + +public class McpOperationResult +{ + public McpOperationStatus Status { get; init; } + public string? Message { get; init; } + public string? Error { get; init; } + + protected static T Accepted(string message) where T : McpOperationResult, new() => new() + { + Status = McpOperationStatus.Accepted, + Message = message + }; + + protected static T InProgress(string message) where T : McpOperationResult, new() => new() + { + Status = McpOperationStatus.InProgress, + Message = message + }; + + protected static T ValidationError(string error) where T : McpOperationResult, new() => new() + { + Status = McpOperationStatus.ValidationError, + Error = error + }; +} diff --git a/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs new file mode 100644 index 0000000000..9d1336bf4e --- /dev/null +++ b/src/ServiceControl.Infrastructure/Mcp/McpRetryOperationResult.cs @@ -0,0 +1,12 @@ +#nullable enable + +namespace ServiceControl.Infrastructure.Mcp; + +public class McpRetryOperationResult : McpOperationResult +{ + public static McpRetryOperationResult Accepted(string message) => Accepted(message); + + public static McpRetryOperationResult InProgress(string message) => InProgress(message); + + public static McpRetryOperationResult ValidationError(string error) => ValidationError(error); +} diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index df59b8fdf9..cca2bc7e8b 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -285,33 +285,26 @@ SortInfo sortInfo public async Task> ErrorsSummary() { using var session = await sessionProvider.OpenSession(); - var facetResults = await session.Query() - .AggregateBy(new List - { - new Facet - { - FieldName = "Name", - DisplayFieldName = "Endpoints" - }, - new Facet - { - FieldName = "Host", - DisplayFieldName = "Hosts" - }, - new Facet - { - FieldName = "MessageType", - DisplayFieldName = "Message types" - } - }).ExecuteAsync(); - - var results = facetResults - .ToDictionary( - x => x.Key, - x => (object)x.Value - ); - return results; + var unresolvedCount = await session.Query() + .CountAsync(message => message.Status == FailedMessageStatus.Unresolved); + + var archivedCount = await session.Query() + .CountAsync(message => message.Status == FailedMessageStatus.Archived); + + var resolvedCount = await session.Query() + .CountAsync(message => message.Status == FailedMessageStatus.Resolved); + + var retryIssuedCount = await session.Query() + .CountAsync(message => message.Status == FailedMessageStatus.RetryIssued); + + return new Dictionary + { + ["unresolved"] = unresolvedCount, + ["archived"] = archivedCount, + ["resolved"] = resolvedCount, + ["retryissued"] = retryIssuedCount + }; } public Task ErrorBy(string failedMessageId) => ErrorByDocumentId(FailedMessageIdGenerator.MakeDocumentId(failedMessageId)); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs index 5ef3d88d91..e390ebf915 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs @@ -53,6 +53,48 @@ public async Task ErrorGet() Assert.That(result.Results, Is.Not.Empty); } + [Test] + public async Task ErrorsSummary_returns_counts_by_status() + { + using (var session = DocumentStore.OpenAsyncSession()) + { + await session.StoreAsync(FailedMessageBuilder.Default(m => + { + m.Id = "3"; + m.UniqueMessageId = "c"; + m.Status = FailedMessageStatus.Archived; + })); + + await session.StoreAsync(FailedMessageBuilder.Default(m => + { + m.Id = "4"; + m.UniqueMessageId = "d"; + m.Status = FailedMessageStatus.Resolved; + })); + + await session.StoreAsync(FailedMessageBuilder.Default(m => + { + m.Id = "5"; + m.UniqueMessageId = "e"; + m.Status = FailedMessageStatus.RetryIssued; + })); + + await session.SaveChangesAsync(); + } + + CompleteDatabaseOperation(); + + var result = await store.ErrorsSummary(); + + Assert.Multiple(() => + { + Assert.That(result["unresolved"], Is.EqualTo(2)); + Assert.That(result["archived"], Is.EqualTo(1)); + Assert.That(result["resolved"], Is.EqualTo(1)); + Assert.That(result["retryissued"], Is.EqualTo(1)); + }); + } + [SetUp] public async Task GetStore() { @@ -94,4 +136,4 @@ async Task GenerateAndSaveFailedMessage() await session.SaveChangesAsync(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs index 84d98811d2..3764dfe24f 100644 --- a/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/ArchiveMcpToolsTests.cs @@ -3,11 +3,11 @@ namespace ServiceControl.UnitTests.Mcp; using System.Collections.Generic; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using NServiceBus.Testing; using NUnit.Framework; +using ServiceControl.Infrastructure.Mcp; using ServiceControl.Mcp; using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; @@ -32,9 +32,10 @@ public void SetUp() public async Task ArchiveFailedMessage_returns_accepted() { var result = await tools.ArchiveFailedMessage("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Archive requested for message 'msg-1'.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -42,9 +43,10 @@ public async Task ArchiveFailedMessage_returns_accepted() public async Task ArchiveFailedMessages_returns_accepted() { var result = await tools.ArchiveFailedMessages(["msg-1", "msg-2"]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Archive requested for 2 messages.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(2)); } @@ -52,18 +54,20 @@ public async Task ArchiveFailedMessages_returns_accepted() public async Task ArchiveFailedMessages_rejects_empty_ids() { var result = await tools.ArchiveFailedMessages(["msg-1", ""]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("non-empty")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError)); + Assert.That(result.Message, Is.Null); + Assert.That(result.Error, Does.Contain("non-empty")); } [Test] public async Task ArchiveFailureGroup_returns_accepted() { var result = await tools.ArchiveFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Archive requested for all messages in failure group 'group-1'.")); + Assert.That(result.Error, Is.Null); } [Test] @@ -72,18 +76,20 @@ public async Task ArchiveFailureGroup_returns_in_progress_when_already_running() archiver.OperationInProgress = true; var result = await tools.ArchiveFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("InProgress")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress)); + Assert.That(result.Message, Is.EqualTo("An archive operation is already in progress for group 'group-1'.")); + Assert.That(result.Error, Is.Null); } [Test] public async Task UnarchiveFailedMessage_returns_accepted() { var result = await tools.UnarchiveFailedMessage("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Unarchive requested for message 'msg-1'.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -91,27 +97,30 @@ public async Task UnarchiveFailedMessage_returns_accepted() public async Task UnarchiveFailedMessages_returns_accepted() { var result = await tools.UnarchiveFailedMessages(["msg-1", "msg-2"]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Unarchive requested for 2 messages.")); + Assert.That(result.Error, Is.Null); } [Test] public async Task UnarchiveFailedMessages_rejects_empty_ids() { var result = await tools.UnarchiveFailedMessages(["msg-1", ""]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("non-empty")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError)); + Assert.That(result.Message, Is.Null); + Assert.That(result.Error, Does.Contain("non-empty")); } [Test] public async Task UnarchiveFailureGroup_returns_accepted() { var result = await tools.UnarchiveFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Unarchive requested for all messages in failure group 'group-1'.")); + Assert.That(result.Error, Is.Null); } [Test] @@ -120,22 +129,10 @@ public async Task UnarchiveFailureGroup_returns_in_progress_when_already_running archiver.OperationInProgress = true; var result = await tools.UnarchiveFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("InProgress")); - } - - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpStatusResponse - { - public string? Status { get; set; } - public string? Message { get; set; } - } - - class McpErrorResponse - { - public string? Error { get; set; } + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress)); + Assert.That(result.Message, Is.EqualTo("An unarchive operation is already in progress for group 'group-1'.")); + Assert.That(result.Error, Is.Null); } class StubArchiveMessages : IArchiveMessages diff --git a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs index 2a9c0b8c94..e81ffb2bc1 100644 --- a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs @@ -4,14 +4,18 @@ namespace ServiceControl.UnitTests.Mcp; using System; using System.Collections.Generic; -using System.Text.Json; +using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; using NUnit.Framework; +using ServiceControl.Contracts.Operations; using ServiceControl.CompositeViews.Messages; using ServiceControl.EventLog; using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Mcp; using ServiceControl.MessageFailures; using ServiceControl.MessageFailures.Api; using ServiceControl.Mcp; @@ -41,10 +45,11 @@ public async Task GetFailedMessages_returns_messages() new QueryStatsInfo("etag", 1, false)); var result = await tools.GetFailedMessages(); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response.TotalCount, Is.EqualTo(1)); - Assert.That(response.Results, Has.Count.EqualTo(1)); + Assert.That(result, Is.TypeOf>()); + + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Results, Has.Count.EqualTo(1)); } [Test] @@ -76,13 +81,52 @@ public async Task GetFailedMessageById_returns_message() { Id = "msg-1", UniqueMessageId = "unique-1", - Status = FailedMessageStatus.Unresolved + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + MessageId = "message-1", + Body = "body", + AttemptedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + FailureDetails = new FailureDetails + { + AddressOfFailingEndpoint = "Sales", + Exception = new ExceptionDetails { ExceptionType = "System.Exception", Message = "boom" }, + TimeOfFailure = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) + }, + Headers = new Dictionary { ["NServiceBus.MessageId"] = "message-1" }, + MessageMetadata = new Dictionary { ["Retries"] = 3 } + } + ], + FailureGroups = + [ + new FailedMessage.FailureGroup + { + Id = "group-1", + Title = "Unhandled exception", + Type = "Exception" + } + ] }; var result = await tools.GetFailedMessageById("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.UniqueMessageId, Is.EqualTo("unique-1")); + Assert.That(result, Is.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(result.Error, Is.Null); + Assert.That(result.UniqueMessageId, Is.EqualTo("unique-1")); + Assert.That(result.ProcessingAttempts, Has.Count.EqualTo(1)); + Assert.That(result.ProcessingAttempts[0].MessageId, Is.EqualTo("message-1")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata, Has.Count.EqualTo(1)); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Key, Is.EqualTo("Retries")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Value, Is.EqualTo("3")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Type, Is.EqualTo("integer")); + Assert.That(result.FailureGroups, Has.Count.EqualTo(1)); + Assert.That(result.FailureGroups[0].Id, Is.EqualTo("group-1")); + }); } [Test] @@ -91,9 +135,9 @@ public async Task GetFailedMessageById_returns_error_when_not_found() store.ErrorByResult = null; var result = await tools.GetFailedMessageById("msg-missing"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("not found")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.Error, Does.Contain("not found")); } [Test] @@ -107,9 +151,10 @@ public async Task GetFailedMessageLastAttempt_returns_view() }; var result = await tools.GetFailedMessageLastAttempt("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.MessageType, Is.EqualTo("MyMessage")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.Error, Is.Null); + Assert.That(result.MessageType, Is.EqualTo("MyMessage")); } [Test] @@ -118,9 +163,36 @@ public async Task GetFailedMessageLastAttempt_returns_error_when_not_found() store.ErrorLastByResult = null; var result = await tools.GetFailedMessageLastAttempt("msg-missing"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("not found")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.Error, Does.Contain("not found")); + } + + [Test] + public void GetFailedMessageById_returns_top_level_mcp_contract() + { + var method = typeof(FailedMessageTools).GetMethod(nameof(FailedMessageTools.GetFailedMessageById))!; + + Assert.That(method.ReturnType, Is.EqualTo(typeof(Task))); + } + + [Test] + public void GetFailedMessageLastAttempt_returns_top_level_mcp_contract() + { + var method = typeof(FailedMessageTools).GetMethod(nameof(FailedMessageTools.GetFailedMessageLastAttempt))!; + + Assert.That(method.ReturnType, Is.EqualTo(typeof(Task))); + } + + [Test] + public void Failed_message_detail_contract_uses_mcp_specific_nested_dtos() + { + Assert.Multiple(() => + { + Assert.That(GetGenericListArgument(typeof(McpFailedMessageResult), nameof(McpFailedMessageResult.ProcessingAttempts)), Is.EqualTo(typeof(McpFailedProcessingAttemptResult))); + Assert.That(GetGenericListArgument(typeof(McpFailedMessageResult), nameof(McpFailedMessageResult.FailureGroups)), Is.EqualTo(typeof(McpFailedFailureGroupResult))); + Assert.That(GetGenericListArgument(typeof(McpFailedProcessingAttemptResult), nameof(McpFailedProcessingAttemptResult.MessageMetadata)), Is.EqualTo(typeof(McpMessageMetadataEntryResult))); + }); } [Test] @@ -133,10 +205,12 @@ public async Task GetErrorsSummary_returns_summary() }; var result = await tools.GetErrorsSummary(); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response, Contains.Key("unresolved")); - Assert.That(response, Contains.Key("archived")); + Assert.That(result, Is.TypeOf()); + Assert.That(result.Unresolved, Is.EqualTo(5L)); + Assert.That(result.Archived, Is.EqualTo(3L)); + Assert.That(result.Resolved, Is.EqualTo(0L)); + Assert.That(result.RetryIssued, Is.EqualTo(0L)); } [Test] @@ -147,23 +221,23 @@ public async Task GetFailedMessagesByEndpoint_returns_messages() new QueryStatsInfo("etag", 1, false)); var result = await tools.GetFailedMessagesByEndpoint("Sales"); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response.TotalCount, Is.EqualTo(1)); + Assert.That(result, Is.TypeOf>()); + Assert.That(result.TotalCount, Is.EqualTo(1)); Assert.That(store.LastErrorsByEndpointName, Is.EqualTo("Sales")); } - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpToolResponse + [TestCase(nameof(FailedMessageTools.GetFailedMessages))] + [TestCase(nameof(FailedMessageTools.GetFailedMessageById))] + [TestCase(nameof(FailedMessageTools.GetFailedMessageLastAttempt))] + [TestCase(nameof(FailedMessageTools.GetErrorsSummary))] + [TestCase(nameof(FailedMessageTools.GetFailedMessagesByEndpoint))] + public void Structured_tools_use_structured_content(string methodName) { - public int TotalCount { get; set; } - public List Results { get; set; } = []; - } + var method = typeof(FailedMessageTools).GetMethod(methodName)!; + var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!; - class McpErrorResponse - { - public string? Error { get; set; } + Assert.That(attribute.UseStructuredContent, Is.True); } class StubErrorMessageDataStore : IErrorMessageDataStore @@ -227,4 +301,7 @@ public Task>> ErrorsByEndpointName(string s public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => throw new NotImplementedException(); public Task CreateNotificationsManager() => throw new NotImplementedException(); } + + static Type GetGenericListArgument(Type declaringType, string propertyName) => + declaringType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public)!.PropertyType.GetGenericArguments().Single(); } diff --git a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs index 5745ee095a..764162376e 100644 --- a/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/FailureGroupMcpToolsTests.cs @@ -4,9 +4,9 @@ namespace ServiceControl.UnitTests.Mcp; using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; using NUnit.Framework; using ServiceControl.Mcp; using ServiceControl.Persistence; @@ -42,11 +42,11 @@ public async Task GetFailureGroups_returns_groups() ]; var result = await tools.GetFailureGroups(); - var response = JsonSerializer.Deserialize>(result, JsonOptions)!; - Assert.That(response, Has.Count.EqualTo(1)); - Assert.That(response[0].Id, Is.EqualTo("group-1")); - Assert.That(response[0].Count, Is.EqualTo(5)); + Assert.That(result, Is.TypeOf()); + Assert.That(result, Has.Length.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("group-1")); + Assert.That(result[0].Count, Is.EqualTo(5)); } [Test] @@ -63,13 +63,21 @@ public async Task GetRetryHistory_returns_history() retryStore.RetryHistoryResult = RetryHistory.CreateNew(); var result = await tools.GetRetryHistory(); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.HistoricOperations, Is.Empty); - Assert.That(response.UnacknowledgedOperations, Is.Empty); + Assert.That(result, Is.TypeOf()); + Assert.That(result.HistoricOperations, Is.Empty); + Assert.That(result.UnacknowledgedOperations, Is.Empty); } - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + [TestCase(nameof(FailureGroupTools.GetFailureGroups))] + [TestCase(nameof(FailureGroupTools.GetRetryHistory))] + public void Structured_tools_use_structured_content(string methodName) + { + var method = typeof(FailureGroupTools).GetMethod(methodName)!; + var attribute = (McpServerToolAttribute)Attribute.GetCustomAttribute(method, typeof(McpServerToolAttribute))!; + + Assert.That(attribute.UseStructuredContent, Is.True); + } class StubGroupsDataStore : IGroupsDataStore { diff --git a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs index e4c283b93b..f2f91b76b4 100644 --- a/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/McpMetadataDescriptionsTests.cs @@ -150,13 +150,16 @@ public void Retry_tools_describe_targeted_group_and_broad_scenarios() }); } + static MethodInfo GetMethod(Type toolType, string methodName) + => toolType.GetMethod(methodName)!; + static string GetMethodDescription(Type toolType, string methodName) - => toolType.GetMethod(methodName)! + => GetMethod(toolType, methodName) .GetCustomAttribute()! .Description; static string GetParameterDescription(Type toolType, string methodName, string parameterName) - => toolType.GetMethod(methodName)! + => GetMethod(toolType, methodName) .GetParameters() .Single(p => p.Name == parameterName) .GetCustomAttribute()! diff --git a/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs b/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs new file mode 100644 index 0000000000..322d6ea9fc --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpStructuredOutputReadinessTests.cs @@ -0,0 +1,49 @@ +#nullable enable + +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Reflection; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using NUnit.Framework; +using ServiceControl.Mcp; + +[TestFixture] +class McpStructuredOutputReadinessTests +{ + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessages))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageById))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessageLastAttempt))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetErrorsSummary))] + [TestCase(typeof(FailedMessageTools), nameof(FailedMessageTools.GetFailedMessagesByEndpoint))] + [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetFailureGroups))] + [TestCase(typeof(FailureGroupTools), nameof(FailureGroupTools.GetRetryHistory))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessage))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailedMessagesByQueue))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessages))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryAllFailedMessagesByEndpoint))] + [TestCase(typeof(RetryTools), nameof(RetryTools.RetryFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessage))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.ArchiveFailureGroup))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessage))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailedMessages))] + [TestCase(typeof(ArchiveTools), nameof(ArchiveTools.UnarchiveFailureGroup))] + public void Migrated_primary_mcp_tools_opt_into_structured_content_and_do_not_return_task_of_string(Type toolType, string methodName) + { + var method = GetMethod(toolType, methodName); + var attribute = method.GetCustomAttribute(); + + Assert.Multiple(() => + { + Assert.That(attribute, Is.Not.Null, $"Expected {toolType.Name}.{methodName} to have an {nameof(McpServerToolAttribute)}."); + Assert.That(attribute!.UseStructuredContent, Is.True); + Assert.That(method.ReturnType, Is.Not.EqualTo(typeof(Task))); + }); + } + + static MethodInfo GetMethod(Type toolType, string methodName) + => toolType.GetMethod(methodName)!; +} diff --git a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs index 62ed5eaa7d..ee7dafb28e 100644 --- a/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/RetryMcpToolsTests.cs @@ -2,11 +2,11 @@ namespace ServiceControl.UnitTests.Mcp; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using NServiceBus.Testing; using NUnit.Framework; +using ServiceControl.Infrastructure.Mcp; using ServiceControl.Mcp; using ServiceControl.Persistence; using ServiceControl.Recoverability; @@ -31,9 +31,10 @@ public void SetUp() public async Task RetryFailedMessage_returns_accepted() { var result = await tools.RetryFailedMessage("msg-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for message 'msg-1'.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -41,9 +42,10 @@ public async Task RetryFailedMessage_returns_accepted() public async Task RetryFailedMessages_returns_accepted() { var result = await tools.RetryFailedMessages(["msg-1", "msg-2"]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for 2 messages.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -51,18 +53,20 @@ public async Task RetryFailedMessages_returns_accepted() public async Task RetryFailedMessages_rejects_empty_ids() { var result = await tools.RetryFailedMessages(["msg-1", ""]); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Error, Does.Contain("non-empty")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.ValidationError)); + Assert.That(result.Message, Is.Null); + Assert.That(result.Error, Does.Contain("non-empty")); } [Test] public async Task RetryFailedMessagesByQueue_returns_accepted() { var result = await tools.RetryFailedMessagesByQueue("Sales@machine"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages in queue 'Sales@machine'.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -70,9 +74,10 @@ public async Task RetryFailedMessagesByQueue_returns_accepted() public async Task RetryAllFailedMessages_returns_accepted() { var result = await tools.RetryAllFailedMessages(); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages.")); + Assert.That(result.Error, Is.Null); Assert.That(messageSession.SentMessages, Has.Length.EqualTo(1)); } @@ -80,18 +85,20 @@ public async Task RetryAllFailedMessages_returns_accepted() public async Task RetryAllFailedMessagesByEndpoint_returns_accepted() { var result = await tools.RetryAllFailedMessagesByEndpoint("Sales"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for all failed messages in endpoint 'Sales'.")); + Assert.That(result.Error, Is.Null); } [Test] public async Task RetryFailureGroup_returns_accepted() { var result = await tools.RetryFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("Accepted")); + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.Accepted)); + Assert.That(result.Message, Is.EqualTo("Retry requested for all messages in failure group 'group-1'.")); + Assert.That(result.Error, Is.Null); } [Test] @@ -101,21 +108,9 @@ public async Task RetryFailureGroup_returns_in_progress_when_already_running() await retryingManager.Preparing("group-1", RetryType.FailureGroup, 1); var result = await tools.RetryFailureGroup("group-1"); - var response = JsonSerializer.Deserialize(result, JsonOptions)!; - Assert.That(response.Status, Is.EqualTo("InProgress")); - } - - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - class McpStatusResponse - { - public string? Status { get; set; } - public string? Message { get; set; } - } - - class McpErrorResponse - { - public string? Error { get; set; } + Assert.That(result.Status, Is.EqualTo(McpOperationStatus.InProgress)); + Assert.That(result.Message, Is.EqualTo("A retry operation is already in progress for group 'group-1'.")); + Assert.That(result.Error, Is.Null); } } diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 17dc44d5d3..557c2daefc 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -23,6 +23,11 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Se if (settings.EnableMcpServer) { + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services .AddMcpServer() .WithHttpTransport() @@ -65,4 +70,4 @@ static void RegisterApiTypes(this IServiceCollection serviceCollection, Assembly } } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Mcp/ArchiveTools.cs b/src/ServiceControl/Mcp/ArchiveTools.cs index 78f124d95d..52691b3252 100644 --- a/src/ServiceControl/Mcp/ArchiveTools.cs +++ b/src/ServiceControl/Mcp/ArchiveTools.cs @@ -2,7 +2,6 @@ namespace ServiceControl.Mcp; using System.ComponentModel; using System.Linq; -using System.Text.Json; using System.Threading.Tasks; using MessageFailures.InternalMessages; using Microsoft.Extensions.Logging; @@ -10,6 +9,7 @@ namespace ServiceControl.Mcp; using NServiceBus; using Persistence.Recoverability; using ServiceControl.Recoverability; +using ServiceControl.Infrastructure.Mcp; [McpServerToolType, Description( "Tools for archiving and unarchiving failed messages.\n\n" + @@ -23,7 +23,7 @@ namespace ServiceControl.Mcp; )] public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archiver, ILogger logger) { - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to dismiss a single failed message that does not need to be retried. " + "This operation changes system state. " + "Good for questions like: 'archive this message', 'dismiss this failure', or 'I do not need to retry this one'. " + @@ -31,23 +31,23 @@ public class ArchiveTools(IMessageSession messageSession, IArchiveMessages archi "This is an asynchronous operation — the message will be archived shortly after the request is accepted. " + "If you need to archive many messages with the same root cause, use ArchiveFailureGroup instead." )] - public async Task ArchiveFailedMessage( + public async Task ArchiveFailedMessage( [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP ArchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.Accepted($"Archive requested for message '{failedMessageId}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to dismiss multiple failed messages at once that do not need to be retried. " + "This operation changes system state. " + "It may affect many messages. " + "Good for questions like: 'archive these messages', 'dismiss these failures', or 'archive messages msg-1, msg-2, msg-3'. " + "Prefer ArchiveFailureGroup when all messages share the same failure cause — use this tool when you have a specific set of message IDs to archive." )] - public async Task ArchiveFailedMessages( + public async Task ArchiveFailedMessages( [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds) { logger.LogInformation("MCP ArchiveFailedMessages invoked (count={Count})", messageIds.Length); @@ -55,17 +55,17 @@ public async Task ArchiveFailedMessages( if (messageIds.Any(string.IsNullOrEmpty)) { logger.LogWarning("MCP ArchiveFailedMessages: rejected due to empty message IDs"); - return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); + return McpArchiveOperationResult.ValidationError("All message IDs must be non-empty strings."); } 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); + return McpArchiveOperationResult.Accepted($"Archive requested for {messageIds.Length} messages."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to dismiss an entire failure group — all messages that failed with the same exception type and stack trace. " + "This operation changes system state. " + "It may affect many messages. " + @@ -74,7 +74,7 @@ public async Task ArchiveFailedMessages( "You need a failure group ID, which you can get from GetFailureGroups. " + "Returns InProgress if an archive operation is already running for this group." )] - public async Task ArchiveFailureGroup( + public async Task ArchiveFailureGroup( [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP ArchiveFailureGroup invoked (groupId={GroupId})", groupId); @@ -82,39 +82,39 @@ public async Task ArchiveFailureGroup( if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { logger.LogInformation("MCP ArchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId); - return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.InProgress($"An archive operation is already in progress for group '{groupId}'."); } await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); await messageSession.SendLocal(m => m.GroupId = groupId); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Archive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.Accepted($"Archive requested for all messages in failure group '{groupId}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to restore a previously archived failed message back to the unresolved list so it can be retried. " + "This operation changes system state. " + "Good for questions like: 'unarchive this message', 'restore this failure', or 'I need to retry this archived message'. " + "Use when a message was archived by mistake or when the underlying issue has been fixed and the message should be reprocessed. " + "If you need to restore many messages from the same failure group, use UnarchiveFailureGroup instead." )] - public async Task UnarchiveFailedMessage( + public async Task UnarchiveFailedMessage( [Description("The failed message ID to restore from the archived state.")] string failedMessageId) { logger.LogInformation("MCP UnarchiveFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); await messageSession.SendLocal(m => m.FailedMessageIds = [failedMessageId]); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for message '{failedMessageId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.Accepted($"Unarchive requested for message '{failedMessageId}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to restore multiple previously archived failed messages back to the unresolved list. " + "This operation changes system state. " + "It may affect many messages. " + "Good for questions like: 'unarchive these messages', 'restore these failures', or 'unarchive messages msg-1, msg-2, msg-3'. " + "Prefer UnarchiveFailureGroup when restoring an entire group — use this tool when you have a specific set of message IDs." )] - public async Task UnarchiveFailedMessages( + public async Task UnarchiveFailedMessages( [Description("The failed message IDs to restore from the archived state.")] string[] messageIds) { logger.LogInformation("MCP UnarchiveFailedMessages invoked (count={Count})", messageIds.Length); @@ -122,14 +122,14 @@ public async Task UnarchiveFailedMessages( if (messageIds.Any(string.IsNullOrEmpty)) { logger.LogWarning("MCP UnarchiveFailedMessages: rejected due to empty message IDs"); - return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); + return McpArchiveOperationResult.ValidationError("All message IDs must be non-empty strings."); } await messageSession.SendLocal(m => m.FailedMessageIds = messageIds); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for {messageIds.Length} messages." }, McpJsonOptions.Default); + return McpArchiveOperationResult.Accepted($"Unarchive requested for {messageIds.Length} messages."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to restore an entire archived failure group back to the unresolved list. " + "This operation changes system state. " + "It may affect many messages. " + @@ -138,7 +138,7 @@ public async Task UnarchiveFailedMessages( "You need a failure group ID, which you can get from GetFailureGroups. " + "Returns InProgress if an unarchive operation is already running for this group." )] - public async Task UnarchiveFailureGroup( + public async Task UnarchiveFailureGroup( [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP UnarchiveFailureGroup invoked (groupId={GroupId})", groupId); @@ -146,12 +146,12 @@ public async Task UnarchiveFailureGroup( if (archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { logger.LogInformation("MCP UnarchiveFailureGroup: operation already in progress for group '{GroupId}'", groupId); - return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"An archive operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.InProgress($"An unarchive operation is already in progress for group '{groupId}'."); } await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); await messageSession.SendLocal(m => m.GroupId = groupId); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default); + return McpArchiveOperationResult.Accepted($"Unarchive requested for all messages in failure group '{groupId}'."); } } diff --git a/src/ServiceControl/Mcp/FailedMessageTools.cs b/src/ServiceControl/Mcp/FailedMessageTools.cs index a3ecdbd57e..4327ef5e07 100644 --- a/src/ServiceControl/Mcp/FailedMessageTools.cs +++ b/src/ServiceControl/Mcp/FailedMessageTools.cs @@ -2,14 +2,16 @@ namespace ServiceControl.Mcp; +using System.Collections.Generic; using System.ComponentModel; -using System.Text.Json; +using System.Linq; using System.Threading.Tasks; using MessageFailures.Api; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Persistence; using Persistence.Infrastructure; +using ServiceControl.Infrastructure.Mcp; [McpServerToolType, Description( "Read-only tools for investigating failed messages.\n\n" + @@ -23,14 +25,14 @@ namespace ServiceControl.Mcp; )] public class FailedMessageTools(IErrorMessageDataStore store, ILogger logger) { - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve failed messages for investigation. " + "Use this when exploring recent failures or narrowing down failures by queue, status, or time range. " + "Prefer GetFailureGroups when starting root-cause analysis across many failures. " + "Use GetFailedMessageById when inspecting a specific failed message. " + "Read-only." )] - public async Task GetFailedMessages( + public async Task> GetFailedMessages( [Description("Filter failed messages by status: unresolved (still failing), resolved (succeeded on retry), archived (dismissed), or retryissued (retry in progress). Omit this filter to include all statuses.")] string? status = null, [Description("Restricts failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null, [Description("Filter failed messages to a specific queue address, for example 'Sales@machine'. Omit this filter to include all queues.")] string? queueAddress = null, @@ -48,20 +50,20 @@ public async Task GetFailedMessages( logger.LogInformation("MCP GetFailedMessages returned {Count} results", results.QueryStats.TotalCount); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Get detailed information about a specific failed message. " + "Use this when you already know the failed message ID and need to inspect its contents or failure details. " + "Use GetFailedMessages or GetFailureGroups to locate relevant messages before calling this tool. " + "Read-only." )] - public async Task GetFailedMessageById( + public async Task GetFailedMessageById( [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP GetFailedMessageById invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -71,19 +73,22 @@ public async Task GetFailedMessageById( if (result == null) { logger.LogWarning("MCP GetFailedMessageById: message '{FailedMessageId}' not found", failedMessageId); - return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default); + return new McpFailedMessageResult + { + Error = $"Failed message '{failedMessageId}' not found." + }; } - return JsonSerializer.Serialize(result, McpJsonOptions.Default); + return McpFailedMessageResult.From(result); } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve the last processing attempt for a failed message. " + "Use this to understand the most recent failure behavior, including exception details and processing context. " + "Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. " + "Read-only." )] - public async Task GetFailedMessageLastAttempt( + public async Task GetFailedMessageLastAttempt( [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP GetFailedMessageLastAttempt invoked (failedMessageId={FailedMessageId})", failedMessageId); @@ -93,35 +98,37 @@ public async Task GetFailedMessageLastAttempt( if (result == null) { logger.LogWarning("MCP GetFailedMessageLastAttempt: message '{FailedMessageId}' not found", failedMessageId); - return JsonSerializer.Serialize(new { Error = $"Failed message '{failedMessageId}' not found." }, McpJsonOptions.Default); + return new McpFailedMessageViewResult + { + Error = $"Failed message '{failedMessageId}' not found." + }; } - return JsonSerializer.Serialize(result, McpJsonOptions.Default); + return McpFailedMessageViewResult.From(result); } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool as a quick health check to see how many messages are in each failure state. " + "Good for questions like: 'how many errors are there?', 'what is the error situation?', or 'are there unresolved failures?'. " + "Returns counts for unresolved, archived, resolved, and retryissued statuses. " + "This is a good first tool to call when asked about the overall error situation before drilling into specific messages. " + "Read-only." )] - public async Task GetErrorsSummary() + public async Task GetErrorsSummary() { logger.LogInformation("MCP GetErrorsSummary invoked"); - var result = await store.ErrorsSummary(); - return JsonSerializer.Serialize(result, McpJsonOptions.Default); + return McpErrorsSummaryResult.From(await store.ErrorsSummary()); } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve failed messages for a specific endpoint. " + "Use this when investigating failures in a named endpoint such as Billing or Sales. " + "Prefer GetFailureGroups when you need root-cause analysis across many failures. " + "Use GetFailedMessageLastAttempt after this when you need the most recent failure details for a specific message. " + "Read-only." )] - public async Task GetFailedMessagesByEndpoint( + public async Task> GetFailedMessagesByEndpoint( [Description("The endpoint name that owns the failed messages. Use values obtained from endpoint-aware failed-message results.")] string endpointName, [Description("Filter failed messages by status: unresolved, resolved, archived, or retryissued. Omit this filter to include all statuses for the endpoint.")] string? status = null, [Description("Restricts endpoint failed-message results to entries modified after this ISO 8601 date/time. Omitting this may return a large result set.")] string? modified = null, @@ -139,10 +146,10 @@ public async Task GetFailedMessagesByEndpoint( logger.LogInformation("MCP GetFailedMessagesByEndpoint returned {Count} results for endpoint '{EndpointName}'", results.QueryStats.TotalCount, endpointName); - return JsonSerializer.Serialize(new + return new McpCollectionResult { - results.QueryStats.TotalCount, - results.Results - }, McpJsonOptions.Default); + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results.ToArray() + }; } } diff --git a/src/ServiceControl/Mcp/FailureGroupTools.cs b/src/ServiceControl/Mcp/FailureGroupTools.cs index 3f0d0e342f..4ffcd9bb19 100644 --- a/src/ServiceControl/Mcp/FailureGroupTools.cs +++ b/src/ServiceControl/Mcp/FailureGroupTools.cs @@ -3,7 +3,6 @@ namespace ServiceControl.Mcp; using System.ComponentModel; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; @@ -19,13 +18,13 @@ namespace ServiceControl.Mcp; )] public class FailureGroupTools(GroupFetcher fetcher, IRetryHistoryDataStore retryStore, ILogger logger) { - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Retrieve failure groups, where failed messages are grouped by exception type and stack trace. " + "Use this as the first step when analyzing large numbers of failures to identify dominant root causes. " + "Prefer GetFailedMessages when you need individual message details. " + "Read-only." )] - public async Task GetFailureGroups( + public async Task GetFailureGroups( [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace", [Description("Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.")] string? classifierFilter = null) { @@ -35,21 +34,20 @@ public async Task GetFailureGroups( logger.LogInformation("MCP GetFailureGroups returned {Count} groups", results.Length); - return JsonSerializer.Serialize(results, McpJsonOptions.Default); + return results; } - [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false), Description( + [McpServerTool(ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to check the history of retry operations. " + "Good for questions like: 'has someone already retried these?', 'what happened the last time we retried this group?', 'show retry history', or 'were any retries attempted today?'. " + "Returns which groups were retried, when, and whether the retries succeeded or failed. " + "Use this before retrying a group to avoid duplicate retry attempts. " + "Read-only." )] - public async Task GetRetryHistory() + public async Task GetRetryHistory() { logger.LogInformation("MCP GetRetryHistory invoked"); - var retryHistory = await retryStore.GetRetryHistory(); - return JsonSerializer.Serialize(retryHistory, McpJsonOptions.Default); + return await retryStore.GetRetryHistory(); } } diff --git a/src/ServiceControl/Mcp/McpFailedMessageResult.cs b/src/ServiceControl/Mcp/McpFailedMessageResult.cs new file mode 100644 index 0000000000..9b33df9f50 --- /dev/null +++ b/src/ServiceControl/Mcp/McpFailedMessageResult.cs @@ -0,0 +1,132 @@ +#nullable enable + +namespace ServiceControl.Mcp; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using ServiceControl.Contracts.Operations; +using MessageFailures; + +public class McpFailedMessageResult +{ + public string? Error { get; init; } + public string Id { get; init; } = string.Empty; + public List ProcessingAttempts { get; init; } = []; + public List FailureGroups { get; init; } = []; + public string UniqueMessageId { get; init; } = string.Empty; + public FailedMessageStatus Status { get; init; } + + public static McpFailedMessageResult From(FailedMessage message) + { + return new McpFailedMessageResult + { + Id = message.Id, + ProcessingAttempts = message.ProcessingAttempts.Select(McpFailedProcessingAttemptResult.From).ToList(), + FailureGroups = message.FailureGroups.Select(McpFailedFailureGroupResult.From).ToList(), + UniqueMessageId = message.UniqueMessageId, + Status = message.Status + }; + } +} + +public class McpFailedProcessingAttemptResult +{ + public List MessageMetadata { get; init; } = []; + public FailureDetails? FailureDetails { get; init; } + public DateTime AttemptedAt { get; init; } + public string MessageId { get; init; } = string.Empty; + public string Body { get; init; } = string.Empty; + public Dictionary Headers { get; init; } = []; + + public static McpFailedProcessingAttemptResult From(FailedMessage.ProcessingAttempt attempt) + { + return new McpFailedProcessingAttemptResult + { + MessageMetadata = attempt.MessageMetadata.Select(entry => McpMessageMetadataEntryResult.From(entry.Key, entry.Value)).ToList(), + FailureDetails = attempt.FailureDetails, + AttemptedAt = attempt.AttemptedAt, + MessageId = attempt.MessageId, + Body = attempt.Body, + Headers = attempt.Headers + }; + } +} + +public class McpMessageMetadataEntryResult +{ + public string Key { get; init; } = string.Empty; + public string? Value { get; init; } + public string Type { get; init; } = string.Empty; + + public static McpMessageMetadataEntryResult From(string key, object? value) + { + return new McpMessageMetadataEntryResult + { + Key = key, + Value = FormatValue(value), + Type = GetTypeName(value) + }; + } + + static string? FormatValue(object? value) + { + return value switch + { + null => null, + DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture), + TimeSpan timeSpan => timeSpan.ToString("c", CultureInfo.InvariantCulture), + bool boolean => boolean ? "true" : "false", + string text => text, + sbyte number => number.ToString(CultureInfo.InvariantCulture), + byte number => number.ToString(CultureInfo.InvariantCulture), + short number => number.ToString(CultureInfo.InvariantCulture), + ushort number => number.ToString(CultureInfo.InvariantCulture), + int number => number.ToString(CultureInfo.InvariantCulture), + uint number => number.ToString(CultureInfo.InvariantCulture), + long number => number.ToString(CultureInfo.InvariantCulture), + ulong number => number.ToString(CultureInfo.InvariantCulture), + float number => number.ToString(CultureInfo.InvariantCulture), + double number => number.ToString(CultureInfo.InvariantCulture), + decimal number => number.ToString(CultureInfo.InvariantCulture), + Enum enumValue => enumValue.ToString(), + _ => JsonSerializer.Serialize(value) + }; + } + + static string GetTypeName(object? value) + { + return value switch + { + null => "null", + string => "string", + bool => "boolean", + sbyte or byte or short or ushort or int or uint or long or ulong => "integer", + float or double or decimal => "number", + DateTime or DateTimeOffset => "date-time", + TimeSpan => "time-span", + Enum => "enum", + _ => "json" + }; + } +} + +public class McpFailedFailureGroupResult +{ + public string Id { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string Type { get; init; } = string.Empty; + + public static McpFailedFailureGroupResult From(FailedMessage.FailureGroup group) + { + return new McpFailedFailureGroupResult + { + Id = group.Id, + Title = group.Title, + Type = group.Type + }; + } +} diff --git a/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs new file mode 100644 index 0000000000..557b6d1541 --- /dev/null +++ b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs @@ -0,0 +1,51 @@ +#nullable enable + +namespace ServiceControl.Mcp; + +using System; +using ServiceControl.Contracts.Operations; +using ServiceControl.MessageFailures; +using MessageFailures.Api; +using ServiceControl.Operations; + +public class McpFailedMessageViewResult +{ + public string? Error { get; init; } + public string Id { get; init; } = string.Empty; + public string MessageType { get; init; } = string.Empty; + public DateTime? TimeSent { get; init; } + public bool IsSystemMessage { get; init; } + public ExceptionDetails? Exception { get; init; } + public string MessageId { get; init; } = string.Empty; + public int NumberOfProcessingAttempts { get; init; } + public FailedMessageStatus Status { get; init; } + public EndpointDetails? SendingEndpoint { get; init; } + public EndpointDetails? ReceivingEndpoint { get; init; } + public string QueueAddress { get; init; } = string.Empty; + public DateTime TimeOfFailure { get; init; } + public DateTime LastModified { get; init; } + public bool Edited { get; init; } + public string EditOf { get; init; } = string.Empty; + + public static McpFailedMessageViewResult From(FailedMessageView message) + { + return new McpFailedMessageViewResult + { + Id = message.Id, + MessageType = message.MessageType, + TimeSent = message.TimeSent, + IsSystemMessage = message.IsSystemMessage, + Exception = message.Exception, + MessageId = message.MessageId, + NumberOfProcessingAttempts = message.NumberOfProcessingAttempts, + Status = message.Status, + SendingEndpoint = message.SendingEndpoint, + ReceivingEndpoint = message.ReceivingEndpoint, + QueueAddress = message.QueueAddress, + TimeOfFailure = message.TimeOfFailure, + LastModified = message.LastModified, + Edited = message.Edited, + EditOf = message.EditOf + }; + } +} diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs index dafb0a4634..11cfa92834 100644 --- a/src/ServiceControl/Mcp/RetryTools.cs +++ b/src/ServiceControl/Mcp/RetryTools.cs @@ -2,7 +2,6 @@ namespace ServiceControl.Mcp; using System.ComponentModel; using System.Linq; -using System.Text.Json; using System.Threading.Tasks; using MessageFailures; using MessageFailures.InternalMessages; @@ -11,6 +10,7 @@ namespace ServiceControl.Mcp; using NServiceBus; using Recoverability; using Persistence; +using ServiceControl.Infrastructure.Mcp; [McpServerToolType, Description( "Tools for retrying failed messages.\n\n" + @@ -24,23 +24,23 @@ namespace ServiceControl.Mcp; )] public class RetryTools(IMessageSession messageSession, RetryingManager retryingManager, ILogger logger) { - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Use this tool to reprocess a single failed message by sending it back to its original queue. " + "This operation changes system state. " + "Good for questions like: 'retry this message', 'reprocess this failure', or 'send this message back for processing'. " + "The message will go through normal processing again. Only use after the underlying issue (bug fix, infrastructure problem) has been resolved. " + "If you need to retry many messages with the same root cause, use RetryFailureGroup instead." )] - public async Task RetryFailedMessage( + public async Task RetryFailedMessage( [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId) { logger.LogInformation("MCP RetryFailedMessage invoked (failedMessageId={FailedMessageId})", failedMessageId); await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for message '{failedMessageId}'." }, McpJsonOptions.Default); + return McpRetryOperationResult.Accepted($"Retry requested for message '{failedMessageId}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Retry a selected set of failed messages by their IDs. " + "Use this when the user explicitly wants to retry specific known messages. " + "Prefer RetryFailureGroup when retrying all messages with the same root cause. " + @@ -48,7 +48,7 @@ public async Task RetryFailedMessage( "It may affect many messages. " + "Use values obtained from failed-message investigation tools." )] - public async Task RetryFailedMessages( + public async Task RetryFailedMessages( [Description("The failed message IDs from previous failed-message query results.")] string[] messageIds) { logger.LogInformation("MCP RetryFailedMessages invoked (count={Count})", messageIds.Length); @@ -56,14 +56,14 @@ public async Task RetryFailedMessages( if (messageIds.Any(string.IsNullOrEmpty)) { logger.LogWarning("MCP RetryFailedMessages: rejected due to empty message IDs"); - return JsonSerializer.Serialize(new { Error = "All message IDs must be non-empty strings." }, McpJsonOptions.Default); + return McpRetryOperationResult.ValidationError("All message IDs must be non-empty strings."); } await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for {messageIds.Length} messages." }, McpJsonOptions.Default); + return McpRetryOperationResult.Accepted($"Retry requested for {messageIds.Length} messages."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Retry all unresolved failed messages from a specific queue. " + "Use this when the user explicitly wants a queue-scoped retry after a queue or consumer issue is fixed. " + "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " + @@ -71,7 +71,7 @@ public async Task RetryFailedMessages( "It may affect many messages. " + "Use the queue address from failed-message results." )] - public async Task RetryFailedMessagesByQueue( + public async Task RetryFailedMessagesByQueue( [Description("Queue address whose unresolved failed messages should be retried. Use values obtained from failed-message results.")] string queueAddress) { logger.LogInformation("MCP RetryFailedMessagesByQueue invoked (queueAddress={QueueAddress})", queueAddress); @@ -81,10 +81,10 @@ 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); + return McpRetryOperationResult.Accepted($"Retry requested for all failed messages in queue '{queueAddress}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Retry all currently failed messages across all queues. " + "Use only when the user explicitly requests a broad retry operation. " + "Prefer narrower retry tools such as RetryFailureGroup or RetryFailedMessages when possible. " + @@ -92,15 +92,15 @@ await messageSession.SendLocal(m => "It may affect many messages. " + "It affects all unresolved failed messages across the instance and may affect a large number of messages." )] - public async Task RetryAllFailedMessages() + public async Task RetryAllFailedMessages() { logger.LogInformation("MCP RetryAllFailedMessages invoked"); await messageSession.SendLocal(new RequestRetryAll()); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = "Retry requested for all failed messages." }, McpJsonOptions.Default); + return McpRetryOperationResult.Accepted("Retry requested for all failed messages."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Retry all failed messages for a specific endpoint. " + "Use this when the user explicitly wants an endpoint-scoped retry after an endpoint-specific issue is fixed. " + "Prefer RetryFailureGroup or RetryFailedMessages when you can retry a narrower set of failures. " + @@ -108,16 +108,16 @@ public async Task RetryAllFailedMessages() "It may affect many messages. " + "Use the endpoint name from failed-message results." )] - public async Task RetryAllFailedMessagesByEndpoint( + public async Task RetryAllFailedMessagesByEndpoint( [Description("The endpoint name whose failed messages should be retried. Use values obtained from failed-message results.")] string endpointName) { logger.LogInformation("MCP RetryAllFailedMessagesByEndpoint invoked (endpoint={EndpointName})", endpointName); await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName }); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all failed messages in endpoint '{endpointName}'." }, McpJsonOptions.Default); + return McpRetryOperationResult.Accepted($"Retry requested for all failed messages in endpoint '{endpointName}'."); } - [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false), Description( + [McpServerTool(ReadOnly = false, Idempotent = false, Destructive = true, OpenWorld = false, UseStructuredContent = true), Description( "Retry all failed messages in a failure group that share the same root cause. " + "Use this when multiple failures are caused by the same issue and can be retried together. " + "Prefer RetryFailedMessages for more granular control. " + @@ -126,7 +126,7 @@ public async Task RetryAllFailedMessagesByEndpoint( "Use the failure group ID from GetFailureGroups. " + "Returns InProgress if a retry is already running for this group." )] - public async Task RetryFailureGroup( + public async Task RetryFailureGroup( [Description("The failure group ID from previous GetFailureGroups results.")] string groupId) { logger.LogInformation("MCP RetryFailureGroup invoked (groupId={GroupId})", groupId); @@ -134,7 +134,7 @@ public async Task RetryFailureGroup( if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { logger.LogInformation("MCP RetryFailureGroup: operation already in progress for group '{GroupId}'", groupId); - return JsonSerializer.Serialize(new { Status = "InProgress", Message = $"A retry operation is already in progress for group '{groupId}'." }, McpJsonOptions.Default); + return McpRetryOperationResult.InProgress($"A retry operation is already in progress for group '{groupId}'."); } var started = System.DateTime.UtcNow; @@ -145,6 +145,6 @@ await messageSession.SendLocal(new RetryAllInGroup Started = started }); - return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Retry requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default); + return McpRetryOperationResult.Accepted($"Retry requested for all messages in failure group '{groupId}'."); } } From 537bc13c9dcfd6beca82956d0898bdcef8482ec7 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 26 Mar 2026 09:50:17 +0100 Subject: [PATCH 28/29] Add MCP source-generated JSON contexts Use source-generated serialization metadata for MCP DTOs and acceptance support while keeping centralized options as the compatibility path. This keeps manual MCP serialization aligned with the published structured content contract without changing the SDK-managed response shape. --- .../Mcp/McpAcceptanceJsonContext.cs | 15 ++++++++++++ .../Mcp/McpAcceptanceTestSupport.cs | 15 ++++++------ .../Mcp/When_mcp_server_is_enabled.cs | 2 +- .../Mcp/When_mcp_server_is_enabled.cs | 2 +- .../Mcp/McpOperationResult.cs | 2 +- .../Mcp/FailedMessageMcpToolsTests.cs | 11 +++++++-- .../Mcp/McpFailedMessageResult.cs | 2 +- src/ServiceControl/Mcp/McpJsonOptions.cs | 4 +++- .../Mcp/McpSerializationContext.cs | 24 +++++++++++++++++++ 9 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs create mode 100644 src/ServiceControl/Mcp/McpSerializationContext.cs diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs new file mode 100644 index 0000000000..9956243955 --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceJsonContext.cs @@ -0,0 +1,15 @@ +namespace ServiceControl.AcceptanceTesting.Mcp; + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(McpListToolsResponse))] +[JsonSerializable(typeof(McpCallToolResponse))] +[JsonSerializable(typeof(McpInitializeResponse))] +public partial class McpAcceptanceJsonContext : JsonSerializerContext; diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs index 2fd690ba5b..e21b18ca8d 100644 --- a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs +++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs @@ -13,8 +13,6 @@ public static class McpAcceptanceTestSupport { const string RequestedProtocolVersion = "2025-11-25"; - static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public static async Task InitializeMcpSession(HttpClient httpClient) { var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") @@ -46,7 +44,7 @@ public static async Task InitializeAndGetSessionInfo(HttpClient return null; } - var initializeResponse = JsonSerializer.Deserialize(await ReadMcpResponseJson(response), JsonOptions)!; + var initializeResponse = JsonSerializer.Deserialize(await ReadMcpResponseJson(response), McpAcceptanceJsonContext.Default.McpInitializeResponse)!; var protocolVersion = initializeResponse.Result.ProtocolVersion; if (!response.Headers.TryGetValues("mcp-session-id", out var values)) @@ -108,10 +106,13 @@ public static async Task ReadMcpResponseJson(HttpResponseMessage respons } public static McpListToolsResponse DeserializeListToolsResponse(string toolsJson) => - JsonSerializer.Deserialize(toolsJson, JsonOptions)!; + JsonSerializer.Deserialize(toolsJson, McpAcceptanceJsonContext.Default.McpListToolsResponse)!; public static McpCallToolResponse DeserializeCallToolResponse(string toolResult) => - JsonSerializer.Deserialize(toolResult, JsonOptions)!; + JsonSerializer.Deserialize(toolResult, McpAcceptanceJsonContext.Default.McpCallToolResponse)!; + + public static string FormatToolsForApproval(List sortedTools) => + JsonSerializer.Serialize(sortedTools, McpAcceptanceJsonContext.Default.ListJsonElement); public static void AssertToolsHaveOutputSchema(IEnumerable tools) { @@ -182,12 +183,12 @@ public class McpContent public string Text { get; set; } } -class McpInitializeResponse +public class McpInitializeResponse { public McpInitializeResult Result { get; set; } } -class McpInitializeResult +public class McpInitializeResult { public string ProtocolVersion { get; set; } } diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 82cdb50c3b..11f42abba2 100644 --- a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -60,7 +60,7 @@ await Define() var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); AssertPrimaryTools(sortedTools); McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools); - var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var formattedTools = McpAcceptanceTestSupport.FormatToolsForApproval(sortedTools); Approver.Verify(formattedTools); } diff --git a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs index 84c593095a..31a8efcc04 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -64,7 +64,7 @@ await Define() var sortedTools = mcpResponse.Result.Tools.Cast().OrderBy(t => t.GetProperty("name").GetString()).ToList(); AssertAuditTools(sortedTools); McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools); - var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var formattedTools = McpAcceptanceTestSupport.FormatToolsForApproval(sortedTools); Approver.Verify(formattedTools); } diff --git a/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs index 8c8c590ed6..1f977e1aac 100644 --- a/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs +++ b/src/ServiceControl.Infrastructure/Mcp/McpOperationResult.cs @@ -4,7 +4,7 @@ namespace ServiceControl.Infrastructure.Mcp; using System.Text.Json.Serialization; -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum McpOperationStatus { Accepted, diff --git a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs index e81ffb2bc1..6142a00be4 100644 --- a/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs +++ b/src/ServiceControl.UnitTests/Mcp/FailedMessageMcpToolsTests.cs @@ -96,7 +96,11 @@ public async Task GetFailedMessageById_returns_message() TimeOfFailure = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) }, Headers = new Dictionary { ["NServiceBus.MessageId"] = "message-1" }, - MessageMetadata = new Dictionary { ["Retries"] = 3 } + MessageMetadata = new Dictionary + { + ["Retries"] = 3, + ["Context"] = new { RetryCount = 3, Note = (string?)null } + } } ], FailureGroups = @@ -120,10 +124,13 @@ public async Task GetFailedMessageById_returns_message() Assert.That(result.UniqueMessageId, Is.EqualTo("unique-1")); Assert.That(result.ProcessingAttempts, Has.Count.EqualTo(1)); Assert.That(result.ProcessingAttempts[0].MessageId, Is.EqualTo("message-1")); - Assert.That(result.ProcessingAttempts[0].MessageMetadata, Has.Count.EqualTo(1)); + Assert.That(result.ProcessingAttempts[0].MessageMetadata, Has.Count.EqualTo(2)); Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Key, Is.EqualTo("Retries")); Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Value, Is.EqualTo("3")); Assert.That(result.ProcessingAttempts[0].MessageMetadata[0].Type, Is.EqualTo("integer")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Key, Is.EqualTo("Context")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Value, Is.EqualTo("{\"retryCount\":3}")); + Assert.That(result.ProcessingAttempts[0].MessageMetadata[1].Type, Is.EqualTo("json")); Assert.That(result.FailureGroups, Has.Count.EqualTo(1)); Assert.That(result.FailureGroups[0].Id, Is.EqualTo("group-1")); }); diff --git a/src/ServiceControl/Mcp/McpFailedMessageResult.cs b/src/ServiceControl/Mcp/McpFailedMessageResult.cs index 9b33df9f50..99e308854b 100644 --- a/src/ServiceControl/Mcp/McpFailedMessageResult.cs +++ b/src/ServiceControl/Mcp/McpFailedMessageResult.cs @@ -93,7 +93,7 @@ public static McpMessageMetadataEntryResult From(string key, object? value) double number => number.ToString(CultureInfo.InvariantCulture), decimal number => number.ToString(CultureInfo.InvariantCulture), Enum enumValue => enumValue.ToString(), - _ => JsonSerializer.Serialize(value) + _ => JsonSerializer.Serialize(value, value.GetType(), McpJsonOptions.Default) }; } diff --git a/src/ServiceControl/Mcp/McpJsonOptions.cs b/src/ServiceControl/Mcp/McpJsonOptions.cs index 1e37e52d37..1288bb7b1c 100644 --- a/src/ServiceControl/Mcp/McpJsonOptions.cs +++ b/src/ServiceControl/Mcp/McpJsonOptions.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Mcp; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; static class McpJsonOptions { @@ -9,6 +10,7 @@ static class McpJsonOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false + WriteIndented = false, + TypeInfoResolverChain = { McpSerializationContext.Default, new DefaultJsonTypeInfoResolver() } }; } diff --git a/src/ServiceControl/Mcp/McpSerializationContext.cs b/src/ServiceControl/Mcp/McpSerializationContext.cs new file mode 100644 index 0000000000..6a3091da3a --- /dev/null +++ b/src/ServiceControl/Mcp/McpSerializationContext.cs @@ -0,0 +1,24 @@ +namespace ServiceControl.Mcp; + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MessageFailures; +using ServiceControl.Contracts.Operations; +using ServiceControl.Infrastructure.Mcp; +using ServiceControl.MessageFailures.Api; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(McpCollectionResult))] +[JsonSerializable(typeof(McpErrorsSummaryResult))] +[JsonSerializable(typeof(McpFailedMessageResult))] +[JsonSerializable(typeof(McpFailedMessageViewResult))] +[JsonSerializable(typeof(McpOperationResult))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(McpMessageMetadataEntryResult))] +[JsonSerializable(typeof(McpFailedProcessingAttemptResult))] +[JsonSerializable(typeof(McpFailedFailureGroupResult))] +[JsonSerializable(typeof(FailedMessageStatus))] +public partial class McpSerializationContext : JsonSerializerContext; From 295949906233f292750791892948e6e06f5b3653 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 26 Mar 2026 10:43:54 +0100 Subject: [PATCH 29/29] Removed unused options type --- src/ServiceControl.Audit/Mcp/McpJsonOptions.cs | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 src/ServiceControl.Audit/Mcp/McpJsonOptions.cs diff --git a/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs b/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs deleted file mode 100644 index ff03d91eae..0000000000 --- a/src/ServiceControl.Audit/Mcp/McpJsonOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -#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 - }; -}