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;
}
}