Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
52 changes: 52 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -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
<add key="ServiceControl/EnableMcpServer" value="true" />
```

If you also want write-capable retry tools, enable `ServiceControl/EnableMcpServerWriteMode` as well:

```xml
<add key="ServiceControl/EnableMcpServerWriteMode" value="true" />
```

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.
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(RuntimeFrameworkVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="$(RuntimeFrameworkVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(RuntimeFrameworkVersion)" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5" />
Expand Down
205 changes: 205 additions & 0 deletions src/ServiceControl.AcceptanceTesting/Mcp/McpAcceptanceTestSupport.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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<McpSessionInfo?> InitializeAndGetSessionInfo(HttpClient httpClient, CancellationToken cancellationToken = default)
{
var response = await InitializeMcpSession(httpClient, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}

var initializeResponse = JsonSerializer.Deserialize<McpInitializeResponse>(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<HttpResponseMessage> 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<string> 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<McpListToolsResponse>(toolsJson, SerializerOptions)!;

public static McpCallToolResponse DeserializeCallToolResponse(string toolResult) =>
JsonSerializer.Deserialize<McpCallToolResponse>(toolResult, SerializerOptions)!;

public static string FormatToolsForApproval(List<JsonElement> sortedTools) =>
JsonSerializer.Serialize(sortedTools);

public static void AssertToolsHaveOutputSchema(IEnumerable<JsonElement> 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<McpContent> content, Action<JsonElement> 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<HttpResponseMessage> 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<JsonElement> Tools { get; set; } = [];
}

public sealed class McpCallToolResponse
{
public McpCallToolResult Result { get; set; } = new();
}

public sealed class McpCallToolResult
{
public JsonElement StructuredContent { get; set; }
public List<McpContent> 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Particular.Approvals" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Particular.Approvals" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Particular.Approvals" />
<PackageReference Include="Testcontainers.MsSql" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ScenarioContext>()
.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"));
});
}
}
Loading
Loading