diff --git a/README.md b/README.md index dbdd477cb5..1e16760bb3 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Local testing guides: - [Reverse Proxy Testing](docs/reverseproxy-testing.md) - [Forward Headers Testing](docs/forward-headers-testing.md) - [Authentication Testing](docs/authentication-testing.md) +- [MCP Server](docs/mcp.md) - [Persistence Tests](docs/testing-persistence.md) ## How to developer test the PowerShell Module @@ -149,4 +150,4 @@ Steps: Since version 6.13, ServiceControl ships with a copy of ServicePulse and [can host it from an Error instance](https://docs.particular.net/servicecontrol/servicecontrol-instances/integrated-servicepulse). -ServiceControl Error instances have a reference to the Particular.ServicePulse.Core package; this contains the ServicePulse assets, along with the code required to serve them out of an ASP.NET web host. +ServiceControl Error instances have a reference to the Particular.ServicePulse.Core package; this contains the ServicePulse assets, along with the code required to serve them out of an ASP.NET web host. \ No newline at end of file diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000000..5052584dd4 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,52 @@ +# ServiceControl MCP server + +ServiceControl can host a Model Context Protocol (MCP) server inside the main ServiceControl process. + +## Enablement + +Enable the read-only MCP server with the `ServiceControl/EnableMcpServer` setting. + +```xml + +``` + +If you also want write-capable retry tools, enable `ServiceControl/EnableMcpServerWriteMode` as well: + +```xml + +``` + +When enabled, ServiceControl exposes MCP on `POST /mcp` using the same ASP.NET Core host, authentication, and authorization pipeline as the built-in API. + +## Security + +The MCP surface reuses the existing ServiceControl authentication and permission model: + +- authentication is handled by the existing JWT/OIDC configuration +- authorization uses the same permission names as the HTTP API +- retry actions continue to flow through the existing audit trail + +## Current tools + +The first MCP tools focus on failures and recoverability groups: + +- list failed messages +- get a failed message by id +- get the last attempt for a failed message +- get an errors summary +- list failed messages by endpoint +- retry a failed message +- list failure groups +- get failures in a failure group +- get retry history +- retry a failure group + +## Deployment + +Because the MCP server lives in the main ServiceControl host, it is deployed and versioned with the rest of ServiceControl. + +For production, keep MCP disabled unless you need it. When enabled, protect the host with the same TLS, proxy, and authentication settings you would use for the HTTP API. + +## Extending MCP + +Add new tools under `src/ServiceControl/Mcp/Tools/` and reuse the existing ServiceControl services and permission policies. The current structure is intentionally small so future tools can be added without introducing a second security model or a separate host. \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1a712572da..6f65fea9fc 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs new file mode 100644 index 0000000000..8e384d67ae --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs @@ -0,0 +1,205 @@ +#nullable enable +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; +using System.Threading.Tasks; +using NUnit.Framework; + +public static class McpAcceptanceTestSupport +{ + static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true }; + + const string RequestedProtocolVersion = "2025-11-25"; + + public static async Task InitializeMcpSession(HttpClient httpClient, CancellationToken cancellationToken = default) + { + 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, cancellationToken); + } + + public static async Task InitializeAndGetSessionInfo(HttpClient httpClient, CancellationToken cancellationToken = default) + { + var response = await InitializeMcpSession(httpClient, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var initializeResponse = JsonSerializer.Deserialize(await ReadMcpResponseJson(response, cancellationToken), SerializerOptions); + var protocolVersion = initializeResponse?.Result?.ProtocolVersion; + if (string.IsNullOrEmpty(protocolVersion)) + { + return null; + } + + if (!response.Headers.TryGetValues("mcp-session-id", out var values)) + { + return null; + } + + var sessionId = values.FirstOrDefault(); + if (string.IsNullOrEmpty(sessionId)) + { + return null; + } + + var initializedResponse = await SendInitializedNotification(httpClient, sessionId, protocolVersion, cancellationToken); + if (!initializedResponse.IsSuccessStatusCode) + { + return null; + } + + return new McpSessionInfo(sessionId, protocolVersion); + } + + public static async Task SendMcpRequest(HttpClient httpClient, McpSessionInfo sessionInfo, string method, object @params, CancellationToken cancellationToken = default) + { + 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, cancellationToken); + } + + public static async Task ReadMcpResponseJson(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (contentType == "text/event-stream") + { + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + return line["data: ".Length..]; + } + } + } + + return body; + } + + public static McpListToolsResponse DeserializeListToolsResponse(string toolsJson) => + JsonSerializer.Deserialize(toolsJson, SerializerOptions)!; + + public static McpCallToolResponse DeserializeCallToolResponse(string toolResult) => + JsonSerializer.Deserialize(toolResult, SerializerOptions)!; + + public static string FormatToolsForApproval(List sortedTools) => + JsonSerializer.Serialize(sortedTools); + + 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, CancellationToken cancellationToken) + { + 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, cancellationToken); + } +} + +public sealed record McpSessionInfo(string SessionId, string ProtocolVersion); + +public sealed class McpListToolsResponse +{ + public McpListToolsResult Result { get; set; } = new(); +} + +public sealed class McpListToolsResult +{ + public List Tools { get; set; } = []; +} + +public sealed class McpCallToolResponse +{ + public McpCallToolResult Result { get; set; } = new(); +} + +public sealed class McpCallToolResult +{ + public JsonElement StructuredContent { get; set; } + public List Content { get; set; } = []; +} + +public sealed class McpContent +{ + public string Type { get; set; } = string.Empty; + public string? Text { get; set; } +} + +public sealed class McpInitializeResponse +{ + public McpInitializeResult Result { get; set; } = new(); +} + +public sealed class McpInitializeResult +{ + public string ProtocolVersion { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj index 718b22bc78..20ceb4a302 100644 --- a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj +++ b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj index f6b9fd49e9..23c0b26354 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj +++ b/src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj index cbc66d5b4a..6af36e8e48 100644 --- a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj +++ b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_failed_message_tools_are_available.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_failed_message_tools_are_available.cs new file mode 100644 index 0000000000..b1f65f6dc5 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_failed_message_tools_are_available.cs @@ -0,0 +1,54 @@ +#nullable enable +namespace ServiceControl.AcceptanceTests.Mcp; + +using System.Net; +using System.Threading.Tasks; +using AcceptanceTesting; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using ServiceControl.AcceptanceTesting.Mcp; + +[TestFixture] +class When_failed_message_tools_are_available : AcceptanceTest +{ + [SetUp] + public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true; + + [Test] + public async Task Should_return_structured_content_for_a_missing_failed_message() + { + string? toolResult = null; + + await Define() + .Done(async _ => + { + var session = await McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); + if (session == null) + { + return false; + } + + var response = await McpAcceptanceTestSupport.SendMcpRequest(HttpClient, session, "tools/call", new + { + name = "get_failed_message_by_id", + arguments = new { failedMessageId = "missing-message-id" } + }); + + if (response == null || response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + toolResult = await McpAcceptanceTestSupport.ReadMcpResponseJson(response); + return true; + }) + .Run(); + + Assert.That(toolResult, Is.Not.Null); + var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult); + McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent => + { + Assert.That(structuredContent.GetProperty("error").GetString(), Does.Contain("not found")); + }); + } +} \ No newline at end of file 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..a12473545c --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs @@ -0,0 +1,132 @@ +#nullable enable +namespace ServiceControl.AcceptanceTests.Mcp; + +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using Particular.Approvals; +using ServiceControl.AcceptanceTesting.Mcp; + +[TestFixture] +class When_mcp_server_is_enabled : AcceptanceTest +{ + [SetUp] + public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true; + + [Test] + public async Task Should_expose_mcp_endpoint() + { + await Define() + .Done(async _ => + { + var response = await InitializeMcpSession(); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + } + + [Test] + public async Task Should_list_read_only_primary_instance_tools() + { + string? toolsJson = null; + + await Define() + .Done(async _ => + { + var session = await InitializeAndGetSessionInfo(); + if (session == null) + { + return false; + } + + var response = await SendMcpRequest(session, "tools/list", new { }); + if (response == null) + { + return false; + } + + toolsJson = await ReadMcpResponseJson(response); + return response.StatusCode == HttpStatusCode.OK; + }) + .Run(); + + Assert.That(toolsJson, Is.Not.Null); + var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(toolsJson); + var sortedTools = mcpResponse.Result.Tools.OrderBy(tool => tool.GetProperty("name").GetString()).ToList(); + AssertReadOnlyTools(sortedTools); + McpAcceptanceTestSupport.AssertToolsHaveOutputSchema(sortedTools); + var formattedTools = McpAcceptanceTestSupport.FormatToolsForApproval(sortedTools); + Approver.Verify(formattedTools); + } + + [Test] + public async Task Should_call_get_errors_summary_tool() + { + string? toolResult = null; + + await Define() + .Done(async _ => + { + var session = await InitializeAndGetSessionInfo(); + if (session == null) + { + return false; + } + + var response = await SendMcpRequest(session, "tools/call", new + { + name = "get_errors_summary", + arguments = new { } + }); + + if (response == null || response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + toolResult = await ReadMcpResponseJson(response); + return true; + }) + .Run(); + + Assert.That(toolResult, Is.Not.Null); + var mcpResponse = McpAcceptanceTestSupport.DeserializeCallToolResponse(toolResult); + McpAcceptanceTestSupport.AssertStructuredToolResponse(toolResult, mcpResponse.Result.StructuredContent, mcpResponse.Result.Content, structuredContent => + { + Assert.That(structuredContent.GetProperty("unresolved").GetInt64(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("archived").GetInt64(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("resolved").GetInt64(), Is.GreaterThanOrEqualTo(0)); + Assert.That(structuredContent.GetProperty("retryIssued").GetInt64(), Is.GreaterThanOrEqualTo(0)); + }); + } + + static void AssertReadOnlyTools(IReadOnlyCollection tools) + { + Assert.That(tools, Has.Count.EqualTo(8)); + + var names = tools.Select(tool => tool.GetProperty("name").GetString()).ToArray(); + + Assert.That(names, Does.Contain("get_errors_summary")); + Assert.That(names, Does.Contain("get_failed_messages")); + Assert.That(names, Does.Contain("get_failed_message_by_id")); + Assert.That(names, Does.Contain("get_failed_message_last_attempt")); + Assert.That(names, Does.Contain("get_failed_messages_by_endpoint")); + Assert.That(names, Does.Contain("get_failure_groups")); + Assert.That(names, Does.Contain("get_failure_group_errors")); + Assert.That(names, Does.Contain("get_retry_history")); + } + + Task InitializeMcpSession() => McpAcceptanceTestSupport.InitializeMcpSession(HttpClient); + + Task InitializeAndGetSessionInfo() => McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); + + Task SendMcpRequest(McpSessionInfo sessionInfo, string method, object @params) => McpAcceptanceTestSupport.SendMcpRequest(HttpClient, sessionInfo, method, @params); + + static Task ReadMcpResponseJson(HttpResponseMessage response) => McpAcceptanceTestSupport.ReadMcpResponseJson(response); +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_disabled.cs new file mode 100644 index 0000000000..5f9fd951b7 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_disabled.cs @@ -0,0 +1,51 @@ +#nullable enable +namespace ServiceControl.AcceptanceTests.Mcp; + +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using ServiceControl.AcceptanceTesting.Mcp; + +[TestFixture] +class When_mcp_server_write_mode_is_disabled : AcceptanceTest +{ + [SetUp] + public void EnableReadOnlyMcp() => SetSettings = s => s.EnableMcpServer = true; + + [Test] + public async Task Should_not_expose_write_tools() + { + string?[]? toolNames = null; + + await Define() + .Done(async _ => + { + var session = await McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); + if (session == null) + { + return false; + } + + var response = await McpAcceptanceTestSupport.SendMcpRequest(HttpClient, session, "tools/list", new { }); + if (response == null || response.StatusCode != System.Net.HttpStatusCode.OK) + { + return false; + } + + var json = await McpAcceptanceTestSupport.ReadMcpResponseJson(response); + var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(json); + toolNames = mcpResponse.Result.Tools.Cast() + .Select(t => t.GetProperty("name").GetString()) + .ToArray(); + return true; + }) + .Run(); + + Assert.That(toolNames, Is.Not.Null); + Assert.That(toolNames, Does.Not.Contain("retry_failed_message")); + Assert.That(toolNames, Does.Not.Contain("retry_failure_group")); + } +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_enabled.cs new file mode 100644 index 0000000000..76273d2341 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_write_mode_is_enabled.cs @@ -0,0 +1,54 @@ +#nullable enable +namespace ServiceControl.AcceptanceTests.Mcp; + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using ServiceControl.AcceptanceTesting.Mcp; + +[TestFixture] +class When_mcp_server_write_mode_is_enabled : AcceptanceTest +{ + [SetUp] + public void EnableWriteMode() => SetSettings = s => s.EnableMcpServerWriteMode = true; + + [Test] + public async Task Should_expose_write_tools() + { + string?[]? toolNames = null; + + await Define() + .Done(async _ => + { + var session = await McpAcceptanceTestSupport.InitializeAndGetSessionInfo(HttpClient); + if (session == null) + { + return false; + } + + var response = await McpAcceptanceTestSupport.SendMcpRequest(HttpClient, session, "tools/list", new { }); + if (response == null || response.StatusCode != HttpStatusCode.OK) + { + return false; + } + + var json = await McpAcceptanceTestSupport.ReadMcpResponseJson(response); + var mcpResponse = McpAcceptanceTestSupport.DeserializeListToolsResponse(json); + toolNames = mcpResponse.Result.Tools.Cast() + .Select(t => t.GetProperty("name").GetString()) + .ToArray(); + return true; + }) + .Run(); + + Assert.That(toolNames, Is.Not.Null); + Assert.That(toolNames, Does.Contain("retry_failed_message")); + Assert.That(toolNames, Does.Contain("retry_failure_group")); + Assert.That(toolNames, Has.Length.EqualTo(10)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index b78c31a0da..36352bbc32 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -148,7 +148,7 @@ async Task InitializeServiceControlCore(ScenarioContext context) host.UseTestRemoteIp(); host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled); - host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings); + host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer || settings.EnableMcpServerWriteMode); await host.StartAsync(); DomainEvents = host.Services.GetRequiredService(); // Bring this back and look into the base address of the client diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index abd4c98313..dd07c6650e 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -1,4 +1,4 @@ -{ +{ "LoggingSettings": { "LogLevel": "Information", "LogPath": "C:\\Logs" @@ -44,8 +44,9 @@ "NotificationsFilter": null, "AllowMessageEditing": false, "EnableIntegratedServicePulse": false, + "EnableMcpServer": false, + "EnableMcpServerWriteMode": false, "ServicePulseSettings": null, - "MessageFilter": null, "EmailDropFolder": null, "ValidateConfiguration": true, "ExternalIntegrationsDispatchingBatchSize": 100, diff --git a/src/ServiceControl.UnitTests/Mcp/McpAuthorizationServiceTests.cs b/src/ServiceControl.UnitTests/Mcp/McpAuthorizationServiceTests.cs new file mode 100644 index 0000000000..5546a5ee19 --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpAuthorizationServiceTests.cs @@ -0,0 +1,91 @@ +#nullable enable +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using ServiceControl.Mcp.Authorization; + +[TestFixture] +public class McpAuthorizationServiceTests +{ + [Test] + public async Task RequirePermissionAsync_uses_the_current_http_user_and_permission_name() + { + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "alice-sub-001"), + new Claim(ClaimTypes.Name, "Alice Smith"), + new Claim(ClaimTypes.Role, "reader") + }, authenticationType: "Bearer")) + } + }; + + var authorizationService = new RecordingAuthorizationService(); + var service = new McpAuthorizationService(authorizationService, httpContextAccessor); + + await service.RequirePermissionAsync(McpPermissions.RetryFailure, CancellationToken.None); + + Assert.That(authorizationService.Permission, Is.EqualTo(McpPermissions.RetryFailure)); + Assert.That(authorizationService.User?.Identity?.IsAuthenticated, Is.True); + Assert.That(authorizationService.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value, Is.EqualTo("alice-sub-001")); + Assert.That(authorizationService.User?.FindFirst(ClaimTypes.Role)?.Value, Is.EqualTo("reader")); + } + + [Test] + public void RequirePermissionAsync_throws_when_authorization_fails() + { + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity()) + } + }; + + var authorizationService = new DenyingAuthorizationService(); + var service = new McpAuthorizationService(authorizationService, httpContextAccessor); + + Assert.That( + async () => await service.RequirePermissionAsync(McpPermissions.GetFailureGroup, CancellationToken.None), + Throws.TypeOf().With.Message.Contains(McpPermissions.GetFailureGroup)); + } + + sealed class RecordingAuthorizationService : IAuthorizationService + { + public ClaimsPrincipal? User { get; private set; } + public string? Permission { get; private set; } + + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + throw new System.NotSupportedException(); + } + + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + { + User = user; + Permission = policyName; + return Task.FromResult(AuthorizationResult.Success()); + } + } + + sealed class DenyingAuthorizationService : IAuthorizationService + { + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + throw new System.NotSupportedException(); + } + + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + => Task.FromResult(AuthorizationResult.Failed()); + } +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Mcp/McpPermissionsTests.cs b/src/ServiceControl.UnitTests/Mcp/McpPermissionsTests.cs new file mode 100644 index 0000000000..7463641918 --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpPermissionsTests.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace ServiceControl.UnitTests.Mcp; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Mcp.Authorization; + +[TestFixture] +public class McpPermissionsTests +{ + [Test] + public void Failure_and_retry_tools_reuse_the_existing_permission_constants() + { + Assert.That(McpPermissions.ListFailures, Is.EqualTo(Permissions.ErrorMessagesView)); + Assert.That(McpPermissions.GetFailure, Is.EqualTo(Permissions.ErrorMessagesView)); + Assert.That(McpPermissions.GetFailureLastAttempt, Is.EqualTo(Permissions.ErrorMessagesView)); + Assert.That(McpPermissions.GetFailuresByEndpoint, Is.EqualTo(Permissions.ErrorMessagesView)); + Assert.That(McpPermissions.GetErrorsSummary, Is.EqualTo(Permissions.ErrorMessagesView)); + + Assert.That(McpPermissions.ListFailureGroups, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsView)); + Assert.That(McpPermissions.GetFailureGroup, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsView)); + Assert.That(McpPermissions.GetRetryHistory, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsView)); + + Assert.That(McpPermissions.RetryFailure, Is.EqualTo(Permissions.ErrorMessagesRetry)); + Assert.That(McpPermissions.RetryFailureGroup, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Mcp/McpServerConfigurationTests.cs b/src/ServiceControl.UnitTests/Mcp/McpServerConfigurationTests.cs new file mode 100644 index 0000000000..0d2fb60567 --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpServerConfigurationTests.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace ServiceControl.UnitTests.Mcp; + +using NUnit.Framework; +using ServiceControl.Mcp; + +[TestFixture] +class McpServerConfigurationTests +{ + [Test] + public void Server_instructions_guide_the_model_toward_the_important_tools() + { + Assert.Multiple(() => + { + Assert.That(McpServerConfiguration.ServerInstructions, Does.Contain("get_errors_summary")); + Assert.That(McpServerConfiguration.ServerInstructions, Does.Contain("get_failure_groups")); + Assert.That(McpServerConfiguration.ServerInstructions, Does.Contain("Retry tools")); + }); + } + + [Test] + public void Overview_prompt_matches_the_expected_first_steps() + { + var prompt = ServiceControlMcpPrompts.ServiceControlOverview(); + + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("get_errors_summary")); + Assert.That(prompt, Does.Contain("get_failed_message_by_id")); + Assert.That(prompt, Does.Contain("retry_failure_group")); + }); + } +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Mcp/McpToolInputValidationTests.cs b/src/ServiceControl.UnitTests/Mcp/McpToolInputValidationTests.cs new file mode 100644 index 0000000000..6798d56696 --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/McpToolInputValidationTests.cs @@ -0,0 +1,66 @@ +#nullable enable +namespace ServiceControl.UnitTests.Mcp; + +using System; +using NUnit.Framework; +using ServiceControl.Mcp; + +[TestFixture] +class McpToolInputValidationTests +{ + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("undefined")] + [TestCase("UNDEFINED")] + public void NormalizeOptionalFilter_treats_blank_and_undefined_as_missing(string? value) + { + Assert.That(McpToolInputValidation.NormalizeOptionalFilter(value), Is.Null); + } + + [TestCase(null, "time_of_failure")] + [TestCase("time_sent", "time_sent")] + [TestCase("MESSAGE_TYPE", "message_type")] + public void NormalizeSort_accepts_the_allowed_values_and_defaults_when_missing(string? value, string expected) + { + Assert.That(McpToolInputValidation.NormalizeSort(value), Is.EqualTo(expected)); + } + + [TestCase(null, "desc")] + [TestCase("asc", "asc")] + [TestCase("DESC", "desc")] + public void NormalizeDirection_accepts_the_allowed_values_and_defaults_when_missing(string? value, string expected) + { + Assert.That(McpToolInputValidation.NormalizeDirection(value), Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase("undefined")] + [TestCase("UNDEFINED")] + [TestCase("resolved")] + [TestCase("ARCHIVED")] + public void NormalizeStatus_accepts_the_allowed_values_and_omits_missing_values(string? value) + { + var expected = McpToolInputValidation.NormalizeOptionalFilter(value) is null ? null : value!.Trim().ToLowerInvariant(); + + Assert.That(McpToolInputValidation.NormalizeStatus(value), Is.EqualTo(expected)); + } + + [Test] + public void NormalizeSort_rejects_invalid_values() + { + Assert.That(() => McpToolInputValidation.NormalizeSort("not-a-sort"), Throws.TypeOf()); + } + + [Test] + public void NormalizeDirection_rejects_invalid_values() + { + Assert.That(() => McpToolInputValidation.NormalizeDirection("up"), Throws.TypeOf()); + } + + [Test] + public void NormalizeStatus_rejects_invalid_values() + { + Assert.That(() => McpToolInputValidation.NormalizeStatus("bad-status"), Throws.TypeOf()); + } +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Mcp/RetryToolsTests.cs b/src/ServiceControl.UnitTests/Mcp/RetryToolsTests.cs new file mode 100644 index 0000000000..ee2bf9df2d --- /dev/null +++ b/src/ServiceControl.UnitTests/Mcp/RetryToolsTests.cs @@ -0,0 +1,144 @@ +#nullable enable +namespace ServiceControl.UnitTests.Mcp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NServiceBus; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Mcp; +using ServiceControl.Mcp.Authorization; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.InternalMessages; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; +using ServiceControl.Recoverability.Retrying.Metrics; +using ServiceControl.UnitTests.Operations; +using ServiceControl.UnitTests.Recoverability; + +[TestFixture] +public class RetryToolsTests +{ + [Test] + public async Task RetryFailedMessage_emits_an_audited_operation_and_message_headers() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var tools = CreateTools(clock, session, new RetryingManager(new FakeDomainEvents(), TestRetryMetrics.Create(clock), NullLogger.Instance, clock), audit); + + var result = await tools.RetryFailedMessage("msg-1"); + + Assert.That(result.Status, Is.EqualTo("accepted")); + Assert.That(result.Message, Does.Contain("msg-1")); + + var sent = session.SentMessages.Single(message => message.Message is RetryMessage); + Assert.That(((RetryMessage)sent.Message).FailedMessageId, Is.EqualTo("msg-1")); + + var headers = sent.Options.GetHeaders(); + using (Assert.EnterMultipleScope()) + { + Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo("alice-sub-001")); + Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo("Alice")); + Assert.That(headers[AuditHeaders.OperationId], Is.EqualTo("trace-retry")); + } + + var op = audit.Operations.Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorMessagesRetry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("msg-1")); + Assert.That(op.OperationId, Is.EqualTo("trace-retry")); + Assert.That(op.Success, Is.True); + } + } + + [Test] + public async Task RetryFailureGroup_returns_in_progress_when_a_retry_is_already_running() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var retryingManager = new RetryingManager(new FakeDomainEvents(), TestRetryMetrics.Create(clock), NullLogger.Instance, clock); + await retryingManager.Preparing("group-42", RetryType.FailureGroup, 10, clock.GetUtcNow().UtcDateTime); + + var tools = CreateTools(clock, session, retryingManager, audit); + var result = await tools.RetryFailureGroup("group-42"); + + Assert.That(result.Status, Is.EqualTo("in_progress")); + Assert.That(session.SentMessages, Is.Empty); + Assert.That(audit.Operations, Is.Empty); + } + + [Test] + public async Task RetryFailureGroup_emits_an_audited_operation_and_retry_message() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var tools = CreateTools(clock, session, new RetryingManager(new FakeDomainEvents(), TestRetryMetrics.Create(clock), NullLogger.Instance, clock), audit); + + var result = await tools.RetryFailureGroup("group-42"); + + Assert.That(result.Status, Is.EqualTo("accepted")); + Assert.That(result.Message, Does.Contain("group-42")); + + var sent = session.SentMessages.Single(message => message.Message is RetryAllInGroup); + Assert.That(((RetryAllInGroup)sent.Message).GroupId, Is.EqualTo("group-42")); + + var op = audit.Operations.Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-42")); + Assert.That(op.OperationId, Is.EqualTo("trace-retry")); + Assert.That(op.Success, Is.True); + } + } + + static RetryTools CreateTools(TimeProvider clock, TestableMessageSession session, RetryingManager retryingManager, RecordingMessageActionAuditLog audit) + { + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + TraceIdentifier = "trace-retry", + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "alice-sub-001"), + new Claim(ClaimTypes.Name, "Alice") + }, authenticationType: "Bearer")) + } + }; + + return new RetryTools( + session, + retryingManager, + clock, + new StubCurrentUserAccessor(new AuditUser("alice-sub-001", "Alice")), + httpContextAccessor, + audit, + new McpAuthorizationService(new AllowAllAuthorizationService(), httpContextAccessor)); + } + + sealed class AllowAllAuthorizationService : IAuthorizationService + { + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) => + throw new NotSupportedException(); + + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) => + Task.FromResult(AuthorizationResult.Success()); + } +} \ No newline at end of file diff --git a/src/ServiceControl/App.config b/src/ServiceControl/App.config index 98d8fc8433..4b7dbfe9e0 100644 --- a/src/ServiceControl/App.config +++ b/src/ServiceControl/App.config @@ -12,6 +12,8 @@ These settings are only here so that we can debug ServiceControl while developin + + diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index 9b682fe1a6..07eb255577 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -25,6 +25,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.WindowsServices; using Microsoft.Extensions.Logging; + using ModelContextProtocol.AspNetCore; using NServiceBus; using NServiceBus.Configuration.AdvancedExtensibility; using NServiceBus.Hosting; @@ -146,6 +147,30 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S } hostBuilder.AddServiceControlComponents(componentSetupContext, settings, transportCustomization, serviceControlComponents); + + if (settings.EnableMcpServer || settings.EnableMcpServerWriteMode) + { + services.AddScoped(); + services.AddCors(options => options.AddPolicy(global::ServiceControl.Mcp.McpServerConfiguration.CorsPolicyName, policy => policy + .AllowAnyOrigin() + .AllowAnyHeader() + .WithMethods("POST") + .WithExposedHeaders("Mcp-Session-Id"))); + + var mcpBuilder = services.AddMcpServer(options => options.ServerInstructions = global::ServiceControl.Mcp.McpServerConfiguration.ServerInstructions) + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.Stateless; + }) + .WithPrompts() + .WithTools(global::ServiceControl.Mcp.McpJsonOptions.Default) + .WithTools(global::ServiceControl.Mcp.McpJsonOptions.Default); + + if (settings.EnableMcpServerWriteMode) + { + mcpBuilder.WithTools(global::ServiceControl.Mcp.McpJsonOptions.Default); + } + } } public static void AddServiceControlInstallers(this IHostApplicationBuilder hostApplicationBuilder, Settings settings) diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index 33a6361511..56e3135cfe 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -32,7 +32,7 @@ public override async Task Execute(HostArguments args, Settings settings, Cancel hostBuilder.AddServiceControlApi(settings.CorsSettings); var app = hostBuilder.Build(); - app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings); + app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer || settings.EnableMcpServerWriteMode); if (settings.EnableIntegratedServicePulse) { app.UseServicePulse(settings.ServicePulseSettings); @@ -42,4 +42,4 @@ public override async Task Execute(HostArguments args, Settings settings, Cancel await app.RunAsync(settings.RootUrl); } } -} +} \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index af52f4bf88..2bfcf76232 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -69,6 +69,8 @@ public Settings( RetryHistoryDepth = SettingsReader.Read(SettingsRootNamespace, "RetryHistoryDepth", 10); AllowMessageEditing = SettingsReader.Read(SettingsRootNamespace, "AllowMessageEditing"); EnableIntegratedServicePulse = SettingsReader.Read(SettingsRootNamespace, "EnableIntegratedServicePulse", false); + EnableMcpServerWriteMode = SettingsReader.Read(SettingsRootNamespace, "EnableMcpServerWriteMode", false); + EnableMcpServer = SettingsReader.Read(SettingsRootNamespace, "EnableMcpServer", false) || EnableMcpServerWriteMode; if (EnableIntegratedServicePulse) { ServicePulseSettings = ServicePulseSettings.GetFromEnvironmentVariables() with @@ -120,9 +122,12 @@ public Settings( public bool AllowMessageEditing { get; set; } public bool EnableIntegratedServicePulse { get; set; } + public bool EnableMcpServer { get; set; } + public bool EnableMcpServerWriteMode { get; set; } public ServicePulseSettings ServicePulseSettings { get; set; } //HINT: acceptance tests only + [JsonIgnore] public Func MessageFilter { get; set; } //HINT: acceptance tests only diff --git a/src/ServiceControl/Mcp/Authorization/McpAuthorizationService.cs b/src/ServiceControl/Mcp/Authorization/McpAuthorizationService.cs new file mode 100644 index 0000000000..824162e08b --- /dev/null +++ b/src/ServiceControl/Mcp/Authorization/McpAuthorizationService.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace ServiceControl.Mcp.Authorization; + +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +/// +/// Wraps the existing ServiceControl authorization pipeline so MCP tools can enforce the exact same +/// permission policies as the built-in HTTP API. +/// +public sealed class McpAuthorizationService( + IAuthorizationService authorizationService, + IHttpContextAccessor httpContextAccessor) +{ + public async Task RequirePermissionAsync(string permission, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(permission); + + var user = httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()); + var result = await authorizationService.AuthorizeAsync(user, resource: null, permission); + + if (result.Succeeded) + { + return; + } + + throw new UnauthorizedAccessException($"Access denied for permission '{permission}'."); + } +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/Authorization/McpPermissions.cs b/src/ServiceControl/Mcp/Authorization/McpPermissions.cs new file mode 100644 index 0000000000..277c6d45f3 --- /dev/null +++ b/src/ServiceControl/Mcp/Authorization/McpPermissions.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace ServiceControl.Mcp.Authorization; + +using ServiceControl.Infrastructure.Auth; + +/// +/// MCP tool-to-permission mapping that intentionally reuses the existing ServiceControl permission +/// constants rather than inventing a separate auth model. +/// +public static class McpPermissions +{ + public const string ListFailures = Permissions.ErrorMessagesView; + public const string GetFailure = Permissions.ErrorMessagesView; + public const string GetFailureLastAttempt = Permissions.ErrorMessagesView; + public const string GetFailuresByEndpoint = Permissions.ErrorMessagesView; + public const string GetErrorsSummary = Permissions.ErrorMessagesView; + + public const string ListFailureGroups = Permissions.ErrorRecoverabilityGroupsView; + public const string GetFailureGroup = Permissions.ErrorRecoverabilityGroupsView; + public const string GetRetryHistory = Permissions.ErrorRecoverabilityGroupsView; + + public const string RetryFailure = Permissions.ErrorMessagesRetry; + public const string RetryFailureGroup = Permissions.ErrorRecoverabilityGroupsRetry; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpCollectionResult.cs b/src/ServiceControl/Mcp/McpCollectionResult.cs new file mode 100644 index 0000000000..2e7524e56c --- /dev/null +++ b/src/ServiceControl/Mcp/McpCollectionResult.cs @@ -0,0 +1,10 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.Collections.Generic; + +public sealed class McpCollectionResult +{ + public int TotalCount { get; init; } + public IReadOnlyCollection Results { get; init; } = []; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpErrorsSummaryResult.cs b/src/ServiceControl/Mcp/McpErrorsSummaryResult.cs new file mode 100644 index 0000000000..8de0e827ec --- /dev/null +++ b/src/ServiceControl/Mcp/McpErrorsSummaryResult.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace ServiceControl.Mcp; + +public sealed 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(long unresolved, long archived, long resolved, long retryIssued) + => new() + { + Unresolved = unresolved, + Archived = archived, + Resolved = resolved, + RetryIssued = retryIssued + }; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpFailedMessageResult.cs b/src/ServiceControl/Mcp/McpFailedMessageResult.cs new file mode 100644 index 0000000000..991d3a1b1e --- /dev/null +++ b/src/ServiceControl/Mcp/McpFailedMessageResult.cs @@ -0,0 +1,113 @@ +#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 ServiceControl.MessageFailures; + +public sealed 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) => new() + { + Id = message.Id ?? string.Empty, + ProcessingAttempts = message.ProcessingAttempts.Select(McpFailedProcessingAttemptResult.From).ToList(), + FailureGroups = message.FailureGroups.Select(McpFailedFailureGroupResult.From).ToList(), + UniqueMessageId = message.UniqueMessageId, + Status = message.Status + }; +} + +public sealed class McpFailedProcessingAttemptResult +{ + public List MessageMetadata { get; init; } = []; + public FailureDetails? FailureDetails { get; init; } + public DateTime AttemptedAt { get; init; } + public string? MessageId { get; init; } + public string? Body { get; init; } + public Dictionary Headers { get; init; } = []; + + public static McpFailedProcessingAttemptResult From(FailedMessage.ProcessingAttempt attempt) => new() + { + 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 sealed 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) => new() + { + Key = key, + Value = FormatValue(value), + Type = GetTypeName(value) + }; + + static string? FormatValue(object? value) => value switch + { + null => null, + DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture), + TimeSpan timeSpan => timeSpan.ToString("c", CultureInfo.InvariantCulture), + bool boolean => boolean ? "true" : "false", + string text => text, + sbyte number => number.ToString(CultureInfo.InvariantCulture), + byte number => number.ToString(CultureInfo.InvariantCulture), + short number => number.ToString(CultureInfo.InvariantCulture), + ushort number => number.ToString(CultureInfo.InvariantCulture), + int number => number.ToString(CultureInfo.InvariantCulture), + uint number => number.ToString(CultureInfo.InvariantCulture), + long number => number.ToString(CultureInfo.InvariantCulture), + ulong number => number.ToString(CultureInfo.InvariantCulture), + float number => number.ToString(CultureInfo.InvariantCulture), + double number => number.ToString(CultureInfo.InvariantCulture), + decimal number => number.ToString(CultureInfo.InvariantCulture), + Enum enumValue => enumValue.ToString(), + _ => JsonSerializer.Serialize(value, value.GetType(), McpJsonOptions.Default) + }; + + static string GetTypeName(object? value) => 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 sealed class McpFailedFailureGroupResult +{ + public string Id { get; init; } = string.Empty; + public string? Title { get; init; } + public string? Type { get; init; } + + public static McpFailedFailureGroupResult From(FailedMessage.FailureGroup group) => new() + { + Id = group.Id, + Title = group.Title, + Type = group.Type + }; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs new file mode 100644 index 0000000000..a755f9bb81 --- /dev/null +++ b/src/ServiceControl/Mcp/McpFailedMessageViewResult.cs @@ -0,0 +1,47 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System; +using ServiceControl.Contracts.Operations; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Operations; + +public sealed class McpFailedMessageViewResult +{ + public string? Error { get; init; } + public string Id { get; init; } = string.Empty; + public string? MessageType { get; init; } + public DateTime? TimeSent { get; init; } + public bool IsSystemMessage { get; init; } + public ExceptionDetails? Exception { get; init; } + public string? MessageId { get; init; } + 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; } + public DateTime TimeOfFailure { get; init; } + public DateTime LastModified { get; init; } + public bool Edited { get; init; } + public string? EditOf { get; init; } + + public static McpFailedMessageViewResult From(FailedMessageView message) => new() + { + 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 + }; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpJsonOptions.cs b/src/ServiceControl/Mcp/McpJsonOptions.cs new file mode 100644 index 0000000000..eea2a032ba --- /dev/null +++ b/src/ServiceControl/Mcp/McpJsonOptions.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +static class McpJsonOptions +{ + public static JsonSerializerOptions Default { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + TypeInfoResolverChain = { McpSerializationContext.Default, new DefaultJsonTypeInfoResolver() } + }; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpOperationResult.cs b/src/ServiceControl/Mcp/McpOperationResult.cs new file mode 100644 index 0000000000..c3c6024cd5 --- /dev/null +++ b/src/ServiceControl/Mcp/McpOperationResult.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace ServiceControl.Mcp; + +public sealed class McpOperationResult +{ + public string Status { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public static McpOperationResult Accepted(string message) => new() { Status = "accepted", Message = message }; + + public static McpOperationResult InProgress(string message) => new() { Status = "in_progress", Message = message }; + + public static McpOperationResult ValidationError(string message) => new() { Status = "validation_error", Message = message }; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpSerializationContext.cs b/src/ServiceControl/Mcp/McpSerializationContext.cs new file mode 100644 index 0000000000..79cc4e5a0a --- /dev/null +++ b/src/ServiceControl/Mcp/McpSerializationContext.cs @@ -0,0 +1,30 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using ServiceControl.Contracts.Operations; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +[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(FailedMessageView))] +[JsonSerializable(typeof(FailedMessage))] +[JsonSerializable(typeof(GroupOperation[]))] +[JsonSerializable(typeof(RetryHistory))] +public partial class McpSerializationContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpServerConfiguration.cs b/src/ServiceControl/Mcp/McpServerConfiguration.cs new file mode 100644 index 0000000000..e27d450cda --- /dev/null +++ b/src/ServiceControl/Mcp/McpServerConfiguration.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace ServiceControl.Mcp; + +public static class McpServerConfiguration +{ + public const string Route = "/mcp"; + + public const string CorsPolicyName = "servicecontrol-mcp"; + + public const string ServerInstructions = "ServiceControl documentation is available through the failure and recoverability tools. Start with get_errors_summary or get_failure_groups, then drill into a specific failed message, failure group, or retry history. Retry tools are write operations and should only be used after the underlying issue has been resolved."; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/McpToolInputValidation.cs b/src/ServiceControl/Mcp/McpToolInputValidation.cs new file mode 100644 index 0000000000..8f0b3c3c4f --- /dev/null +++ b/src/ServiceControl/Mcp/McpToolInputValidation.cs @@ -0,0 +1,87 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System; +using System.Collections.Generic; + +static class McpToolInputValidation +{ + static readonly HashSet AllowedStatuses = new(StringComparer.OrdinalIgnoreCase) + { + "unresolved", + "archived", + "retryissued", + "resolved" + }; + + static readonly HashSet AllowedSorts = new(StringComparer.OrdinalIgnoreCase) + { + "time_sent", + "message_type", + "time_of_failure" + }; + + static readonly HashSet AllowedDirections = new(StringComparer.OrdinalIgnoreCase) + { + "asc", + "desc" + }; + + public static string? NormalizeOptionalFilter(string? value) + { + if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "undefined", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return value.Trim(); + } + + public static string? NormalizeStatus(string? status) + { + var normalized = NormalizeOptionalFilter(status); + if (normalized == null) + { + return null; + } + + if (!AllowedStatuses.Contains(normalized)) + { + throw new ArgumentException($"Unsupported status '{status}'. Supported values are unresolved, archived, retryissued, and resolved."); + } + + return normalized.ToLowerInvariant(); + } + + public static string NormalizeSort(string? sort) + { + var normalized = NormalizeOptionalFilter(sort); + if (normalized == null) + { + return "time_of_failure"; + } + + if (!AllowedSorts.Contains(normalized)) + { + throw new ArgumentException($"Unsupported sort '{sort}'. Supported values are time_sent, message_type, and time_of_failure."); + } + + return normalized.ToLowerInvariant(); + } + + public static string NormalizeDirection(string? direction) + { + var normalized = NormalizeOptionalFilter(direction); + if (normalized == null) + { + return "desc"; + } + + if (!AllowedDirections.Contains(normalized)) + { + throw new ArgumentException($"Unsupported direction '{direction}'. Supported values are asc and desc."); + } + + return normalized.ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/RetryTools.cs b/src/ServiceControl/Mcp/RetryTools.cs new file mode 100644 index 0000000000..3d64b912aa --- /dev/null +++ b/src/ServiceControl/Mcp/RetryTools.cs @@ -0,0 +1,92 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; +using NServiceBus; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Mcp.Authorization; +using ServiceControl.MessageFailures.InternalMessages; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +[Description( + "Tools for retrying failed messages and failure groups.\n\n" + + "Agent guidance:\n" + + "1. Every tool in this group changes system state by sending failed messages back for reprocessing. Only retry after the underlying issue has been resolved.\n" + + "2. Prefer RetryFailureGroup when all messages share the same root cause.\n" + + "3. Retry a single failed message only when the user explicitly asks for that message.\n" + + "4. All operations are asynchronous — they return Accepted or InProgress immediately and complete in the background.")] +public sealed class RetryTools( + IMessageSession messageSession, + RetryingManager retryingManager, + TimeProvider timeProvider, + ICurrentUserAccessor userAccessor, + IHttpContextAccessor httpContextAccessor, + IMessageActionAuditLog auditLog, + McpAuthorizationService authorization) +{ + [McpServerTool(Name = "retry_failed_message", 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' or 'send this message back for processing'.")] + public async Task RetryFailedMessage( + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.RetryFailure, cancellationToken); + + var user = ResolveUser(); + var operationId = GetOperationId(); + + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: operationId, + ct => messageSession.Send(m => m.FailedMessageId = failedMessageId, AuditHeaders.LocalSendOptions(user, operationId), ct), cancellationToken); + + return McpOperationResult.Accepted($"Retry requested for message '{failedMessageId}'."); + } + + [McpServerTool(Name = "retry_failure_group", 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. This operation changes system state. It may affect many messages. " + + "Use the failure group ID from GetFailureGroups. Returns InProgress if a retry is already running for this group.")] + public async Task RetryFailureGroup( + [Description("The failure group ID from previous GetFailureGroups results.")] string groupId, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.RetryFailureGroup, cancellationToken); + + var started = timeProvider.GetUtcNow().UtcDateTime; + if (retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) + { + return McpOperationResult.InProgress($"A retry operation is already in progress for group '{groupId}'."); + } + + var user = ResolveUser(); + var operationId = GetOperationId(); + + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, + resource: groupId, count: null, operationId: operationId, + async ct => + { + await retryingManager.Wait(groupId, RetryType.FailureGroup, started, cancellationToken: ct); + await messageSession.Send(new RetryAllInGroup + { + GroupId = groupId, + Started = started + }, AuditHeaders.LocalSendOptions(user, operationId), ct); + }, cancellationToken); + + return McpOperationResult.Accepted($"Retry requested for all messages in failure group '{groupId}'."); + } + + AuditUser ResolveUser() => userAccessor.Resolve(httpContextAccessor.HttpContext?.User); + + string GetOperationId() + { + var operationId = httpContextAccessor.HttpContext?.TraceIdentifier; + return string.IsNullOrWhiteSpace(operationId) ? Guid.NewGuid().ToString("N") : operationId; + } +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/ServiceControlMcpPrompts.cs b/src/ServiceControl/Mcp/ServiceControlMcpPrompts.cs new file mode 100644 index 0000000000..57a9c9e904 --- /dev/null +++ b/src/ServiceControl/Mcp/ServiceControlMcpPrompts.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.ComponentModel; +using ModelContextProtocol.Server; + +[McpServerPromptType] +public sealed class ServiceControlMcpPrompts +{ + [McpServerPrompt(Name = "servicecontrol_overview"), Description("A short orientation for the ServiceControl MCP server")] + public static string ServiceControlOverview() => """ + Use get_errors_summary to understand the overall failure picture, then use get_failure_groups or get_failed_messages_by_endpoint to narrow the scope. + Use get_failed_message_by_id or get_failed_message_last_attempt when you need details for a specific failed message. + Check get_retry_history before retrying a failure group. + Only use retry_failed_message or retry_failure_group after the underlying issue has been resolved. + """; +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/Tools/ErrorTools.cs b/src/ServiceControl/Mcp/Tools/ErrorTools.cs new file mode 100644 index 0000000000..2d7821b3a1 --- /dev/null +++ b/src/ServiceControl/Mcp/Tools/ErrorTools.cs @@ -0,0 +1,144 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Mcp.Authorization; + +[Description( + "Read-only tools for investigating failed messages.\n\n" + + "Agent guidance:\n" + + "1. Start with GetFailedMessages to get a quick view of failures.\n" + + "2. Use GetFailedMessagesByEndpoint when you already know the endpoint.\n" + + "3. Use GetFailedMessageById for the full failed-message payload, or GetFailedMessageLastAttempt for the most recent failure.\n" + + "4. Use GetErrorsSummary to understand the overall failure counts before drilling into details.\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 sealed class FailedMessageTools(McpAuthorizationService authorization) +{ + [McpServerTool(Name = "get_failed_messages", 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 GetFailedMessagesByEndpoint when you already know the endpoint. Use GetFailedMessageById when inspecting a specific failed message. Read-only.")] + public async Task> GetFailedMessages( + IFailedMessageQueryDataStore store, + [Description("Filter failed messages by status: unresolved, archived, retryissued, or resolved. Omit this filter to include all statuses.")] string? status = null, + [Description("Restricts failed-message results to entries modified after this ISO 8601 date/time. Omit this filter to include all results.")] string? modified = null, + [Description("Filter failed messages to a specific queue address, for example 'Sales@machine'. Omit this filter to include all queues.")] string? queueAddress = null, + [Description("Page number, 1-based.")] int page = 1, + [Description("Results per page.")] int perPage = 50, + [Description("Sort by: time_sent, message_type, or time_of_failure.")] string sort = "time_of_failure", + [Description("Sort direction: asc or desc.")] string direction = "desc", + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.ListFailures, cancellationToken); + + var results = await store.GetFailedMessages( + McpToolInputValidation.NormalizeStatus(status), + McpToolInputValidation.NormalizeOptionalFilter(modified), + McpToolInputValidation.NormalizeOptionalFilter(queueAddress), + new PagingInfo(page, perPage), + new SortInfo(McpToolInputValidation.NormalizeSort(sort), McpToolInputValidation.NormalizeDirection(direction)), + cancellationToken); + + return new McpCollectionResult + { + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results?.ToArray() ?? [] + }; + } + + [McpServerTool(Name = "get_failed_message_by_id", 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 GetFailedMessagesByEndpoint to locate relevant messages before calling this tool. Read-only.")] + public async Task GetFailedMessageById( + IFailedMessageQueryDataStore store, + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetFailure, cancellationToken); + + var result = await store.GetFailedMessage(failedMessageId, cancellationToken); + + return result == null + ? new McpFailedMessageResult { Error = $"Failed message '{failedMessageId}' not found." } + : McpFailedMessageResult.From(result); + } + + [McpServerTool(Name = "get_failed_message_last_attempt", 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 and context. " + + "Typically used after identifying a failed message via GetFailedMessages or GetFailedMessageById. Read-only.")] + public async Task GetFailedMessageLastAttempt( + IFailedMessageQueryDataStore store, + [Description("The failed message ID from a previous failed-message query result.")] string failedMessageId, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetFailureLastAttempt, cancellationToken); + + var result = await store.GetLatestFailedMessageView(failedMessageId, cancellationToken); + + return result == null + ? new McpFailedMessageViewResult { Error = $"Failed message '{failedMessageId}' not found." } + : McpFailedMessageViewResult.From(result); + } + + [McpServerTool(Name = "get_errors_summary", 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?' or 'are there unresolved failures?'. " + + "Returns counts for unresolved, archived, resolved, and retryissued statuses. Read-only.")] + public async Task GetErrorsSummary( + IFailedMessageQueryDataStore store, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetErrorsSummary, cancellationToken); + + var unresolved = store.GetFailedMessagesStats("unresolved", null, null, cancellationToken); + var archived = store.GetFailedMessagesStats("archived", null, null, cancellationToken); + var resolved = store.GetFailedMessagesStats("resolved", null, null, cancellationToken); + var retryIssued = store.GetFailedMessagesStats("retryissued", null, null, cancellationToken); + + await Task.WhenAll(unresolved, archived, resolved, retryIssued); + + return McpErrorsSummaryResult.From( + unresolved.Result.TotalCount, + archived.Result.TotalCount, + resolved.Result.TotalCount, + retryIssued.Result.TotalCount); + } + + [McpServerTool(Name = "get_failed_messages_by_endpoint", 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 GetFailedMessages when you need a broad list, and GetFailedMessageLastAttempt when you need the most recent details for a specific message. Read-only.")] + public async Task> GetFailedMessagesByEndpoint( + IFailedMessageQueryDataStore store, + [Description("The endpoint name that owns the failed messages.")] 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. Omit this filter to include all results.")] 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", + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetFailuresByEndpoint, cancellationToken); + + var results = await store.GetFailedMessagesByEndpoint( + McpToolInputValidation.NormalizeStatus(status), + endpointName, + McpToolInputValidation.NormalizeOptionalFilter(modified), + new PagingInfo(page, perPage), + new SortInfo(McpToolInputValidation.NormalizeSort(sort), McpToolInputValidation.NormalizeDirection(direction)), + cancellationToken); + + return new McpCollectionResult + { + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results?.ToArray() ?? [] + }; + } +} \ No newline at end of file diff --git a/src/ServiceControl/Mcp/Tools/RecoverabilityTools.cs b/src/ServiceControl/Mcp/Tools/RecoverabilityTools.cs new file mode 100644 index 0000000000..e8b0cf054e --- /dev/null +++ b/src/ServiceControl/Mcp/Tools/RecoverabilityTools.cs @@ -0,0 +1,77 @@ +#nullable enable +namespace ServiceControl.Mcp; + +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Mcp.Authorization; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +[Description( + "Read-only tools for inspecting failure groups and retry history.\n\n" + + "Agent guidance:\n" + + "1. GetFailureGroups is usually the best starting point for diagnosing production issues.\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 sealed class FailureGroupTools( + GroupFetcher fetcher, + IGroupsDataStore store, + IRetryHistoryDataStore retryStore, + McpAuthorizationService authorization) +{ + [McpServerTool(Name = "get_failure_groups", 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 diagnosing production issues and identifying dominant root causes. Read-only.")] + public async Task GetFailureGroups( + [Description("How to group failures. The default 'Exception Type and Stack Trace' is almost always what you want. Use 'Message Type' to group by the NServiceBus message type instead.")] string classifier = "Exception Type and Stack Trace", + [Description("Filter failure groups by classifier text. Omit this filter to include all groups for the selected classifier.")] string? classifierFilter = null, + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.ListFailureGroups, cancellationToken); + + return await fetcher.GetGroups(classifier, McpToolInputValidation.NormalizeOptionalFilter(classifierFilter), cancellationToken); + } + + [McpServerTool(Name = "get_failure_group_errors", ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false, UseStructuredContent = true), Description( + "List failed messages within a specific failure group. Read-only.")] + public async Task> GetFailureGroupErrors( + [Description("The failure group ID.")] string groupId, + [Description("Status filter: Unresolved, Archived, RetryIssued, or Resolved.")] string? status = 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", + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetFailureGroup, cancellationToken); + + var results = await store.GetGroupErrors( + groupId, + McpToolInputValidation.NormalizeStatus(status), + null, + new SortInfo(McpToolInputValidation.NormalizeSort(sort), McpToolInputValidation.NormalizeDirection(direction)), + new PagingInfo(page, perPage), + cancellationToken); + + return new McpCollectionResult + { + TotalCount = (int)results.QueryStats.TotalCount, + Results = results.Results?.ToArray() ?? [] + }; + } + + [McpServerTool(Name = "get_retry_history", 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?' or 'what happened the last time we retried this group?'. Read-only.")] + public async Task GetRetryHistory( + CancellationToken cancellationToken = default) + { + await authorization.RequirePermissionAsync(McpPermissions.GetRetryHistory, cancellationToken); + + var retryHistory = await retryStore.GetRetryHistory(cancellationToken); + return retryHistory.Results ?? new RetryHistory(); + } +} \ No newline at end of file diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 7afd8f88cf..5c252778c0 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -34,6 +34,7 @@ + diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index ec2f69f03d..033f8d2e4d 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -2,6 +2,8 @@ namespace ServiceControl; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.AspNetCore; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; using ServiceControl.Hosting.QueryTimeout; @@ -11,7 +13,7 @@ namespace ServiceControl; public static class WebApplicationExtensions { - public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) + public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, bool enableMcpServer = false) { app.UseRequestIdHeader(); app.UseQueryTimeoutResponse(); @@ -22,6 +24,27 @@ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSe app.UseHttpLogging(); app.UseCors(); app.MapControllers(); + + if (enableMcpServer) + { + app.MapGet(global::ServiceControl.Mcp.McpServerConfiguration.Route, () => Results.Content(""" + + + 405: Method Not Allowed + + +

405: Method Not Allowed

+

+ This is an MCP server endpoint and cannot be accessed directly via a + browser or unsupported transports like SSE. Please use a streamable HTTP + transport. +

+ + + """, "text/html", System.Text.Encoding.UTF8, StatusCodes.Status405MethodNotAllowed)).RequireCors(global::ServiceControl.Mcp.McpServerConfiguration.CorsPolicyName); + app.MapMcp(global::ServiceControl.Mcp.McpServerConfiguration.Route).RequireCors(global::ServiceControl.Mcp.McpServerConfiguration.CorsPolicyName); + } + app.MapServiceControlHealthChecks(); } } \ No newline at end of file