? logFailure = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
diff --git a/src/McpServer.Support.Mcp/Web/PairingHtml.cs b/src/McpServer.Support.Mcp/Web/PairingHtml.cs
index ab9bb098..0fdd04b4 100644
--- a/src/McpServer.Support.Mcp/Web/PairingHtml.cs
+++ b/src/McpServer.Support.Mcp/Web/PairingHtml.cs
@@ -1,3 +1,5 @@
+using System.Net;
+
namespace McpServer.Support.Mcp.Web;
///
@@ -6,12 +8,12 @@ namespace McpServer.Support.Mcp.Web;
///
internal static class PairingHtml
{
- /// Renders the login form. Shows an error banner when is true.
- public static string LoginPage(bool error = false)
+ /// Renders the login form. Shows an error banner when is not empty.
+ public static string LoginPage(string? errorMessage = null)
{
- var errorBanner = error
- ? "Invalid username or password.
"
- : "";
+ var errorBanner = string.IsNullOrWhiteSpace(errorMessage)
+ ? string.Empty
+ : $"{WebUtility.HtmlEncode(errorMessage)}
";
return $$"""
diff --git a/src/McpServer.Support.Mcp/Web/PairingHtmlRenderer.cs b/src/McpServer.Support.Mcp/Web/PairingHtmlRenderer.cs
index f853f7fb..4a17da86 100644
--- a/src/McpServer.Support.Mcp/Web/PairingHtmlRenderer.cs
+++ b/src/McpServer.Support.Mcp/Web/PairingHtmlRenderer.cs
@@ -1,3 +1,5 @@
+using System.Net;
+
namespace McpServer.Support.Mcp.Web;
///
@@ -26,12 +28,12 @@ public PairingHtmlRenderer(Services.IPromptTemplateService templateService, ILog
_logger = logger;
}
- /// Renders the login form page. Shows an error banner when is true.
- public async Task RenderLoginPageAsync(bool error = false, CancellationToken cancellationToken = default)
+ /// Renders the login form page. Shows an error banner when is not empty.
+ public async Task RenderLoginPageAsync(string? errorMessage = null, CancellationToken cancellationToken = default)
{
- var errorBanner = error
- ? "Invalid username or password.
"
- : "";
+ var errorBanner = string.IsNullOrWhiteSpace(errorMessage)
+ ? string.Empty
+ : $"{WebUtility.HtmlEncode(errorMessage)}
";
var template = await GetTemplateContentAsync(LoginPageId, cancellationToken).ConfigureAwait(false);
if (template is not null)
@@ -39,7 +41,7 @@ public async Task RenderLoginPageAsync(bool error = false, CancellationT
return template.Replace("{errorBanner}", errorBanner, StringComparison.Ordinal);
}
- return PairingHtml.LoginPage(error);
+ return PairingHtml.LoginPage(errorMessage);
}
/// Renders the API key display page.
diff --git a/src/McpServer.Support.Mcp/appsettings.yaml b/src/McpServer.Support.Mcp/appsettings.yaml
index 9b2d2b27..be3f9617 100644
--- a/src/McpServer.Support.Mcp/appsettings.yaml
+++ b/src/McpServer.Support.Mcp/appsettings.yaml
@@ -47,6 +47,10 @@ Mcp:
RepoAllowlist:
- src/McpServer.Cqrs/**/*.cs
- src/McpServer.Cqrs.Mvvm/**/*.cs
+ DesktopLaunch:
+ Enabled: false
+ AccessToken: ''
+ AllowedExecutables: []
GraphRag:
Enabled: true
EnhanceContextSearch: true
@@ -109,8 +113,8 @@ Mcp:
Url: http://localhost:8000
IngestUrl: http://localhost:8000/api/v1/ingest
StreamName: mcp
- Username: admin
- Password: admin
+ Username: ''
+ Password: ''
FallbackLogPath: logs/mcp-.log
PairingUsers: []
Workspaces:
diff --git a/tests/McpServer.Client.Tests/DesktopClientTests.cs b/tests/McpServer.Client.Tests/DesktopClientTests.cs
index 7d554d5f..e6a60327 100644
--- a/tests/McpServer.Client.Tests/DesktopClientTests.cs
+++ b/tests/McpServer.Client.Tests/DesktopClientTests.cs
@@ -59,4 +59,31 @@ public async Task LaunchAsync_PostsStructuredDesktopLaunchRequest()
Assert.Contains("\"windowStyle\":\"Hidden\"", handler.LastRequestBody!);
Assert.Contains("\"waitForExit\":true", handler.LastRequestBody!);
}
+
+ ///
+ /// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that forwards the
+ /// optional privileged desktop-launch token as the dedicated HTTP header for the desktop
+ /// endpoint only.
+ /// The test uses the existing so the outbound headers can be
+ /// inspected without contacting a live MCP server.
+ ///
+ [Fact]
+ public async Task LaunchAsync_WhenDesktopLaunchTokenConfigured_AddsDesktopLaunchHeader()
+ {
+ var handler = new MockHttpHandler(HttpStatusCode.OK, """{"success":true,"processId":4242,"exitCode":0}""");
+ using var http = new HttpClient(handler);
+ var client = new DesktopClient(
+ http,
+ new McpServerClientOptions
+ {
+ ApiKey = "test-key",
+ BaseUrl = new Uri("http://localhost:7147"),
+ DesktopLaunchToken = "desktop-secret"
+ });
+
+ await client.LaunchAsync(new DesktopLaunchRequest { ExecutablePath = @"C:\Windows\System32\cmd.exe" });
+
+ Assert.True(handler.LastRequest!.Headers.TryGetValues("X-Desktop-Launch-Token", out var values));
+ Assert.Equal("desktop-secret", Assert.Single(values));
+ }
}
diff --git a/tests/McpServer.Client.Tests/TodoClientTests.cs b/tests/McpServer.Client.Tests/TodoClientTests.cs
index 3b4ac346..2f90676c 100644
--- a/tests/McpServer.Client.Tests/TodoClientTests.cs
+++ b/tests/McpServer.Client.Tests/TodoClientTests.cs
@@ -91,6 +91,69 @@ public async System.Threading.Tasks.Task GetAuditAsync_SendsCorrectUrl()
Assert.Equal("Before", result.Entries[0].PreviousSnapshot?.Title);
}
+ [Fact]
+ public async System.Threading.Tasks.Task GetProjectionStatusAsync_SendsCorrectUrl()
+ {
+ var handler = new MockHttpHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "authoritativeStore": "sqlite",
+ "authoritativeDataSource": "E:\\todo.db",
+ "projectionTargetPath": "E:\\docs\\Project\\TODO.yaml",
+ "projectionTargetExists": true,
+ "projectionConsistent": true,
+ "repairRequired": false,
+ "verifiedAtUtc": "2026-03-21T00:00:00Z",
+ "lastProjectedToYamlUtc": "2026-03-21T00:00:00Z",
+ "message": "TODO.yaml matches authoritative SQLite state."
+ }
+ """);
+ using var http = new HttpClient(handler);
+ var client = new TodoClient(http, DefaultOptions);
+
+ var result = await client.GetProjectionStatusAsync();
+
+ Assert.Equal("sqlite", result.AuthoritativeStore);
+ Assert.True(result.ProjectionConsistent);
+ Assert.False(result.RepairRequired);
+ Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
+ Assert.Contains("/mcpserver/todo/projection/status", handler.LastRequest.RequestUri!.AbsolutePath);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task RepairProjectionAsync_PostsCorrectUrl()
+ {
+ var handler = new MockHttpHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "success": true,
+ "error": null,
+ "status": {
+ "authoritativeStore": "sqlite",
+ "authoritativeDataSource": "E:\\todo.db",
+ "projectionTargetPath": "E:\\docs\\Project\\TODO.yaml",
+ "projectionTargetExists": true,
+ "projectionConsistent": true,
+ "repairRequired": false,
+ "verifiedAtUtc": "2026-03-21T00:01:00Z",
+ "message": "TODO.yaml matches authoritative SQLite state."
+ }
+ }
+ """);
+ using var http = new HttpClient(handler);
+ var client = new TodoClient(http, DefaultOptions);
+
+ var result = await client.RepairProjectionAsync();
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Status);
+ Assert.True(result.Status.ProjectionConsistent);
+ Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
+ Assert.Contains("/mcpserver/todo/projection/repair", handler.LastRequest.RequestUri!.AbsolutePath);
+ }
+
[Fact]
public async System.Threading.Tasks.Task CreateAsync_PostsJsonBody()
{
diff --git a/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs b/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs
index 992f0c57..e52e64a3 100644
--- a/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs
+++ b/tests/McpServer.McpAgent.Tests/McpHostedAgentAdapterTests.cs
@@ -277,7 +277,7 @@ public async Task Registration_Functions_RepoOperations_ReuseExistingRepoClient(
[Fact]
public async Task Registration_Functions_DesktopLaunch_ReusesExistingDesktopClient()
{
- var (hostedAgent, handler) = CreateHostedAgent();
+ var (hostedAgent, handler) = CreateHostedAgent("desktop-secret");
var launchFunction = hostedAgent.Registration.Functions.Single(static function =>
function.Name == "mcp_desktop_launch");
@@ -303,6 +303,7 @@ public async Task Registration_Functions_DesktopLaunch_ReusesExistingDesktopClie
Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
Assert.Equal("/mcpserver/desktop/launch", handler.Requests[0].RequestUri.AbsolutePath);
+ Assert.Equal("desktop-secret", handler.Requests[0].DesktopLaunchToken);
using var requestBody = JsonDocument.Parse(handler.Requests[0].Body!);
Assert.Equal(@"C:\Windows\System32\cmd.exe", requestBody.RootElement.GetProperty("executablePath").GetString());
@@ -416,7 +417,7 @@ public async Task PowerShellSessions_ExecuteInteractiveCommand_PreservesHostLoca
Assert.Empty(handler.Requests);
}
- private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handler) CreateHostedAgent()
+ private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handler) CreateHostedAgent(string? desktopLaunchToken = null)
{
var handler = new RecordingMcpHttpMessageHandler();
var httpClient = new HttpClient(handler);
@@ -426,6 +427,7 @@ private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handl
{
ApiKey = "test-key",
BaseUrl = new Uri("http://localhost:7147"),
+ DesktopLaunchToken = desktopLaunchToken,
WorkspacePath = @"E:\github\McpServer",
});
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 03, 09, 15, 01, 05, TimeSpan.Zero));
@@ -434,6 +436,7 @@ private static (McpHostedAgent HostedAgent, RecordingMcpHttpMessageHandler Handl
{
ApiKey = "test-key",
BaseUrl = new Uri("http://localhost:7147"),
+ DesktopLaunchToken = desktopLaunchToken,
SourceType = "Codex",
WorkspacePath = @"E:\github\McpServer",
});
@@ -498,7 +501,11 @@ protected override async Task SendAsync(HttpRequestMessage
? null
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body));
+ var desktopLaunchToken = request.Headers.TryGetValues("X-Desktop-Launch-Token", out var tokenValues)
+ ? tokenValues.SingleOrDefault()
+ : null;
+
+ Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body, desktopLaunchToken));
return request.RequestUri!.AbsolutePath switch
{
@@ -632,10 +639,12 @@ private static HttpResponseMessage CreateDesktopLaunchResponse() => CreateJsonRe
/// The emitted HTTP method.
/// The emitted request URI.
/// The serialized request body, when present.
+ /// The privileged desktop-launch header, when present.
private sealed record RecordedRequest(
HttpMethod Method,
Uri RequestUri,
- string? Body);
+ string? Body,
+ string? DesktopLaunchToken);
///
/// TEST-MCP-089: Provides a deterministic clock for the hosted-agent adapter tests.
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/ApiKeyEndpointTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/ApiKeyEndpointTests.cs
new file mode 100644
index 00000000..776506ab
--- /dev/null
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/ApiKeyEndpointTests.cs
@@ -0,0 +1,33 @@
+using System.Net;
+using Xunit;
+
+namespace McpServer.Support.Mcp.IntegrationTests.Controllers;
+
+/// TR-PLANNED-013: Integration tests for the default API-key issuance endpoint.
+public sealed class ApiKeyEndpointTests : IClassFixture
+{
+ private readonly CustomWebApplicationFactory _factory;
+
+ public ApiKeyEndpointTests(CustomWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ /// GET /api-key returns 429 after the fixed window issuance limit is exhausted.
+ [Fact]
+ public async Task GetApiKey_AfterPermitLimit_ReturnsTooManyRequests()
+ {
+ using var client = _factory.CreateClient();
+
+ for (var i = 0; i < 30; i++)
+ {
+ using var response = await client.GetAsync(new Uri("/api-key", UriKind.Relative)).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ using var throttled = await client.GetAsync(new Uri("/api-key", UriKind.Relative)).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
+ Assert.True(throttled.Headers.TryGetValues("Retry-After", out var values));
+ Assert.NotEmpty(values);
+ }
+}
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/DesktopControllerTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/DesktopControllerTests.cs
index 1d81de5f..8b5182be 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/DesktopControllerTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/DesktopControllerTests.cs
@@ -19,6 +19,8 @@ namespace McpServer.Support.Mcp.IntegrationTests.Controllers;
///
public sealed class DesktopControllerTests
{
+ private const string DesktopLaunchToken = "desktop-launch-test-token";
+
///
/// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that POST /mcpserver/desktop/launch
/// returns the normalized launcher result and forwards the structured launch payload to the
@@ -35,7 +37,9 @@ public async Task Launch_ReturnsOkAndNormalizedResult()
.Returns(Task.FromResult(new ProcessRunResult(0, """{"success":true,"processId":4242,"exitCode":0}""", null)));
var launcherPath = Environment.ProcessPath
- ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe");
+ ?? throw new InvalidOperationException("Expected the integration test host to expose an executable path.");
+ var workingDirectory = Path.GetDirectoryName(launcherPath)
+ ?? throw new InvalidOperationException("Expected a launcher working directory.");
using var factory = new CustomWebApplicationFactory(
services =>
@@ -45,19 +49,23 @@ public async Task Launch_ReturnsOkAndNormalizedResult()
},
new Dictionary
{
- ["Mcp:LauncherPath"] = launcherPath
+ ["Mcp:LauncherPath"] = launcherPath,
+ ["Mcp:DesktopLaunch:Enabled"] = "true",
+ ["Mcp:DesktopLaunch:AccessToken"] = DesktopLaunchToken,
+ ["Mcp:DesktopLaunch:AllowedExecutables:0"] = $"**/{Path.GetFileName(launcherPath)}"
});
var client = factory.CreateClient();
TestAuthHelper.AddAuthHeader(client, factory.Services);
+ client.DefaultRequestHeaders.Add("X-Desktop-Launch-Token", DesktopLaunchToken);
var response = await client.PostAsJsonAsync(
new Uri("/mcpserver/desktop/launch", UriKind.Relative),
new DesktopLaunchRequest
{
- ExecutablePath = @"C:\Windows\System32\cmd.exe",
+ ExecutablePath = launcherPath,
Arguments = "/c exit 0",
- WorkingDirectory = @"C:\Windows\System32",
+ WorkingDirectory = workingDirectory,
EnvironmentVariables = new Dictionary { ["TEST_ENV"] = "true" },
CreateNoWindow = true,
WindowStyle = "Hidden",
@@ -76,7 +84,7 @@ await processRunner.Received(1).RunAsync(
launcherPath,
Arg.Is(arguments =>
arguments != null
- && arguments.Contains("cmd.exe", StringComparison.Ordinal)
+ && arguments.Contains(Path.GetFileName(launcherPath), StringComparison.Ordinal)
&& arguments.Contains("TEST_ENV", StringComparison.Ordinal)
&& arguments.Contains("Hidden", StringComparison.Ordinal)),
Arg.Any());
@@ -99,4 +107,42 @@ public async Task Launch_WithoutBody_ReturnsBadRequest()
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
+
+ ///
+ /// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that the HTTP desktop-launch endpoint rejects
+ /// authenticated workspace-key callers that do not also present the privileged desktop-launch
+ /// token header.
+ /// The test uses the real HTTP pipeline plus a substituted process runner so the stronger
+ /// authorization tier can be asserted without starting any local desktop program.
+ ///
+ [Fact]
+ public async Task Launch_WithoutDesktopLaunchToken_ReturnsForbidden()
+ {
+ var launcherPath = Environment.ProcessPath
+ ?? throw new InvalidOperationException("Expected the integration test host to expose an executable path.");
+ var processRunner = Substitute.For();
+ using var factory = new CustomWebApplicationFactory(
+ services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(processRunner);
+ },
+ new Dictionary
+ {
+ ["Mcp:LauncherPath"] = launcherPath,
+ ["Mcp:DesktopLaunch:Enabled"] = "true",
+ ["Mcp:DesktopLaunch:AccessToken"] = DesktopLaunchToken,
+ ["Mcp:DesktopLaunch:AllowedExecutables:0"] = $"**/{Path.GetFileName(launcherPath)}"
+ });
+
+ var client = factory.CreateClient();
+ TestAuthHelper.AddAuthHeader(client, factory.Services);
+
+ var response = await client.PostAsJsonAsync(
+ new Uri("/mcpserver/desktop/launch", UriKind.Relative),
+ new DesktopLaunchRequest { ExecutablePath = launcherPath }).ConfigureAwait(true);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ await processRunner.DidNotReceive().RunAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
}
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/Http500ErrorContractTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/Http500ErrorContractTests.cs
index a8fec020..ef323682 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/Http500ErrorContractTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/Http500ErrorContractTests.cs
@@ -240,6 +240,10 @@ public sealed class PassThroughTodoService : ITodoService
public Task GetByIdAsync(string id, CancellationToken cancellationToken) => _inner.GetByIdAsync(id, cancellationToken);
public Task GetAuditAsync(string id, int limit = 50, int offset = 0, CancellationToken cancellationToken = default)
=> _inner.GetAuditAsync(id, limit, offset, cancellationToken);
+ public Task GetProjectionStatusAsync(CancellationToken cancellationToken = default)
+ => _inner.GetProjectionStatusAsync(cancellationToken);
+ public Task RepairProjectionAsync(CancellationToken cancellationToken = default)
+ => _inner.RepairProjectionAsync(cancellationToken);
public Task CreateAsync(TodoCreateRequest request, CancellationToken cancellationToken) => Task.FromResult(new TodoMutationResult(true, null, new TodoFlatItem { Id = request.Id, Title = request.Title, Section = request.Section, Priority = request.Priority, Done = false }));
public Task UpdateAsync(string id, TodoUpdateRequest request, CancellationToken cancellationToken) => _inner.UpdateAsync(id, request, cancellationToken);
public Task DeleteAsync(string id, CancellationToken cancellationToken) => _inner.DeleteAsync(id, cancellationToken);
@@ -261,6 +265,10 @@ public Task QueryAsync(TodoQueryRequest request, CancellationTo
public Task GetByIdAsync(string id, CancellationToken cancellationToken) => _inner.GetByIdAsync(id, cancellationToken);
public Task GetAuditAsync(string id, int limit = 50, int offset = 0, CancellationToken cancellationToken = default)
=> _inner.GetAuditAsync(id, limit, offset, cancellationToken);
+ public Task GetProjectionStatusAsync(CancellationToken cancellationToken = default)
+ => _inner.GetProjectionStatusAsync(cancellationToken);
+ public Task RepairProjectionAsync(CancellationToken cancellationToken = default)
+ => _inner.RepairProjectionAsync(cancellationToken);
public Task CreateAsync(TodoCreateRequest request, CancellationToken cancellationToken) => _inner.CreateAsync(request, cancellationToken);
public Task UpdateAsync(string id, TodoUpdateRequest request, CancellationToken cancellationToken) => _inner.UpdateAsync(id, request, cancellationToken);
public Task DeleteAsync(string id, CancellationToken cancellationToken)
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MarkerRegenerationIntegrationTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MarkerRegenerationIntegrationTests.cs
index 4de683e5..6a8bf98f 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MarkerRegenerationIntegrationTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MarkerRegenerationIntegrationTests.cs
@@ -76,7 +76,7 @@ public ValueTask InitializeAsync()
// FileSystemWatcher on appsettings.json — fires when config writes complete.
_settingsWatcher = new FileSystemWatcher(_workspacePath, "appsettings.json")
{
- NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime | NotifyFilters.FileName,
EnableRaisingEvents = true,
};
@@ -202,18 +202,28 @@ public async Task GlobalAndWorkspacePrompts_CombineInMarkerFile()
///
/// Returns a that completes when appsettings.json is written.
- /// The releases the latch on the first event.
+ /// The releases the latch on the first change/create/rename event so
+ /// atomic temp-file replace writes are observed deterministically.
///
private Task WatchForSettingsChange()
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
FileSystemEventHandler? handler = null;
- handler = (_, _) =>
+ RenamedEventHandler? renamedHandler = null;
+
+ void Complete()
{
_settingsWatcher.Changed -= handler;
+ _settingsWatcher.Created -= handler;
+ _settingsWatcher.Renamed -= renamedHandler;
tcs.TrySetResult();
- };
+ }
+
+ handler = (_, _) => Complete();
+ renamedHandler = (_, _) => Complete();
_settingsWatcher.Changed += handler;
+ _settingsWatcher.Created += handler;
+ _settingsWatcher.Renamed += renamedHandler;
// Guard against the write completing before the handler was attached.
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/McpTransportMultiTenantTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/McpTransportMultiTenantTests.cs
index 4ed98c52..9298bbc9 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/McpTransportMultiTenantTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/McpTransportMultiTenantTests.cs
@@ -81,4 +81,34 @@ public async Task McpTransport_WithoutWorkspaceHeader_UsesDefaultWorkspace()
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("serverInfo", body, StringComparison.Ordinal);
}
+
+ [Fact]
+ public async Task McpTransport_BearerWithoutWorkspaceHeader_Returns404()
+ {
+ var initRequest = new
+ {
+ jsonrpc = "2.0",
+ id = 1,
+ method = "initialize",
+ @params = new
+ {
+ protocolVersion = "2025-03-26",
+ capabilities = new { },
+ clientInfo = new { name = "test-client-bearer", version = "1.0.0" }
+ }
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp-transport");
+ request.Content = new StringContent(
+ JsonSerializer.Serialize(initRequest),
+ Encoding.UTF8,
+ "application/json");
+ 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.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "synthetic-jwt");
+
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
}
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MultiTenantIntegrationTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MultiTenantIntegrationTests.cs
index f11c4de7..700420b1 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MultiTenantIntegrationTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/MultiTenantIntegrationTests.cs
@@ -119,6 +119,17 @@ public async Task NoHeaders_NoApiKey_Returns401()
}
}
+ [Fact]
+ public async Task BearerWithoutWorkspaceHeader_OnTenantRoute_Returns404()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "synthetic-jwt");
+
+ var response = await client.GetAsync(new Uri("/mcpserver/sessionlog/query", UriKind.Relative));
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
[Fact]
public async Task WorkspaceResolutionMiddleware_OnlyRunsForMcpRoutes()
{
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/PairingEndpointTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/PairingEndpointTests.cs
index b8cc0801..2b460b3f 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/PairingEndpointTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/PairingEndpointTests.cs
@@ -44,6 +44,43 @@ public async Task PairPost_WithBadCredentials_ShowsError()
Assert.Contains("Invalid username or password", body, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task PairPost_AfterRepeatedFailures_ReturnsTooManyRequests()
+ {
+ await using var factory = new PairingWebApplicationFactory();
+ using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ });
+
+ for (var i = 0; i < 5; i++)
+ {
+ using var failedAttempt = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("username", "admin"),
+ new KeyValuePair("password", "wrong"),
+ ]);
+
+ var failedResponse = await client.PostAsync("/pair", failedAttempt).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, failedResponse.StatusCode);
+ }
+
+ using var lockedAttempt = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("username", "admin"),
+ new KeyValuePair("password", "testpass"),
+ ]);
+
+ var response = await client.PostAsync("/pair", lockedAttempt).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
+ Assert.True(response.Headers.TryGetValues("Retry-After", out var retryAfterValues));
+ Assert.NotEmpty(retryAfterValues);
+
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
+ Assert.Contains("Too many failed sign-in attempts", body, StringComparison.Ordinal);
+ Assert.False(response.Headers.TryGetValues("Set-Cookie", out _));
+ }
+
[Fact]
public async Task PairPost_WithGoodCredentials_RedirectsToKey()
{
diff --git a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/TodoControllerTests.cs b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/TodoControllerTests.cs
index f7674ff6..358650ec 100644
--- a/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/TodoControllerTests.cs
+++ b/tests/McpServer.Support.Mcp.IntegrationTests/Controllers/TodoControllerTests.cs
@@ -157,6 +157,29 @@ public async Task Create_ThenGetById_ReturnsCreatedItem()
Assert.Equal("Remaining from create", item.Remaining);
}
+ /// POST /mcpserver/todo with the default /api-key token returns 403 Forbidden.
+ [Fact]
+ public async Task Create_WithDefaultApiKey_ReturnsForbidden()
+ {
+ using var client = _factory.CreateClient();
+ var tokenService = _factory.Services.GetRequiredService();
+ var config = _factory.Services.GetRequiredService();
+ var defaultToken = tokenService.GetDefaultToken(config["Mcp:RepoRoot"]!)
+ ?? throw new InvalidOperationException("Workspace default API key was not generated for test host.");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-Api-Key", defaultToken);
+
+ var createRequest = new
+ {
+ id = "DEFAULT-TODO-001",
+ title = "Default key write should fail",
+ section = "mvp-app",
+ priority = "low"
+ };
+
+ var response = await client.PostAsJsonAsync(new Uri("/mcpserver/todo", UriKind.Relative), createRequest).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
/// POST /mcpserver/todo with duplicate id returns 400 Bad Request.
[Fact]
public async Task Create_DuplicateId_ReturnsConflict()
@@ -268,12 +291,90 @@ public async Task AuditEndpoint_AfterCreateUpdateDelete_ReturnsOrderedHistory()
Assert.Equal("Audit item", entry.PreviousSnapshot?.Title);
},
entry =>
+ {
+ Assert.Equal(3, entry.Version);
+ Assert.Equal("deleted", entry.Action);
+ Assert.Equal("Audit item updated", entry.Snapshot?.Title);
+ Assert.Equal("Audit item updated", entry.PreviousSnapshot?.Title);
+ });
+ }
+
+ ///
+ /// TR-MCP-TODO-006: Verifies that a real mutation-time projection failure remains visible through the
+ /// REST API while the SQLite-authoritative item is still committed and the repair endpoint can rebuild
+ /// TODO.yaml afterward. The fixture temporarily replaces the projected TODO.yaml file with a directory
+ /// so create fails deterministically without compromising the authoritative SQLite store.
+ ///
+ [Fact]
+ public async Task ProjectionEndpoints_AfterMutationProjectionFailure_ReportAndRepairAuthoritativeState()
+ {
+ var yamlPath = _factory.TodoYamlPath;
+ Assert.True(File.Exists(yamlPath));
+
+ File.Delete(yamlPath);
+ Directory.CreateDirectory(yamlPath);
+
+ try
+ {
+ var createRequest = new
{
- Assert.Equal(3, entry.Version);
- Assert.Equal("deleted", entry.Action);
- Assert.Equal("Audit item updated", entry.Snapshot?.Title);
- Assert.Equal("Audit item updated", entry.PreviousSnapshot?.Title);
- });
+ id = "INT-PROJ-001",
+ title = "Projection failure integration",
+ section = "mvp-app",
+ priority = "high"
+ };
+
+ var createResponse = await _client.PostAsJsonAsync(new Uri("/mcpserver/todo", UriKind.Relative), createRequest).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.InternalServerError, createResponse.StatusCode);
+
+ var createResult = await createResponse.Content.ReadFromJsonAsync().ConfigureAwait(true);
+ Assert.NotNull(createResult);
+ Assert.False(createResult.Success);
+ Assert.Equal("ProjectionFailed", createResult.FailureKind);
+
+ var getResponse = await _client.GetAsync(new Uri("/mcpserver/todo/INT-PROJ-001", UriKind.Relative)).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var statusResponse = await _client.GetAsync(new Uri("/mcpserver/todo/projection/status", UriKind.Relative)).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
+
+ var status = await statusResponse.Content.ReadFromJsonAsync().ConfigureAwait(true);
+ Assert.NotNull(status);
+ Assert.True(status.RepairRequired);
+ Assert.False(status.ProjectionTargetExists);
+ Assert.False(status.ProjectionConsistent);
+ Assert.Contains("directory", status.Message ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+
+ Directory.Delete(yamlPath, recursive: true);
+
+ var repairResponse = await _client.PostAsync(new Uri("/mcpserver/todo/projection/repair", UriKind.Relative), content: null).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, repairResponse.StatusCode);
+
+ var repair = await repairResponse.Content.ReadFromJsonAsync().ConfigureAwait(true);
+ Assert.NotNull(repair);
+ Assert.True(repair.Success);
+ Assert.NotNull(repair.Status);
+ Assert.False(repair.Status.RepairRequired);
+ Assert.True(repair.Status.ProjectionTargetExists);
+ Assert.True(repair.Status.ProjectionConsistent);
+ Assert.True(File.Exists(yamlPath));
+
+ var repairedStatusResponse = await _client.GetAsync(new Uri("/mcpserver/todo/projection/status", UriKind.Relative)).ConfigureAwait(true);
+ Assert.Equal(HttpStatusCode.OK, repairedStatusResponse.StatusCode);
+
+ var repairedStatus = await repairedStatusResponse.Content.ReadFromJsonAsync().ConfigureAwait(true);
+ Assert.NotNull(repairedStatus);
+ Assert.False(repairedStatus.RepairRequired);
+ Assert.True(repairedStatus.ProjectionConsistent);
+ }
+ finally
+ {
+ if (Directory.Exists(yamlPath))
+ Directory.Delete(yamlPath, recursive: true);
+
+ if (!File.Exists(yamlPath))
+ await _client.PostAsync(new Uri("/mcpserver/todo/projection/repair", UriKind.Relative), content: null).ConfigureAwait(true);
+ }
}
/// DELETE /mcpserver/todo/{id} for missing item returns 404.
@@ -480,7 +581,21 @@ public async Task Update_WithCircularDependency_ReturnsNotFound()
Assert.Contains("Circular", result.Error ?? "", StringComparison.OrdinalIgnoreCase);
}
- private sealed record MutationResult(bool Success, string? Error);
+ private sealed record MutationResult(bool Success, string? Error, string? FailureKind = null);
+ private sealed record ProjectionStatusResult(
+ string AuthoritativeStore,
+ string AuthoritativeDataSource,
+ string ProjectionTargetPath,
+ bool ProjectionTargetExists,
+ bool ProjectionConsistent,
+ bool RepairRequired,
+ string VerifiedAtUtc,
+ string? LastImportedFromYamlUtc,
+ string? LastProjectedToYamlUtc,
+ string? LastProjectionFailureUtc,
+ string? LastProjectionFailure,
+ string? Message);
+ private sealed record ProjectionRepairResult(bool Success, string? Error, ProjectionStatusResult Status);
#region Test DTOs (for deserialization)
@@ -516,12 +631,14 @@ public sealed class TodoWebFactory : WebApplicationFactory, ID
{
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "mcp-todo-tests-" + Guid.NewGuid().ToString("N")[..8]);
+ public string TodoYamlPath => Path.Combine(_tempDir, "docs", "Project", "TODO.yaml");
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Create seed TODO.yaml
var projectDir = Path.Combine(_tempDir, "docs", "Project");
Directory.CreateDirectory(projectDir);
- File.WriteAllText(Path.Combine(projectDir, "TODO.yaml"), SeedYaml);
+ File.WriteAllText(TodoYamlPath, SeedYaml);
builder.UseEnvironment("Test");
builder.UseContentRoot(CustomWebApplicationFactory.ResolveContentRoot());
diff --git a/tests/McpServer.Support.Mcp.Tests/Controllers/GitHubControllerTests.cs b/tests/McpServer.Support.Mcp.Tests/Controllers/GitHubControllerTests.cs
new file mode 100644
index 00000000..e0a2abe8
--- /dev/null
+++ b/tests/McpServer.Support.Mcp.Tests/Controllers/GitHubControllerTests.cs
@@ -0,0 +1,89 @@
+using McpServer.Support.Mcp.Controllers;
+using McpServer.Support.Mcp.Options;
+using McpServer.Support.Mcp.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Xunit;
+
+namespace McpServer.Support.Mcp.Tests.Controllers;
+
+///
+/// TEST-MCP-GH-006: Validates GitHub controller boundary hardening for state and close-reason query
+/// parameters so only canonical values reach and the gh CLI layer.
+///
+public sealed class GitHubControllerTests
+{
+ ///
+ /// TEST-MCP-GH-006: Verifies that invalid close reasons are rejected at the controller boundary before
+ /// the GitHub CLI service is invoked, preventing query-string flag injection in issue-close requests.
+ ///
+ [Fact]
+ public async Task CloseIssueAsync_WithInvalidReason_ReturnsBadRequest()
+ {
+ var gitHubCliService = Substitute.For();
+ var controller = CreateController(gitHubCliService);
+
+ var result = await controller.CloseIssueAsync(42, "completed --repo other/repo", CancellationToken.None).ConfigureAwait(true);
+
+ Assert.IsType(result.Result);
+ await gitHubCliService.DidNotReceive().CloseIssueAsync(Arg.Any(), Arg.Any(), Arg.Any()).ConfigureAwait(true);
+ }
+
+ ///
+ /// TEST-MCP-GH-006: Verifies that list-state query parameters are normalized to canonical lowercase
+ /// values before forwarding them to the GitHub CLI service, preventing raw user input from reaching gh.
+ ///
+ [Fact]
+ public async Task ListIssuesAsync_WithMixedCaseState_NormalizesBeforeCallingService()
+ {
+ var gitHubCliService = Substitute.For();
+ gitHubCliService.ListIssuesAsync("open", 30, Arg.Any())
+ .Returns(new GitHubIssueListResult(true, null, Array.Empty()));
+
+ var controller = CreateController(gitHubCliService);
+ var result = await controller.ListIssuesAsync(" Open ", 30, CancellationToken.None).ConfigureAwait(true);
+
+ Assert.IsType(result.Result);
+ await gitHubCliService.Received(1).ListIssuesAsync("open", 30, Arg.Any()).ConfigureAwait(true);
+ }
+
+ ///
+ /// TEST-MCP-GH-006: Verifies that invalid list-state query parameters are rejected before the GitHub CLI
+ /// service is invoked, blocking raw multi-token input from being incorporated into gh list commands.
+ ///
+ [Fact]
+ public async Task ListPullsAsync_WithInvalidState_ReturnsBadRequest()
+ {
+ var gitHubCliService = Substitute.For();
+ var controller = CreateController(gitHubCliService);
+
+ var result = await controller.ListPullsAsync("open --repo other/repo", 30, CancellationToken.None).ConfigureAwait(true);
+
+ Assert.IsType(result.Result);
+ await gitHubCliService.DidNotReceive().ListPullsAsync(Arg.Any(), Arg.Any(), Arg.Any()).ConfigureAwait(true);
+ }
+
+ private static GitHubController CreateController(IGitHubCliService gitHubCliService)
+ {
+ var tokenStore = Substitute.For();
+ var gitHubOptions = Substitute.For>();
+ gitHubOptions.CurrentValue.Returns(new GitHubIntegrationOptions());
+
+ return new GitHubController(
+ gitHubCliService,
+ tokenStore,
+ gitHubOptions,
+ syncService: null,
+ eventBus: null,
+ logger: NullLogger.Instance)
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ }
+ };
+ }
+}
diff --git a/tests/McpServer.Support.Mcp.Tests/Controllers/TodoControllerTests.cs b/tests/McpServer.Support.Mcp.Tests/Controllers/TodoControllerTests.cs
index 1862cc88..6a489b1a 100644
--- a/tests/McpServer.Support.Mcp.Tests/Controllers/TodoControllerTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Controllers/TodoControllerTests.cs
@@ -167,6 +167,90 @@ public async Task DeleteAsync_WhenProjectionFails_ReturnsInternalServerError()
Assert.Equal(TodoMutationFailureKind.ProjectionFailed, mutation.FailureKind);
}
+ ///
+ /// TR-MCP-TODO-006: Verifies that GET /mcpserver/todo/projection/status returns the service-provided
+ /// projection status payload when the active TODO provider supports SQLite projection diagnostics.
+ /// The fixture supplies a fully-populated status result so the controller's success shaping can be asserted.
+ ///
+ [Fact]
+ public async Task GetProjectionStatusAsync_WhenSupported_ReturnsOk()
+ {
+ var todoService = Substitute.For();
+ todoService.GetProjectionStatusAsync(Arg.Any())
+ .Returns(new TodoProjectionStatusResult(
+ "sqlite",
+ "E:\\todo.db",
+ "E:\\docs\\Project\\TODO.yaml",
+ true,
+ true,
+ false,
+ "2026-03-21T00:00:00.0000000Z",
+ LastProjectedToYamlUtc: "2026-03-21T00:00:00.0000000Z",
+ Message: "TODO.yaml matches authoritative SQLite state."));
+
+ var controller = CreateController(todoService);
+ var actionResult = await controller.GetProjectionStatusAsync(CancellationToken.None).ConfigureAwait(true);
+
+ var ok = Assert.IsType(actionResult.Result);
+ var status = Assert.IsType(ok.Value);
+ Assert.False(status.RepairRequired);
+ Assert.True(status.ProjectionConsistent);
+ }
+
+ ///
+ /// TR-MCP-TODO-006: Verifies that GET /mcpserver/todo/projection/status returns 501 when the active TODO
+ /// provider does not support SQLite projection diagnostics. The fixture uses a thrown
+ /// to exercise the controller's compatibility path.
+ ///
+ [Fact]
+ public async Task GetProjectionStatusAsync_WhenNotSupported_ReturnsNotImplemented()
+ {
+ var todoService = Substitute.For();
+ todoService.GetProjectionStatusAsync(Arg.Any())
+ .Returns(_ => Task.FromException(
+ new NotSupportedException("Projection status requires sqlite-backed TODO storage.")));
+
+ var controller = CreateController(todoService);
+ var actionResult = await controller.GetProjectionStatusAsync(CancellationToken.None).ConfigureAwait(true);
+
+ var objectResult = Assert.IsType(actionResult.Result);
+ Assert.Equal(StatusCodes.Status501NotImplemented, objectResult.StatusCode);
+ }
+
+ ///
+ /// TR-MCP-TODO-006: Verifies that POST /mcpserver/todo/projection/repair returns HTTP 500 when the
+ /// service reports an unsuccessful repair attempt. The fixture returns a failed repair result so the
+ /// controller can preserve the service's operator-visible error details.
+ ///
+ [Fact]
+ public async Task RepairProjectionAsync_WhenRepairFails_ReturnsInternalServerError()
+ {
+ var todoService = Substitute.For();
+ todoService.RepairProjectionAsync(Arg.Any())
+ .Returns(new TodoProjectionRepairResult(
+ false,
+ "repair failed",
+ new TodoProjectionStatusResult(
+ "sqlite",
+ "E:\\todo.db",
+ "E:\\docs\\Project\\TODO.yaml",
+ false,
+ false,
+ true,
+ "2026-03-21T00:00:00.0000000Z",
+ LastProjectionFailure: "Directory exists at projection target.",
+ Message: "Projected TODO target 'E:\\docs\\Project\\TODO.yaml' is a directory instead of a file.")));
+
+ var controller = CreateController(todoService);
+ var actionResult = await controller.RepairProjectionAsync(CancellationToken.None).ConfigureAwait(true);
+
+ var objectResult = Assert.IsType(actionResult.Result);
+ Assert.Equal(StatusCodes.Status500InternalServerError, objectResult.StatusCode);
+ var repair = Assert.IsType(objectResult.Value);
+ Assert.True(repair.Status.RepairRequired);
+ Assert.Equal("repair failed", repair.Error);
+ }
+
private static TodoController CreateController(
ITodoService todoService,
IGitHubCliService? gitHubCliService = null,
diff --git a/tests/McpServer.Support.Mcp.Tests/Ingestion/SessionLogIngestorImportTests.cs b/tests/McpServer.Support.Mcp.Tests/Ingestion/SessionLogIngestorImportTests.cs
index a8b245f6..c0a6dc8f 100644
--- a/tests/McpServer.Support.Mcp.Tests/Ingestion/SessionLogIngestorImportTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Ingestion/SessionLogIngestorImportTests.cs
@@ -28,6 +28,7 @@ public SessionLogIngestorImportTests()
_service = new SessionLogService(_db, NullLogger.Instance);
_tempDir = Path.Combine(Path.GetTempPath(), $"fwh-ingestor-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(_tempDir, "docs", "sessions"));
+ _db.OverrideWorkspaceId(_tempDir);
}
public void Dispose()
@@ -46,8 +47,8 @@ public async Task WhenImportingJsonSessionLogThenSessionIsPersisted()
Title = "Imported Session",
Model = "gpt-4",
Started = "2026-02-12T10:00:00Z",
- TurnCount = 1,
- Turns =
+ TurnCount = 1,
+ Turns =
[
new UnifiedRequestEntryDto
{
@@ -63,11 +64,11 @@ public async Task WhenImportingJsonSessionLogThenSessionIsPersisted()
var result = await ingestor.ImportToSessionLogTablesAsync().ConfigureAwait(true);
Assert.Equal(1, result.Imported);
- var stored = await _db.SessionLogs.Include(s => s.Turns).FirstOrDefaultAsync(s => s.SessionId == "import-1").ConfigureAwait(true);
+ var stored = await _db.SessionLogs.Include(s => s.Turns).FirstOrDefaultAsync(s => s.SessionId == "import-1").ConfigureAwait(true);
Assert.NotNull(stored);
Assert.Equal("Copilot", stored!.SourceType);
Assert.Equal("Imported Session", stored.Title);
- Assert.Single(stored.Turns);
+ Assert.Single(stored.Turns);
Assert.NotNull(stored.SourceFilePath);
Assert.EndsWith("copilot-test.json", stored.SourceFilePath!);
Assert.NotNull(stored.ContentHash);
@@ -83,7 +84,7 @@ public async Task WhenImportingWithStringWorkspaceThenWorkspaceIsHandled()
"sessionId": "ws-string",
"title": "String Workspace",
"workspace": "E:\\github\\FunWasHad",
- "turnCount": 0
+ "turnCount": 0
}
""";
File.WriteAllText(Path.Combine(_tempDir, "docs", "sessions", "cursor-ws.json"), json);
@@ -110,7 +111,7 @@ public async Task WhenImportingWithObjectWorkspaceThenWorkspaceFieldsArePersiste
"repository": "sharpninja/FunWasHad",
"branch": "develop"
},
- "turnCount": 0
+ "turnCount": 0
}
""";
File.WriteAllText(Path.Combine(_tempDir, "docs", "sessions", "copilot-ws.json"), json);
@@ -128,7 +129,7 @@ public async Task WhenImportingWithObjectWorkspaceThenWorkspaceFieldsArePersiste
[Fact]
public async Task WhenImportingMissingSourceTypeThenFileIsSkipped()
{
- var json = """{ "sessionId": "no-source", "turnCount": 0 }""";
+ var json = """{ "sessionId": "no-source", "turnCount": 0 }""";
File.WriteAllText(Path.Combine(_tempDir, "docs", "sessions", "bad.json"), json);
var ingestor = CreateIngestor();
@@ -148,7 +149,7 @@ public async Task WhenImportingMultipleFilesThenAllAreImported()
SourceType = "Cursor",
SessionId = $"multi-{i}",
Title = $"Multi {i}",
- TurnCount = 0
+ TurnCount = 0
});
}
@@ -168,7 +169,7 @@ public async Task WhenReimportingSameFileThenSessionIsUpserted()
SourceType = "Copilot",
SessionId = "upsert-1",
Title = "Original",
- TurnCount = 0
+ TurnCount = 0
});
var ingestor = CreateIngestor();
@@ -180,7 +181,7 @@ public async Task WhenReimportingSameFileThenSessionIsUpserted()
SourceType = "Copilot",
SessionId = "upsert-1",
Title = "Updated",
- TurnCount = 0
+ TurnCount = 0
});
var result = await ingestor.ImportToSessionLogTablesAsync().ConfigureAwait(true);
@@ -199,7 +200,7 @@ public async Task WhenFileIsUnchangedThenImportSkipsIt()
SourceType = "Cursor",
SessionId = "unchanged-1",
Title = "Stable",
- TurnCount = 0
+ TurnCount = 0
});
var ingestor = CreateIngestor();
@@ -222,7 +223,7 @@ public async Task WhenFileChangedThenImportUpdatesIt()
SourceType = "Copilot",
SessionId = "changing-1",
Title = "V1",
- TurnCount = 0
+ TurnCount = 0
});
var ingestor = CreateIngestor();
@@ -234,7 +235,7 @@ public async Task WhenFileChangedThenImportUpdatesIt()
SourceType = "Copilot",
SessionId = "changing-1",
Title = "V2",
- TurnCount = 0
+ TurnCount = 0
});
var result = await ingestor.ImportToSessionLogTablesAsync().ConfigureAwait(true);
@@ -252,14 +253,14 @@ public async Task WhenMixOfChangedAndUnchangedThenOnlyChangedAreImported()
SourceType = "Cursor",
SessionId = "stable-1",
Title = "Stable",
- TurnCount = 0
+ TurnCount = 0
});
WriteSessionFile("evolving.json", new UnifiedSessionLogDto
{
SourceType = "Cursor",
SessionId = "evolving-1",
Title = "V1",
- TurnCount = 0
+ TurnCount = 0
});
var ingestor = CreateIngestor();
@@ -272,7 +273,7 @@ public async Task WhenMixOfChangedAndUnchangedThenOnlyChangedAreImported()
SourceType = "Cursor",
SessionId = "evolving-1",
Title = "V2",
- TurnCount = 0
+ TurnCount = 0
});
var second = await ingestor.ImportToSessionLogTablesAsync().ConfigureAwait(true);
@@ -287,7 +288,7 @@ private SessionLogIngestor CreateIngestor()
RepoRoot = _tempDir,
SessionsPath = "docs/sessions"
});
- return new SessionLogIngestor(new Chunker(), opts, new WorkspaceContext(), _service, NullLogger.Instance);
+ return new SessionLogIngestor(new Chunker(), opts, new WorkspaceContext(), _service, NullLogger.Instance);
}
private void WriteSessionFile(string filename, UnifiedSessionLogDto dto)
@@ -327,12 +328,12 @@ Tested Markdown import pipeline.
Assert.True(result.Imported >= 1);
var stored = await _db.SessionLogs
- .Include(s => s.Turns)
+ .Include(s => s.Turns)
.FirstOrDefaultAsync(s => s.SourceType == "copilot")
.ConfigureAwait(true);
Assert.NotNull(stored);
Assert.Contains("MD Import Test", stored!.Title, StringComparison.Ordinal);
- Assert.NotEmpty(stored.Turns);
+ Assert.NotEmpty(stored.Turns);
}
[Fact]
@@ -393,7 +394,7 @@ public async Task WhenImportingJsonAndMarkdownThenBothAreProcessed()
SourceType = "Cursor",
SessionId = "json-coexist-1",
Title = "JSON Session",
- TurnCount = 0
+ TurnCount = 0
});
var md = """
diff --git a/tests/McpServer.Support.Mcp.Tests/Middleware/InteractionLoggingMiddlewareTests.cs b/tests/McpServer.Support.Mcp.Tests/Middleware/InteractionLoggingMiddlewareTests.cs
index 63a6c88f..78556ad6 100644
--- a/tests/McpServer.Support.Mcp.Tests/Middleware/InteractionLoggingMiddlewareTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Middleware/InteractionLoggingMiddlewareTests.cs
@@ -2,6 +2,7 @@
using McpServer.Support.Mcp.Models;
using McpServer.Support.Mcp.Options;
using McpServer.Support.Mcp.Services;
+using McpServer.Support.Mcp.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -291,4 +292,32 @@ public async Task InvokeAsync_ResponseBody_StillWrittenToOriginalStream()
var writtenContent = await reader.ReadToEndAsync().ConfigureAwait(true);
Assert.Equal(responseJson, writtenContent);
}
+
+ /// InvokeAsync logs a warning when the submission queue rejects an interaction log entry because the buffer is full.
+ [Fact]
+ public async Task InvokeAsync_WhenQueueRejectsEntry_LogsWarning()
+ {
+ RequestDelegate next = _ => Task.CompletedTask;
+ var logger = new TestLogger();
+ var options = Microsoft.Extensions.Options.Options.Create(new McpInteractionLoggingOptions
+ {
+ LoggingServiceUrl = "https://log.example.com/ingest",
+ IncludeRequestBody = false,
+ IncludeResponseBody = false
+ });
+ var channel = Substitute.For();
+ channel.TryEnqueue(Arg.Any()).Returns(false);
+
+ var middleware = new McpServer.Support.Mcp.Middleware.InteractionLoggingMiddleware(next, logger, options, channel);
+ var context = CreateContext("POST", "/mcpserver/context/search");
+ context.Response.StatusCode = 202;
+
+ await middleware.InvokeAsync(context).ConfigureAwait(true);
+
+ Assert.Contains(
+ logger.Entries,
+ entry => entry.Level == LogLevel.Warning &&
+ entry.Message.Contains("Interaction log forwarding rejected request", StringComparison.Ordinal) &&
+ entry.Message.Contains("/mcpserver/context/search", StringComparison.Ordinal));
+ }
}
diff --git a/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceAuthMiddlewareTests.cs b/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceAuthMiddlewareTests.cs
index 1e0b9e32..5352888a 100644
--- a/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceAuthMiddlewareTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceAuthMiddlewareTests.cs
@@ -7,7 +7,7 @@
namespace McpServer.Support.Mcp.Tests.Middleware;
-/// Unit tests for default key behavior.
+/// Unit tests for default-key behavior.
public sealed class WorkspaceAuthMiddlewareTests
{
private const string WorkspacePath = @"C:\projects\test";
@@ -78,7 +78,7 @@ public async Task DefaultToken_AllowsReadOnNonTodoRoute()
}
[Fact]
- public async Task DefaultToken_AllowsWriteOnTodoRoute()
+ public async Task DefaultToken_DeniesWriteOnTodoRoute()
{
var tokenService = CreateTokenService();
var defaultToken = tokenService.GetDefaultToken(WorkspacePath)!;
@@ -88,7 +88,8 @@ public async Task DefaultToken_AllowsWriteOnTodoRoute()
await middleware.InvokeAsync(ctx, tokenService, CreateConfig(), CreateWorkspaceContext());
- Assert.True(nextCalled);
+ Assert.False(nextCalled);
+ Assert.Equal(403, ctx.Response.StatusCode);
}
[Fact]
@@ -122,7 +123,7 @@ public async Task DefaultToken_DeniesDeleteOnNonTodoRoute()
}
[Fact]
- public async Task DefaultToken_AllowsDeleteOnTodoRoute()
+ public async Task DefaultToken_DeniesDeleteOnTodoRoute()
{
var tokenService = CreateTokenService();
var defaultToken = tokenService.GetDefaultToken(WorkspacePath)!;
@@ -132,7 +133,8 @@ public async Task DefaultToken_AllowsDeleteOnTodoRoute()
await middleware.InvokeAsync(ctx, tokenService, CreateConfig(), CreateWorkspaceContext());
- Assert.True(nextCalled);
+ Assert.False(nextCalled);
+ Assert.Equal(403, ctx.Response.StatusCode);
}
[Fact]
@@ -184,9 +186,8 @@ public async Task ReadsWorkspaceFromContext_InsteadOfConfig()
}
[Fact]
- public async Task EmptyApiKey_Config_PassesAll()
+ public async Task MissingWorkspaceToken_Returns503()
{
- // When Mcp:ApiKey is empty, auth is open mode — all requests pass.
var tokenService = new WorkspaceTokenService();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
@@ -202,6 +203,25 @@ public async Task EmptyApiKey_Config_PassesAll()
await middleware.InvokeAsync(ctx, tokenService, config, wsContext);
- Assert.True(nextCalled);
+ Assert.False(nextCalled);
+ Assert.Equal(503, ctx.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task EmptyWorkspaceContext_Returns503()
+ {
+ var tokenService = new WorkspaceTokenService();
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary())
+ .Build();
+ var wsContext = new WorkspaceContext();
+ var nextCalled = false;
+ var middleware = new WorkspaceAuthMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance);
+ var ctx = CreateContext("GET", "/mcpserver/context/search", null);
+
+ await middleware.InvokeAsync(ctx, tokenService, config, wsContext);
+
+ Assert.False(nextCalled);
+ Assert.Equal(503, ctx.Response.StatusCode);
}
}
diff --git a/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceResolutionMiddlewareTests.cs b/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceResolutionMiddlewareTests.cs
index 933f6f57..21edcc79 100644
--- a/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceResolutionMiddlewareTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Middleware/WorkspaceResolutionMiddlewareTests.cs
@@ -46,7 +46,7 @@ private static IWorkspaceService CreateWorkspaceService(params WorkspaceDto[] wo
return svc;
}
- private static DefaultHttpContext CreateContext(string path, string method = "GET", string? workspaceHeader = null, string? apiKey = null)
+ private static DefaultHttpContext CreateContext(string path, string method = "GET", string? workspaceHeader = null, string? apiKey = null, string? bearerToken = null)
{
var ctx = new DefaultHttpContext
{
@@ -57,6 +57,8 @@ private static DefaultHttpContext CreateContext(string path, string method = "GE
ctx.Request.Headers[WorkspaceResolutionMiddleware.WorkspacePathHeader] = workspaceHeader;
if (apiKey is not null)
ctx.Request.Headers["X-Api-Key"] = apiKey;
+ if (bearerToken is not null)
+ ctx.Request.Headers.Authorization = $"Bearer {bearerToken}";
return ctx;
}
@@ -236,4 +238,39 @@ public async Task DefaultToken_SetsIsDefaultKey()
Assert.True(wsContext.IsResolved);
Assert.True(wsContext.IsDefaultKey);
}
+
+ [Fact]
+ public async Task BearerToken_WithoutWorkspaceHeader_RejectsTenantRoute()
+ {
+ var wsDto = MakeDto(WorkspaceA, isPrimary: true);
+ var workspaceService = CreateWorkspaceService(wsDto);
+ var tokenService = new WorkspaceTokenService();
+ var wsContext = new WorkspaceContext();
+ var nextCalled = false;
+ var mw = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ var ctx = CreateContext("/mcpserver/sessionlog/query", bearerToken: "jwt-token");
+ await mw.InvokeAsync(ctx, wsContext, tokenService, workspaceService);
+
+ Assert.False(nextCalled);
+ Assert.Equal(404, ctx.Response.StatusCode);
+ Assert.False(wsContext.IsResolved);
+ }
+
+ [Fact]
+ public async Task BearerToken_WithoutWorkspaceHeader_AllowsWorkspaceRegistryRoute()
+ {
+ var wsDto = MakeDto(WorkspaceA, isPrimary: true);
+ var workspaceService = CreateWorkspaceService(wsDto);
+ var tokenService = new WorkspaceTokenService();
+ var wsContext = new WorkspaceContext();
+ var nextCalled = false;
+ var mw = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ var ctx = CreateContext("/mcpserver/workspace", bearerToken: "jwt-token");
+ await mw.InvokeAsync(ctx, wsContext, tokenService, workspaceService);
+
+ Assert.True(nextCalled);
+ Assert.False(wsContext.IsResolved);
+ }
}
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/AgentServiceRuntimeTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/AgentServiceRuntimeTests.cs
index c6addc7b..1251ac14 100644
--- a/tests/McpServer.Support.Mcp.Tests/Services/AgentServiceRuntimeTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Services/AgentServiceRuntimeTests.cs
@@ -61,6 +61,7 @@ public void Dispose()
public async Task LaunchAgentAsync_EnabledConfig_DelegatesToProcessManager()
{
var workspacePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "agent-service-launch"));
+ _db.OverrideWorkspaceId(workspacePath);
SeedDefinitionAndWorkspace(workspacePath, enabled: true, banned: false, launchCommand: "agent --workspace {workspacePath} --id {agentId}");
_processManager.LaunchAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(new AgentProcessInfo
@@ -86,6 +87,7 @@ await _processManager.Received(1).LaunchAsync(
public async Task LaunchAgentAsync_BannedAgent_ThrowsInvalidOperationException()
{
var workspacePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "agent-service-banned"));
+ _db.OverrideWorkspaceId(workspacePath);
SeedDefinitionAndWorkspace(workspacePath, enabled: true, banned: true, launchCommand: "agent");
await Assert.ThrowsAsync(() => _sut.LaunchAgentAsync(workspacePath, "planner")).ConfigureAwait(true);
@@ -95,6 +97,7 @@ public async Task LaunchAgentAsync_BannedAgent_ThrowsInvalidOperationException()
[Fact]
public async Task LaunchAgentAsync_MissingWorkspaceConfig_ThrowsInvalidOperationException()
{
+ _db.OverrideWorkspaceId(Path.GetFullPath("C:/missing-ws"));
_db.AgentDefinitions.Add(new AgentDefinitionEntity
{
Id = "planner",
@@ -117,6 +120,7 @@ public async Task LaunchAgentAsync_MissingWorkspaceConfig_ThrowsInvalidOperation
public async Task StopAgentAsync_RunningAgent_DelegatesToProcessManager()
{
var workspacePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "agent-service-stop"));
+ _db.OverrideWorkspaceId(workspacePath);
SeedDefinitionAndWorkspace(workspacePath, enabled: true, banned: false, launchCommand: "agent");
_processManager.StopAsync(workspacePath, "planner", Arg.Any())
.Returns(Task.FromResult(true));
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/AppSettingsFileServiceTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/AppSettingsFileServiceTests.cs
index ba0ae3d9..ec929584 100644
--- a/tests/McpServer.Support.Mcp.Tests/Services/AppSettingsFileServiceTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Services/AppSettingsFileServiceTests.cs
@@ -180,6 +180,127 @@ await File.WriteAllTextAsync(
Assert.Contains("CopilotModel: should-not-change", contentRootYamlText, StringComparison.Ordinal);
}
+ ///
+ /// TEST-MCP-091: Verifies that concurrent YAML patch requests are serialized across the full
+ /// read-modify-write cycle so both updates persist instead of one request overwriting the other.
+ /// The test blocks the first temp-file write after load to force a true overlap opportunity.
+ ///
+ [Fact]
+ public async Task PatchYamlConfigurationAsync_ConcurrentPatchesSerializeWholeMutation()
+ {
+ var yamlPath = Path.Combine(_tempDirectory, "appsettings.yaml");
+ await File.WriteAllTextAsync(
+ yamlPath,
+ """
+ VoiceConversation:
+ CopilotModel: gpt-5.3-codex
+ """).ConfigureAwait(true);
+
+ var configuration = BuildConfiguration(yamlPath);
+ var firstWriteEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirstWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var writeCount = 0;
+ var service = CreateService(
+ configuration,
+ _tempDirectory,
+ async (path, content, ct) =>
+ {
+ var invocation = Interlocked.Increment(ref writeCount);
+ if (invocation == 1)
+ {
+ firstWriteEntered.SetResult();
+ await releaseFirstWrite.Task.WaitAsync(ct).ConfigureAwait(false);
+ }
+
+ await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false);
+ });
+
+ var firstPatch = service.PatchYamlConfigurationAsync(
+ new Dictionary { ["VoiceConversation:DefaultExecutionStrategy"] = "hosted-mcp-agent" },
+ CancellationToken.None);
+
+ await firstWriteEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
+
+ var secondPatch = service.PatchYamlConfigurationAsync(
+ new Dictionary { ["VoiceConversation:ModelApiKeyEnvironmentVariableName"] = "OPENAI_API_KEY" },
+ CancellationToken.None);
+
+ releaseFirstWrite.SetResult();
+ await Task.WhenAll(firstPatch, secondPatch).ConfigureAwait(true);
+
+ var yamlText = await File.ReadAllTextAsync(yamlPath).ConfigureAwait(true);
+ Assert.Contains("DefaultExecutionStrategy: hosted-mcp-agent", yamlText, StringComparison.Ordinal);
+ Assert.Contains("ModelApiKeyEnvironmentVariableName: OPENAI_API_KEY", yamlText, StringComparison.Ordinal);
+ Assert.Equal("hosted-mcp-agent", configuration["VoiceConversation:DefaultExecutionStrategy"]);
+ Assert.Equal("OPENAI_API_KEY", configuration["VoiceConversation:ModelApiKeyEnvironmentVariableName"]);
+ }
+
+ ///
+ /// TEST-MCP-091: Verifies that a failed temp-file write leaves the original YAML document untouched
+ /// and cleans up the temporary artifact so interrupted writes cannot strand partial config files.
+ ///
+ [Fact]
+ public async Task PatchYamlConfigurationAsync_WhenAtomicWriteFails_LeavesOriginalFileAndCleansTempFile()
+ {
+ var yamlPath = Path.Combine(_tempDirectory, "appsettings.yaml");
+ await File.WriteAllTextAsync(
+ yamlPath,
+ """
+ VoiceConversation:
+ CopilotModel: gpt-5.3-codex
+ """).ConfigureAwait(true);
+
+ var configuration = BuildConfiguration(yamlPath);
+ string? tempPath = null;
+ var service = CreateService(
+ configuration,
+ _tempDirectory,
+ async (path, content, ct) =>
+ {
+ tempPath = path;
+ await File.WriteAllTextAsync(path, "partial", ct).ConfigureAwait(false);
+ throw new IOException("Simulated temp-write failure.");
+ });
+
+ await Assert.ThrowsAsync(() => service.PatchYamlConfigurationAsync(
+ new Dictionary { ["VoiceConversation:CopilotModel"] = "gpt-5.4" },
+ CancellationToken.None)).ConfigureAwait(true);
+
+ var yamlText = await File.ReadAllTextAsync(yamlPath).ConfigureAwait(true);
+ Assert.Contains("CopilotModel: gpt-5.3-codex", yamlText, StringComparison.Ordinal);
+ Assert.Equal("gpt-5.3-codex", configuration["VoiceConversation:CopilotModel"]);
+ Assert.NotNull(tempPath);
+ Assert.False(File.Exists(tempPath));
+ }
+
+ ///
+ /// TEST-MCP-091: Verifies that global prompt updates use the same atomic write service when the active
+ /// configuration file is JSON-backed, preserving reload behavior and consistent formatting.
+ ///
+ [Fact]
+ public async Task UpdateGlobalPromptTemplateAsync_WhenJsonBacked_UpdatesJsonAndReloadsConfiguration()
+ {
+ var jsonPath = Path.Combine(_tempDirectory, "appsettings.json");
+ await File.WriteAllTextAsync(
+ jsonPath,
+ """
+ {
+ "Mcp": {
+ "MarkerPromptTemplate": "old-template"
+ }
+ }
+ """).ConfigureAwait(true);
+
+ var configuration = BuildJsonConfiguration(jsonPath);
+ var service = CreateService(configuration);
+
+ await service.UpdateGlobalPromptTemplateAsync("new-template", CancellationToken.None).ConfigureAwait(true);
+
+ var jsonText = await File.ReadAllTextAsync(jsonPath).ConfigureAwait(true);
+ Assert.Equal("new-template", configuration["Mcp:MarkerPromptTemplate"]);
+ Assert.Contains("\"MarkerPromptTemplate\": \"new-template\"", jsonText, StringComparison.Ordinal);
+ }
+
///
public void Dispose()
{
@@ -192,11 +313,16 @@ private AppSettingsFileService CreateService(IConfiguration configuration)
return CreateService(configuration, _tempDirectory);
}
- private static AppSettingsFileService CreateService(IConfiguration configuration, string contentRootPath)
+ private static AppSettingsFileService CreateService(
+ IConfiguration configuration,
+ string contentRootPath,
+ Func? writeTextAsync = null)
{
var environment = Substitute.For();
environment.ContentRootPath.Returns(contentRootPath);
- return new AppSettingsFileService(configuration, environment);
+ return writeTextAsync is null
+ ? new AppSettingsFileService(configuration, environment)
+ : new AppSettingsFileService(configuration, environment, writeTextAsync);
}
private static IConfigurationRoot BuildConfiguration(string yamlPath)
@@ -205,4 +331,11 @@ private static IConfigurationRoot BuildConfiguration(string yamlPath)
.AddYamlFile(yamlPath, optional: false, reloadOnChange: false)
.Build();
}
+
+ private static IConfigurationRoot BuildJsonConfiguration(string jsonPath)
+ {
+ return new ConfigurationBuilder()
+ .AddJsonFile(jsonPath, optional: false, reloadOnChange: false)
+ .Build();
+ }
}
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/ChannelChangeEventBusTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/ChannelChangeEventBusTests.cs
index 2c89d6eb..666ea1f2 100644
--- a/tests/McpServer.Support.Mcp.Tests/Services/ChannelChangeEventBusTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Services/ChannelChangeEventBusTests.cs
@@ -1,15 +1,17 @@
using McpServer.Support.Mcp.Notifications;
+using McpServer.Support.Mcp.Tests.TestSupport;
using Xunit;
namespace McpServer.Support.Mcp.Tests.Services;
-/// Unit tests for ChannelChangeEventBus pub/sub behavior.
+/// TR-MCP-EVT-001: Unit tests for ChannelChangeEventBus pub/sub behavior.
public sealed class ChannelChangeEventBusTests
{
+ /// PublishAsync does not throw when no subscribers are registered for the event bus.
[Fact]
public async Task PublishAsync_NoSubscribers_DoesNotThrow()
{
- var sut = new ChannelChangeEventBus();
+ var sut = new ChannelChangeEventBus(new TestLogger());
var evt = new ChangeEvent
{
Category = ChangeEventCategories.Todo,
@@ -23,10 +25,11 @@ public async Task PublishAsync_NoSubscribers_DoesNotThrow()
Assert.Null(ex);
}
+ /// PublishAsync fans a change event out to all active subscribers.
[Fact]
public async Task PublishAsync_MultipleSubscribers_AllReceiveEvent()
{
- var sut = new ChannelChangeEventBus();
+ var sut = new ChannelChangeEventBus(new TestLogger());
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sub1 = sut.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token);
@@ -49,10 +52,11 @@ public async Task PublishAsync_MultipleSubscribers_AllReceiveEvent()
Assert.Equal("TEST-002", sub2.Current.EntityId);
}
+ /// SubscribeAsync stops enumeration when the subscriber cancellation token is cancelled.
[Fact]
public async Task SubscribeAsync_Cancellation_StopsEnumeration()
{
- var sut = new ChannelChangeEventBus();
+ var sut = new ChannelChangeEventBus(new TestLogger());
using var cts = new CancellationTokenSource();
var sub = sut.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token);
@@ -63,4 +67,64 @@ await Assert.ThrowsAnyAsync(async () =>
await sub.MoveNextAsync().ConfigureAwait(true);
}).ConfigureAwait(true);
}
+
+ /// PublishAsync logs overflow and preserves queued events when a subscriber buffer is full.
+ [Fact]
+ public async Task PublishAsync_WhenSubscriberBufferIsFull_LogsWarningAndRejectsNewEvent()
+ {
+ var logger = new TestLogger();
+ var sut = new ChannelChangeEventBus(logger);
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await using var subscriber = sut.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token);
+
+ var firstReadTask = subscriber.MoveNextAsync().AsTask();
+ await sut.PublishAsync(
+ new ChangeEvent
+ {
+ Category = ChangeEventCategories.Todo,
+ Action = ChangeEventActions.Updated,
+ EntityId = "ITEM-0000",
+ },
+ cts.Token).ConfigureAwait(true);
+
+ Assert.True(await firstReadTask.ConfigureAwait(true));
+ Assert.Equal("ITEM-0000", subscriber.Current.EntityId);
+
+ for (var index = 1; index <= 1000; index++)
+ {
+ await sut.PublishAsync(
+ new ChangeEvent
+ {
+ Category = ChangeEventCategories.Todo,
+ Action = ChangeEventActions.Updated,
+ EntityId = $"ITEM-{index:0000}",
+ },
+ cts.Token).ConfigureAwait(true);
+ }
+
+ await sut.PublishAsync(
+ new ChangeEvent
+ {
+ Category = ChangeEventCategories.Todo,
+ Action = ChangeEventActions.Updated,
+ EntityId = "ITEM-OVERFLOW",
+ },
+ cts.Token).ConfigureAwait(true);
+
+ var deliveredEntityIds = new List { "ITEM-0000" };
+ for (var index = 1; index <= 1000; index++)
+ {
+ Assert.True(await subscriber.MoveNextAsync().ConfigureAwait(true));
+ deliveredEntityIds.Add(subscriber.Current.EntityId!);
+ }
+
+ Assert.Equal("ITEM-0000", deliveredEntityIds[0]);
+ Assert.Equal("ITEM-1000", deliveredEntityIds[^1]);
+ Assert.DoesNotContain("ITEM-OVERFLOW", deliveredEntityIds);
+ Assert.Contains(
+ logger.Entries,
+ entry => entry.Level == Microsoft.Extensions.Logging.LogLevel.Warning &&
+ entry.Message.Contains("Change event delivery rejected 1 subscriber writes", StringComparison.Ordinal) &&
+ entry.Message.Contains("ITEM-OVERFLOW", StringComparison.Ordinal));
+ }
}
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/DesktopLaunchServiceTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/DesktopLaunchServiceTests.cs
new file mode 100644
index 00000000..9ac7dbce
--- /dev/null
+++ b/tests/McpServer.Support.Mcp.Tests/Services/DesktopLaunchServiceTests.cs
@@ -0,0 +1,137 @@
+using System.Collections.Generic;
+using McpServer.Support.Mcp.Models;
+using McpServer.Support.Mcp.Options;
+using McpServer.Support.Mcp.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Xunit;
+
+namespace McpServer.Support.Mcp.Tests.Services;
+
+///
+/// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that enforces the
+/// desktop-launch feature gate and executable allowlist before invoking the launcher process.
+/// The tests use an in-memory configuration root plus a substituted
+/// so privileged launch decisions can be asserted without starting real desktop programs.
+///
+public sealed class DesktopLaunchServiceTests
+{
+ ///
+ /// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that the service fails closed when desktop launch
+ /// is disabled, even if the launcher path and request payload are otherwise valid.
+ /// The test uses the current process path as a deterministic absolute executable fixture and a
+ /// substituted runner so the denial path can prove no launcher invocation occurs.
+ ///
+ [Fact]
+ public async Task LaunchAsync_WhenDesktopLaunchDisabled_ReturnsFailureWithoutInvokingRunner()
+ {
+ var executablePath = GetExistingExecutablePath();
+ var processRunner = Substitute.For();
+ var service = CreateService(
+ processRunner,
+ new DesktopLaunchOptions
+ {
+ Enabled = false,
+ AllowedExecutables = { $"**/{Path.GetFileName(executablePath)}" }
+ });
+
+ var result = await service.LaunchAsync(
+ Path.GetDirectoryName(executablePath)!,
+ new DesktopLaunchRequest { ExecutablePath = executablePath });
+
+ Assert.False(result.Success);
+ Assert.Contains("disabled", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ await processRunner.DidNotReceive().RunAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ ///
+ /// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that the service rejects executables that do not
+ /// match the configured allowlist, even when desktop launch is enabled.
+ /// The test uses the current process path plus a deliberately non-matching pattern so the
+ /// allowlist check proves the runner stays untouched on denied launches.
+ ///
+ [Fact]
+ public async Task LaunchAsync_WhenExecutableDoesNotMatchAllowlist_ReturnsFailureWithoutInvokingRunner()
+ {
+ var executablePath = GetExistingExecutablePath();
+ var processRunner = Substitute.For();
+ var service = CreateService(
+ processRunner,
+ new DesktopLaunchOptions
+ {
+ Enabled = true,
+ AllowedExecutables = { "**/not-allowed.exe" }
+ });
+
+ var result = await service.LaunchAsync(
+ Path.GetDirectoryName(executablePath)!,
+ new DesktopLaunchRequest { ExecutablePath = executablePath });
+
+ Assert.False(result.Success);
+ Assert.Contains("allowlist", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ await processRunner.DidNotReceive().RunAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ ///
+ /// FR-MCP-047/TR-MCP-DESKTOP-001: Verifies that an enabled service forwards an allowlisted
+ /// executable to the launcher after normalizing the payload.
+ /// The test uses the current process path for both the launcher fixture and the executable
+ /// fixture so the runner can return a deterministic JSON success payload without external files.
+ ///
+ [Fact]
+ public async Task LaunchAsync_WhenExecutableMatchesAllowlist_InvokesRunner()
+ {
+ var executablePath = GetExistingExecutablePath();
+ var processRunner = Substitute.For();
+ processRunner
+ .RunAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new ProcessRunResult(0, """{"success":true,"processId":4242,"exitCode":0}""", null)));
+
+ var service = CreateService(
+ processRunner,
+ new DesktopLaunchOptions
+ {
+ Enabled = true,
+ AllowedExecutables = { $"**/{Path.GetFileName(executablePath)}" }
+ });
+
+ var result = await service.LaunchAsync(
+ Path.GetDirectoryName(executablePath)!,
+ new DesktopLaunchRequest
+ {
+ ExecutablePath = executablePath,
+ CreateNoWindow = true,
+ WaitForExit = true
+ });
+
+ Assert.True(result.Success);
+ Assert.Equal(4242, result.ProcessId);
+ await processRunner.Received(1).RunAsync(
+ executablePath,
+ Arg.Is(arguments => arguments != null && arguments.Contains(Path.GetFileName(executablePath), StringComparison.Ordinal)),
+ Arg.Any());
+ }
+
+ private static DesktopLaunchService CreateService(IProcessRunner processRunner, DesktopLaunchOptions options)
+ {
+ var launcherPath = GetExistingExecutablePath();
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(
+ new Dictionary
+ {
+ ["Mcp:LauncherPath"] = launcherPath
+ })
+ .Build();
+ return new DesktopLaunchService(
+ configuration,
+ Microsoft.Extensions.Options.Options.Create(options),
+ processRunner,
+ NullLogger.Instance);
+ }
+
+ private static string GetExistingExecutablePath()
+ => Environment.ProcessPath
+ ?? throw new InvalidOperationException("Expected the test host to expose an executable path.");
+}
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/FileGitHubWorkspaceTokenStoreTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/FileGitHubWorkspaceTokenStoreTests.cs
index 4c041c21..b9893918 100644
--- a/tests/McpServer.Support.Mcp.Tests/Services/FileGitHubWorkspaceTokenStoreTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Services/FileGitHubWorkspaceTokenStoreTests.cs
@@ -4,6 +4,9 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
using Xunit;
namespace McpServer.Support.Mcp.Tests.Services;
@@ -70,6 +73,86 @@ public async Task DeleteAsync_RemovesTokenRecord()
Assert.Null(record);
}
+ [Fact]
+ public async Task UpsertAsync_WhenStoreLockIsHeld_WaitsForRelease()
+ {
+ var workspacePath = Path.Combine(_tempRoot, "workspace-locked");
+ var lockPath = Path.Combine(_tempRoot, "github-token-store.json.lock");
+ using var lockStream = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+
+ var upsertTask = _sut.UpsertAsync(workspacePath, "gho_wait_for_lock");
+
+ await Task.Delay(100).ConfigureAwait(true);
+ Assert.False(upsertTask.IsCompleted);
+
+ lockStream.Dispose();
+ await upsertTask.ConfigureAwait(true);
+
+ var record = await _sut.GetAsync(workspacePath).ConfigureAwait(true);
+ Assert.NotNull(record);
+ Assert.Equal("gho_wait_for_lock", record.AccessToken);
+ }
+
+ [Fact]
+ public async Task UpsertAsync_HardensStoreAndLockFilePermissions()
+ {
+ var workspacePath = Path.Combine(_tempRoot, "workspace-permissions");
+ var storePath = Path.Combine(_tempRoot, "github-token-store.json");
+ var lockPath = storePath + ".lock";
+
+ await _sut.UpsertAsync(workspacePath, "gho_permissions").ConfigureAwait(true);
+
+ Assert.True(File.Exists(storePath));
+ Assert.True(File.Exists(lockPath));
+
+ if (OperatingSystem.IsWindows())
+ {
+ AssertRestrictedWindowsFile(storePath);
+ AssertRestrictedWindowsFile(lockPath);
+ return;
+ }
+
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ {
+ var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ Assert.Equal(expected, File.GetUnixFileMode(storePath));
+ Assert.Equal(expected, File.GetUnixFileMode(lockPath));
+ return;
+ }
+
+ throw new PlatformNotSupportedException("The token-store permission test only supports Windows ACL or Unix file-mode assertions.");
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void AssertRestrictedWindowsFile(string path)
+ {
+ var security = new FileInfo(path).GetAccessControl();
+ Assert.True(security.AreAccessRulesProtected);
+
+ var rules = security
+ .GetAccessRules(includeExplicit: true, includeInherited: true, targetType: typeof(SecurityIdentifier))
+ .OfType()
+ .ToArray();
+
+ using var identity = WindowsIdentity.GetCurrent();
+ var currentUser = identity.User
+ ?? throw new InvalidOperationException("The current Windows identity did not expose a user SID for ACL assertions.");
+
+ AssertContainsAllowFullControl(rules, currentUser);
+ AssertContainsAllowFullControl(rules, new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null));
+ AssertContainsAllowFullControl(rules, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null));
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void AssertContainsAllowFullControl(IEnumerable rules, SecurityIdentifier expectedSid)
+ {
+ Assert.Contains(rules, rule =>
+ rule.AccessControlType == AccessControlType.Allow
+ && rule.IdentityReference is SecurityIdentifier actualSid
+ && string.Equals(actualSid.Value, expectedSid.Value, StringComparison.OrdinalIgnoreCase)
+ && rule.FileSystemRights.HasFlag(FileSystemRights.FullControl));
+ }
+
public void Dispose()
{
_sut.Dispose();
diff --git a/tests/McpServer.Support.Mcp.Tests/Services/GitHubCliServiceTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/GitHubCliServiceTests.cs
index 3d580982..a5f34d4d 100644
--- a/tests/McpServer.Support.Mcp.Tests/Services/GitHubCliServiceTests.cs
+++ b/tests/McpServer.Support.Mcp.Tests/Services/GitHubCliServiceTests.cs
@@ -1,13 +1,13 @@
-using McpServer.Support.Mcp.Models;
-using McpServer.Support.Mcp.Options;
-using McpServer.Support.Mcp.Services;
-using McpServer.Support.Mcp.Tests;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using NSubstitute;
-using Microsoft.Extensions.Logging.Abstractions;
-using Xunit;
+using McpServer.Support.Mcp.Models;
+using McpServer.Support.Mcp.Options;
+using McpServer.Support.Mcp.Services;
+using McpServer.Support.Mcp.Tests;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
namespace McpServer.Support.Mcp.Tests.Services;
@@ -100,6 +100,24 @@ public async Task CommentOnIssueAsync_WhenGhSucceeds_ReturnsSuccess()
Assert.True(result.Success);
}
+ ///
+ /// TEST-MCP-GH-006: Verifies that issue comment targets are emitted after an explicit end-of-options
+ /// marker so a flag-shaped identifier cannot be reinterpreted as an injected gh CLI option.
+ ///
+ [Fact]
+ public async Task CommentOnIssueAsync_WithFlagLikeIdentifier_UsesEndOfOptionsMarker()
+ {
+ _processRunner.RunAsync("gh", Arg.Any(), Arg.Any())
+ .Returns(new ProcessRunResult(0, "", null));
+
+ var result = await _sut.CommentOnIssueAsync("--repo", "test comment").ConfigureAwait(true);
+
+ Assert.True(result.Success);
+ await _processRunner.Received(1).RunAsync("gh",
+ Arg.Is(a => a != null && a.Contains("issue comment --body \"test comment\" -- --repo", StringComparison.Ordinal)),
+ Arg.Any()).ConfigureAwait(true);
+ }
+
[Fact]
public async Task CommentOnPullAsync_VerifiesGhArgs()
{
@@ -110,7 +128,7 @@ public async Task CommentOnPullAsync_VerifiesGhArgs()
Assert.True(result.Success);
await _processRunner.Received(1).RunAsync("gh",
- Arg.Is(a => a != null && a.Contains("pr comment 42", StringComparison.Ordinal)),
+ Arg.Is(a => a != null && a.Contains("pr comment --body \"PR comment\" -- 42", StringComparison.Ordinal)),
Arg.Any()).ConfigureAwait(true);
}
@@ -205,6 +223,20 @@ await _processRunner.Received(1).RunAsync("gh",
Arg.Any()).ConfigureAwait(true);
}
+ ///
+ /// TEST-MCP-GH-006: Verifies that invalid close reasons are rejected before launching the GitHub CLI so
+ /// attacker-controlled query strings cannot append extra flags through the close-issue reason parameter.
+ ///
+ [Fact]
+ public async Task CloseIssueAsync_WithInvalidReason_DoesNotInvokeGh()
+ {
+ var result = await _sut.CloseIssueAsync(42, "completed --repo other/repo").ConfigureAwait(true);
+
+ Assert.False(result.Success);
+ Assert.Equal("Invalid close reason. Allowed values: completed, not_planned.", result.ErrorMessage);
+ await _processRunner.DidNotReceiveWithAnyArgs().RunAsync(default!, default!, default).ConfigureAwait(true);
+ }
+
[Fact]
public async Task CloseIssueAsync_WithoutReason_NoReasonFlag()
{
@@ -234,6 +266,20 @@ await _processRunner.Received(1).RunAsync("gh",
Arg.Any()).ConfigureAwait(true);
}
+ ///
+ /// TEST-MCP-GH-006: Verifies that invalid list-state values are rejected before process launch so state
+ /// query parameters cannot smuggle additional GitHub CLI flags into the issue-list command line.
+ ///
+ [Fact]
+ public async Task ListIssuesAsync_WithInvalidState_DoesNotInvokeGh()
+ {
+ var result = await _sut.ListIssuesAsync("open --repo other/repo", 10).ConfigureAwait(true);
+
+ Assert.False(result.Success);
+ Assert.Equal("Invalid state. Allowed values: open, closed, all.", result.Error);
+ await _processRunner.DidNotReceiveWithAnyArgs().RunAsync(default!, default!, default).ConfigureAwait(true);
+ }
+
[Fact]
public async Task ListIssueLabelsAsync_WhenGhSucceeds_ReturnsLabels()
{
@@ -251,158 +297,158 @@ public async Task ListIssueLabelsAsync_WhenGhSucceeds_ReturnsLabels()
}
[Fact]
- public async Task ListIssueLabelsAsync_WhenGhFails_ReturnsError()
- {
- _processRunner.RunAsync("gh", Arg.Any(), Arg.Any