Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageVersion Include="ServicePulse.Core" Version="2.5.0-alpha.0.58" />
<PackageVersion Include="Validar.Fody" Version="1.9.0" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.1.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup Label="Versions to pin transitive references">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -135,7 +135,7 @@ async Task InitializeServiceControl(ScenarioContext context)

host.UseTestRemoteIp();
host.UseServiceControlAuthentication(settings.OpenIdConnectSettings.Enabled);
host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
await host.StartAsync();
DomainEvents = host.Services.GetRequiredService<IDomainEvents>();
// Bring this back and look into the base address of the client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"NotificationsFilter": null,
"AllowMessageEditing": false,
"EnableMcpServer": false,
"EnableIntegratedServicePulse": false,
"ServicePulseSettings": null,
"MessageFilter": null,
Expand Down
2 changes: 2 additions & 0 deletions src/ServiceControl/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ These settings are only here so that we can debug ServiceControl while developin
-->
<configuration>
<appSettings>
<add key="ServiceControl/EnableMcpServer" value="false"/>

<add key="ServiceControl/ForwardErrorMessages" value="false" />
<add key="ServiceControl/ErrorRetentionPeriod" value="10.00:00:00" />
<add key="ServiceControl/RemoteInstances" value="[{&quot;api_uri&quot;:&quot;http://localhost:44444/api/&quot;}]" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/ServiceControl/Hosting/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ public override async Task Execute(HostArguments args, Settings settings)
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControl(settings, endpointConfiguration);
hostBuilder.AddServiceControlApi(settings.CorsSettings);
hostBuilder.AddServiceControlApi(settings);

var app = hostBuilder.Build();
app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
app.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.EnableMcpServer);
if (settings.EnableIntegratedServicePulse)
{
app.UseServicePulse(settings.ServicePulseSettings);
Expand Down
3 changes: 3 additions & 0 deletions src/ServiceControl/Infrastructure/Settings/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,27 @@
using Microsoft.Extensions.Hosting;
using Particular.LicensingComponent.WebApi;
using Particular.ServiceControl;
using ServiceBus.Management.Infrastructure.Settings;

static class HostApplicationBuilderExtensions
{
public static void AddServiceControlApi(this IHostApplicationBuilder builder, CorsSettings corsSettings)
public static void AddServiceControlApi(this IHostApplicationBuilder builder, Settings settings)
{
// This registers concrete classes that implement IApi. Currently it is hard to find out to what
// component those APIs should belong to so we leave it here for now.
builder.Services.RegisterApiTypes(Assembly.GetExecutingAssembly());

builder.AddServiceControlApis();

builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
if (settings.EnableMcpServer)
{
builder.Services
.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
}

builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(settings.CorsSettings)));

// We're not explicitly adding Gzip here because it's already in the default list of supported compressors
builder.Services.AddResponseCompression();
Expand Down
90 changes: 90 additions & 0 deletions src/ServiceControl/Mcp/ArchiveTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> ArchiveFailedMessage(
[Description("The unique ID of the failed message to archive")] string failedMessageId)
{
await messageSession.SendLocal<ArchiveMessage>(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<string> 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<ArchiveMessage>(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<string> 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<ArchiveAllInGroup>(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<string> UnarchiveFailedMessage(
[Description("The unique ID of the failed message to unarchive")] string failedMessageId)
{
await messageSession.SendLocal<UnArchiveMessages>(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<string> 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<UnArchiveMessages>(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<string> 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<UnarchiveAllInGroup>(m => m.GroupId = groupId);

return JsonSerializer.Serialize(new { Status = "Accepted", Message = $"Unarchive requested for all messages in failure group '{groupId}'." }, McpJsonOptions.Default);
}
}
94 changes: 94 additions & 0 deletions src/ServiceControl/Mcp/FailedMessageTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string> 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<string> 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<string> GetErrorsSummary()
{
var result = await store.ErrorsSummary();
return JsonSerializer.Serialize(result, McpJsonOptions.Default);
}

[McpServerTool, Description("Get failed messages for a specific endpoint.")]
public async Task<string> 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);
}
}
30 changes: 30 additions & 0 deletions src/ServiceControl/Mcp/FailureGroupTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string> GetRetryHistory()
{
var retryHistory = await retryStore.GetRetryHistory();
return JsonSerializer.Serialize(retryHistory, McpJsonOptions.Default);
}
}
14 changes: 14 additions & 0 deletions src/ServiceControl/Mcp/McpJsonOptions.cs
Original file line number Diff line number Diff line change
@@ -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
};
}
Loading