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 @@ -82,6 +82,7 @@
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.5" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.5" />
<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

Large diffs are not rendered by default.

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

<ItemGroup>
Expand Down
151 changes: 151 additions & 0 deletions src/ServiceControl.AcceptanceTests/Mcp/When_mcp_server_is_enabled.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
namespace ServiceControl.AcceptanceTests.Mcp;

using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;
using Particular.Approvals;

[TestFixture]
class When_mcp_server_is_enabled : AcceptanceTest
{
[SetUp]
public void EnableMcp() => SetSettings = s => s.EnableMcpServer = true;

[Test]
public async Task Should_expose_mcp_endpoint()
{
await Define<ScenarioContext>()
.Done(async _ =>
{
var response = await InitializeMcpSession();
return response.StatusCode == HttpStatusCode.OK;
})
.Run();
}

[Test]
public async Task Should_list_primary_instance_tools()
{
string toolsJson = null;

await Define<ScenarioContext>()
.Done(async _ =>
{
var sessionId = await InitializeAndGetSessionId();
if (sessionId == null)
{
return false;
}

var response = await SendMcpRequest(sessionId, "tools/list", new { });
if (response == null)
{
return false;
}

toolsJson = await ReadMcpResponseJson(response);
return response.StatusCode == HttpStatusCode.OK;
})
.Run();

Assert.That(toolsJson, Is.Not.Null);
var mcpResponse = JsonSerializer.Deserialize<McpListToolsResponse>(toolsJson, JsonOptions)!;
var sortedTools = mcpResponse.Result.Tools.Cast<JsonElement>().OrderBy(t => t.GetProperty("name").GetString()).ToList();
var formattedTools = JsonSerializer.Serialize(sortedTools, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Approver.Verify(formattedTools);
}

static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

class McpListToolsResponse
{
public McpListToolsResult Result { get; set; }
}

class McpListToolsResult
{
public List<object> Tools { get; set; } = [];
}

async Task<HttpResponseMessage> InitializeMcpSession()
{
var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
{
Content = JsonContent.Create(new
{
jsonrpc = "2.0",
id = 1,
method = "initialize",
@params = new
{
protocolVersion = "2025-03-26",
capabilities = new { },
clientInfo = new { name = "test-client", version = "1.0" }
}
})
};
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
return await HttpClient.SendAsync(request);
}

async Task<string> InitializeAndGetSessionId()
{
var response = await InitializeMcpSession();
if (response.StatusCode != HttpStatusCode.OK)
{
return null;
}

if (response.Headers.TryGetValues("mcp-session-id", out var values))
{
return values.FirstOrDefault();
}

return null;
}

static async Task<string> ReadMcpResponseJson(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
var contentType = response.Content.Headers.ContentType?.MediaType;

if (contentType == "text/event-stream")
{
foreach (var line in body.Split('\n'))
{
if (line.StartsWith("data: "))
{
return line.Substring("data: ".Length);
}
}
}

return body;
}

async Task<HttpResponseMessage> SendMcpRequest(string sessionId, string method, object @params)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
{
Content = JsonContent.Create(new
{
jsonrpc = "2.0",
id = 2,
method,
@params
})
};
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Headers.Add("mcp-session-id", sessionId);
return await HttpClient.SendAsync(request);
}
}
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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we better off passing the settings object in at this point?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... Maybe yeah but I don't know what a reasonable threshold would be to make that flip but I agree it starts to smell

await host.StartAsync();
DomainEvents = host.Services.GetRequiredService<IDomainEvents>();
// Bring this back and look into the base address of the client
Expand Down
Loading
Loading