using System.Collections.Concurrent; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Nostos.Backend.Data; using Nostos.Backend.Endpoints; using Nostos.Backend.Integrations.Mcp; using Nostos.Backend.Services.Mcp; using Nostos.Backend.Tests.Support; using Xunit; namespace Nostos.Backend.Tests.Mcp; /// /// The SelfHosted host as it ships (issue #1469): /mcp behind access /// tokens made in Settings, with every log line kept so a test can prove the /// token never reaches one. /// public sealed class McpAccessTokenFactory : LibraryEndpointFactory { public CapturingLoggerProvider Logs { get; } = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.ConfigureLogging(logging => { logging.SetMinimumLevel(LogLevel.Trace); logging.AddProvider(Logs); }); } } public sealed class CapturingLoggerProvider : ILoggerProvider { private readonly ConcurrentQueue _lines = new(); public IReadOnlyCollection Lines => _lines; public ILogger CreateLogger(string categoryName) => new Logger(categoryName, _lines); public void Dispose() { } private sealed class Logger(string category, ConcurrentQueue lines) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { lines.Enqueue($"{category} scope: {state}"); return null; } public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var structured = state is IEnumerable> pairs ? string.Join(" ", pairs.Select(p => $"{p.Key}={p.Value}")) : string.Empty; lines.Enqueue($"{category} {logLevel}: {formatter(state, exception)} {structured} {exception}"); } } } public sealed class McpAccessTokenTests : IAsyncLifetime { private readonly McpAccessTokenFactory _factory = new(); private HttpClient _http = null!; public async Task InitializeAsync() { _http = _factory.CreateClient(); (await _http.GetAsync("/health/live")).EnsureSuccessStatusCode(); } public async Task DisposeAsync() { _http.Dispose(); await _factory.DisposeAsync(); } [Fact] public async Task A_token_is_made_listed_and_revoked_and_shown_only_once() { var create = await _http.PostAsJsonAsync(McpAccessTokenEndpoints.Route, new { name = "Claude Code", scope = "read_write" }); create.StatusCode.Should().Be(HttpStatusCode.Created); create.Headers.CacheControl!.NoStore.Should().BeTrue(); var created = await create.Content.ReadFromJsonAsync(); var token = created.GetProperty("token").GetString()!; token.Should().StartWith("nst_").And.HaveLength(47); var id = created.GetProperty("details").GetProperty("id").GetString(); created.GetProperty("details").GetProperty("scope").GetString().Should().Be("read_write"); created.GetProperty("details").GetProperty("kind").GetString().Should().Be("token"); var list = await _http.GetStringAsync(McpConnectedAppsEndpoints.Route); var page = JsonDocument.Parse(list).RootElement; page.GetProperty("canCreate").GetBoolean().Should().BeTrue(); var item = page.GetProperty("apps").EnumerateArray().Single(); item.GetProperty("id").GetString().Should().Be(id); item.GetProperty("kind").GetString().Should().Be("token"); item.GetProperty("name").GetString().Should().Be("Claude Code"); item.GetProperty("scope").GetString().Should().Be("read_write"); item.GetProperty("createdAt").GetDateTime().Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5)); item.GetProperty("lastUsedAt").ValueKind.Should().Be(JsonValueKind.Null); list.Should().NotContain(token).And.NotContain(token[4..]).And.NotContain(McpAccessTokenService.Hash(token)); await using (var db = await Contexts().CreateDbContextAsync()) { var row = await db.McpAccessTokens.AsNoTracking().SingleAsync(); row.TokenHash.Should().Be(McpAccessTokenService.Hash(token)).And.NotContain(token[4..]); } (await _http.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/{id}")).StatusCode.Should().Be(HttpStatusCode.NoContent); (await _http.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/{id}")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/not-an-id")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)).GetProperty("apps").GetArrayLength().Should().Be(0); // A revoked SelfHosted token is gone, not blocked: there is nothing to // allow again (#1483), so restore finds nothing and brings nothing back. (await _http.PostAsync($"{McpConnectedAppsEndpoints.Route}/{id}/restore", null)).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)).GetProperty("apps").GetArrayLength().Should().Be(0); } [Theory] [InlineData("", "read")] [InlineData(" ", "read_write")] [InlineData("Cursor", "write")] [InlineData("Cursor", "admin")] public async Task A_token_needs_a_name_and_a_known_scope(string name, string scope) { var response = await _http.PostAsJsonAsync(McpAccessTokenEndpoints.Route, new { name, scope }); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); (await Contexts().CreateDbContext().McpAccessTokens.CountAsync()).Should().Be(0); } [Fact] public async Task The_capabilities_offer_connected_apps_on_SelfHosted() { var capabilities = await _http.GetFromJsonAsync("/api/runtime/capabilities"); capabilities.GetProperty("supportsMcpConnectedApps").GetBoolean().Should().BeTrue(); } [Fact] public async Task Mcp_refuses_a_call_without_a_token_with_a_wrong_one_and_with_a_malformed_one() { await CreateTokenAsync("real", "read"); var none = await ListToolsRawAsync(null); var wrong = await ListToolsRawAsync("nst_" + new string('A', 43)); var malformed = await ListToolsRawAsync("not-a-token"); var basic = await _http.SendAsync(new HttpRequestMessage(HttpMethod.Post, NostosMcp.Path) { Headers = { Authorization = new AuthenticationHeaderValue("Basic", "dXNlcjpwYXNz") }, Content = JsonContent.Create(new { jsonrpc = "2.0", id = 1, method = "tools/list" }), }); none.StatusCode.Should().Be(HttpStatusCode.Unauthorized); none.Headers.WwwAuthenticate.ToString().Should().StartWith("Bearer"); wrong.StatusCode.Should().Be(HttpStatusCode.Unauthorized); wrong.Headers.WwwAuthenticate.ToString().Should().Contain("invalid_token"); malformed.StatusCode.Should().Be(HttpStatusCode.Unauthorized); basic.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task A_write_token_searches_and_saves_a_note_and_revoking_it_refuses_the_next_call() { var bookId = await CreateBookAsync("Moby-Dick"); var (id, token) = await CreateTokenAsync("Claude Code", "read_write"); await using var mcp = await ConnectAsync(token); var search = await mcp.CallToolAsync("search", new Dictionary { ["query"] = "whale" }); var save = await mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = bookId, ["thought"] = "The whiteness of the whale is the point.", }); search.IsError.Should().NotBe(true, Text(search)); save.IsError.Should().NotBe(true, Text(save)); await using (var db = await Contexts().CreateDbContextAsync()) { (await db.Notes.CountAsync(n => n.BookId == bookId)).Should().Be(1); (await db.McpAccessTokens.SingleAsync()).LastUsedAtUtc.Should().NotBeNull(); } (await _http.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/{id}")).EnsureSuccessStatusCode(); (await ListToolsRawAsync(token)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Theory] [InlineData("save_note")] [InlineData("save_passage")] [InlineData("create_draft")] [InlineData("add_to_reading_list")] [InlineData("add_book")] [InlineData("update_book")] public async Task A_read_only_token_is_refused_every_write_tool_and_nothing_is_written(string tool) { var bookId = await CreateBookAsync("Walden"); var (_, token) = await CreateTokenAsync("Cursor", "read"); await using var mcp = await ConnectAsync(token); var before = await CountsAsync(); var result = await mcp.CallToolAsync(tool, new Dictionary { ["bookId"] = bookId, ["thought"] = "Simplify.", ["title"] = "A draft", ["markdown"] = "Some words.", ["quoteRef"] = "q1-00000000000000000000000000000000-abc-0-0-0-1", }); var read = await mcp.CallToolAsync("list_books", new Dictionary()); result.IsError.Should().BeTrue(); Text(result).Should().Be(McpScopes.ReadOnlyRefusal); read.IsError.Should().NotBe(true, Text(read)); (await CountsAsync()).Should().Be(before); } [Fact] public async Task The_token_never_reaches_a_log_line_or_a_later_response() { var bookId = await CreateBookAsync("Middlemarch"); var (id, token) = await CreateTokenAsync("Hermes", "read_write"); await using (var mcp = await ConnectAsync(token)) { await mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = bookId, ["thought"] = "Dorothea's choice.", }); } var wrong = token[..^2] + (token[^1] == 'A' ? "BB" : "AA"); await ListToolsRawAsync(wrong); var list = await _http.GetStringAsync(McpConnectedAppsEndpoints.Route); var capabilities = await _http.GetStringAsync("/api/runtime/capabilities"); await _http.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/{id}"); await ListToolsRawAsync(token); _factory.Logs.Lines.Should().NotBeEmpty(); var secret = token[4..]; foreach (var line in _factory.Logs.Lines) { line.Should().NotContain(secret); line.Should().NotContain(wrong[4..]); line.Should().NotContain(McpAccessTokenService.Hash(token)); } list.Should().NotContain(secret); capabilities.Should().NotContain(secret); } [Fact] public async Task Failed_attempts_are_rate_limited_and_a_good_token_does_not_count() { var (_, token) = await CreateTokenAsync("real", "read"); for (var i = 0; i < 3; i++) (await ListToolsRawAsync(token)).StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); for (var i = 0; i < McpAccessTokenFailedAttemptThrottle.MaxFailures; i++) (await ListToolsRawAsync("nst_" + new string('B', 43))).StatusCode.Should().Be(HttpStatusCode.Unauthorized); var blocked = await ListToolsRawAsync("nst_" + new string('C', 43)); var blockedGood = await ListToolsRawAsync(token); blocked.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); blocked.Headers.RetryAfter.Should().NotBeNull(); blockedGood.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); } [Fact] public void The_failed_attempt_window_ends() { var clock = new ManualClock(DateTimeOffset.Parse("2026-11-07T10:00:00Z")); var throttle = new McpAccessTokenFailedAttemptThrottle(clock); for (var i = 0; i < McpAccessTokenFailedAttemptThrottle.MaxFailures; i++) throttle.RecordFailure("10.0.0.2"); throttle.RetryAfter("10.0.0.2").Should().NotBeNull(); throttle.RetryAfter("10.0.0.3").Should().BeNull(); clock.Now += McpAccessTokenFailedAttemptThrottle.Window; throttle.RetryAfter("10.0.0.2").Should().BeNull(); } [Fact] public async Task Last_used_is_written_at_most_once_a_minute() { var clock = new ManualClock(DateTimeOffset.Parse("2026-11-07T10:00:00Z")); var service = new McpAccessTokenService(Contexts(), clock); var created = await service.CreateAsync("Agent", "read"); (await service.AuthenticateAsync(created.Token)).Should().NotBeNull(); var first = await LastUsedAsync(); clock.Now += TimeSpan.FromSeconds(30); await service.AuthenticateAsync(created.Token); var unchanged = await LastUsedAsync(); clock.Now += TimeSpan.FromSeconds(31); await service.AuthenticateAsync(created.Token); var moved = await LastUsedAsync(); first.Should().Be(new DateTime(2026, 11, 7, 10, 0, 0, DateTimeKind.Utc)); unchanged.Should().Be(first); moved.Should().Be(first!.Value.AddSeconds(61)); (await service.AuthenticateAsync(created.Token.ToUpperInvariant())).Should().BeNull(); (await service.AuthenticateAsync(created.Token + "x")).Should().BeNull(); } // ------------------------------------------------------------------ private async Task<(string Id, string Token)> CreateTokenAsync(string name, string scope) { var response = await _http.PostAsJsonAsync(McpAccessTokenEndpoints.Route, new { name, scope }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); return (body.GetProperty("details").GetProperty("id").GetString()!, body.GetProperty("token").GetString()!); } private async Task ConnectAsync(string token) { var authed = _factory.CreateClient(); authed.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return await McpClient.CreateAsync( new HttpClientTransport( new HttpClientTransportOptions { Endpoint = new Uri(authed.BaseAddress!, NostosMcp.Path), TransportMode = HttpTransportMode.StreamableHttp, }, authed, ownsHttpClient: true), new McpClientOptions { InitializationTimeout = TimeSpan.FromMinutes(3), DiscoverProbeTimeout = TimeSpan.FromMinutes(1), }); } private Task ListToolsRawAsync(string? token) { var request = new HttpRequestMessage(HttpMethod.Post, NostosMcp.Path) { Content = new StringContent( """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""", Encoding.UTF8, "application/json"), }; request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("text/event-stream"); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return _http.SendAsync(request); } private async Task CreateBookAsync(string title) { var response = await _http.PostAsJsonAsync("/api/books", new { type = "physical", title, author = "Someone" }); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); } private async Task<(int Notes, int Writings, int ReadingList)> CountsAsync() { await using var db = await Contexts().CreateDbContextAsync(); return (await db.Notes.CountAsync(), await db.Writings.CountAsync(), await db.ReadingListEntries.CountAsync()); } private async Task LastUsedAsync() { await using var db = await Contexts().CreateDbContextAsync(); return (await db.McpAccessTokens.AsNoTracking().SingleAsync()).LastUsedAtUtc; } private IDbContextFactory Contexts() => _factory.Services.GetRequiredService>(); private static string Text(CallToolResult result) => string.Concat(result.Content.OfType().Select(c => c.Text)); private sealed class ManualClock(DateTimeOffset now) : TimeProvider { public DateTimeOffset Now { get; set; } = now; public override DateTimeOffset GetUtcNow() => Now; } }