From 3029c3dee0c90d2169c3ac97ea6e2006b05ad404 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:20:47 +0000 Subject: [PATCH] Prevent MCP startup from reading or watching content directories --- src/BetterMail.App/McpEndpoint.cs | 25 +++++- .../McpHostConfigurationTests.cs | 76 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/BetterMail.Tests/McpHostConfigurationTests.cs diff --git a/src/BetterMail.App/McpEndpoint.cs b/src/BetterMail.App/McpEndpoint.cs index afb3ce6..4d4acd5 100644 --- a/src/BetterMail.App/McpEndpoint.cs +++ b/src/BetterMail.App/McpEndpoint.cs @@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using ModelContextProtocol.AspNetCore; @@ -23,9 +23,7 @@ public McpEndpoint(McpMailTools tools, int port, string endpointPath, Func !endpointPath[4..].All(Uri.IsHexDigit)) throw new ArgumentException("The saved MCP endpoint path is invalid.", nameof(endpointPath)); _endpointPath = endpointPath; - var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { Args = [] }); - builder.Configuration.Sources.Clear(); - builder.Logging.ClearProviders(); + var builder = CreateHostBuilder(); builder.WebHost.ConfigureKestrel(options => { options.Listen(IPAddress.Loopback, port); @@ -91,6 +89,25 @@ request.Host.Host is not ("127.0.0.1" or "localhost") || request.Host.Port != ex } } + internal static WebApplicationBuilder CreateHostBuilder() + { + // SlimBuilder installs JSON reload watchers during construction, before sources can be + // cleared. MCP uses saved database settings and must never load or watch content files. + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + Args = [], + ApplicationName = typeof(McpEndpoint).Assembly.GetName().Name, + ContentRootPath = AppContext.BaseDirectory, + EnvironmentName = "Production" + }); + builder.Environment.ContentRootFileProvider = new NullFileProvider(); + builder.Environment.WebRootFileProvider = new NullFileProvider(); + builder.Logging.ClearProviders(); + builder.WebHost.UseKestrelCore(); + builder.Services.AddRouting(); + return builder; + } + public Task StartAsync(CancellationToken cancellationToken = default) => _app.StartAsync(cancellationToken); public async ValueTask DisposeAsync() diff --git a/tests/BetterMail.Tests/McpHostConfigurationTests.cs b/tests/BetterMail.Tests/McpHostConfigurationTests.cs new file mode 100644 index 0000000..3c77345 --- /dev/null +++ b/tests/BetterMail.Tests/McpHostConfigurationTests.cs @@ -0,0 +1,76 @@ +using BetterMail.App; +using BetterMail.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; + +namespace BetterMail.Tests; + +// The working directory and environment are process-wide; keep this regression isolated. +[CollectionDefinition("MCP host configuration", DisableParallelization = true)] +public sealed class McpHostConfigurationCollection; + +[Collection("MCP host configuration")] +public sealed class McpHostConfigurationTests +{ + [Fact] + public async Task HostDoesNotLoadConfigurationOrEnableFileWatchingFromItsWorkingDirectory() + { + var token = TestContext.Current.CancellationToken; + var directory = Path.Combine(Path.GetTempPath(), "bettermail-mcp-no-watch-" + Guid.NewGuid()); + var workingDirectory = Environment.CurrentDirectory; + const string variable = "DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE"; + var reloadSetting = Environment.GetEnvironmentVariable(variable); + Directory.CreateDirectory(directory); + Directory.CreateDirectory(Path.Combine(directory, "projects", "node_modules", "nested")); + // A default builder tries to parse this before callers can clear its config sources. + await File.WriteAllTextAsync(Path.Combine(directory, "appsettings.json"), "invalid JSON", token); + try + { + Environment.CurrentDirectory = directory; + Environment.SetEnvironmentVariable(variable, "true"); + var builder = McpEndpoint.CreateHostBuilder(); + Assert.Equal(Path.GetFullPath(AppContext.BaseDirectory), Path.GetFullPath(builder.Environment.ContentRootPath)); + Assert.NotEqual(directory, builder.Environment.ContentRootPath); + Assert.IsType(builder.Environment.ContentRootFileProvider); + Assert.IsType(builder.Environment.WebRootFileProvider); + Assert.DoesNotContain(builder.Configuration.Sources, source => source is FileConfigurationSource); + Assert.DoesNotContain(((IConfigurationRoot)builder.Configuration).Providers, provider => provider is FileConfigurationProvider); + foreach (var provider in new[] { builder.Environment.ContentRootFileProvider, builder.Environment.WebRootFileProvider }) + { + var change = provider.Watch("**/*"); + Assert.False(change.ActiveChangeCallbacks); + Assert.False(change.HasChanged); + } + await using var app = builder.Build(); + // MCP host creation does not change the application's global reload preference. + Assert.Equal("true", Environment.GetEnvironmentVariable(variable)); + } + finally + { + Environment.CurrentDirectory = workingDirectory; + Environment.SetEnvironmentVariable(variable, reloadSetting); + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task EndpointStartsWithNoConfigurationFilesAndKeepsBearerAuthentication() + { + var token = TestContext.Current.CancellationToken; + var directory = Path.Combine(Path.GetTempPath(), "bettermail-mcp-empty-host-" + Guid.NewGuid()); + try + { + await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('D', 64)); + await store.InitializeAsync(token); + var settings = await store.GetMcpConfigurationAsync(token); + var tools = new McpMailTools(store, () => new(Enabled: true), () => Task.CompletedTask, (_, _, _) => Task.CompletedTask); + await using var endpoint = new McpEndpoint(tools, 0, settings.EndpointPath, () => true, () => settings.AccessKey); + await endpoint.StartAsync(token); + using var client = new HttpClient(); + using var response = await client.GetAsync(endpoint.Address, token); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Bearer", Assert.Single(response.Headers.WwwAuthenticate).Scheme); + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } +}