using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Nostos.Backend.Data; using Nostos.Backend.Integrations.Mcp; using Nostos.Backend.Tests.Support; using Nostos.Shared.Dtos; using Xunit; namespace Nostos.Backend.Tests.Mcp; /// /// A SelfHosted host with MCP switched on the way a deployment does it: an /// authentication scheme and the /// policy. The scheme here accepts one fixed bearer token. /// public sealed class McpEndpointFactory : LibraryEndpointFactory { public const string Token = "test-mcp-token"; /// A token whose caller was granted nostos:read only. public const string ReadOnlyToken = "test-mcp-read-only-token"; private const string SchemeName = "TestMcp"; protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.ConfigureServices(services => { services.AddAuthentication() .AddScheme(SchemeName, _ => { }); services.AddSingleton(); services.AddAuthorization(options => options.AddPolicy( NostosMcp.AuthorizationPolicy, policy => policy.AddAuthenticationSchemes(SchemeName).RequireAuthenticatedUser())); }); } /// Stands in for a host gate (Cloud's revoked OAuth clients): refuses a marked request. public const string RefuseHeader = "X-Test-Refuse"; private sealed class RefuseOnHeaderGate : IMcpRequestGate { public ValueTask AllowAsync(ClaimsPrincipal user, HttpContext http, CancellationToken cancellationToken) => ValueTask.FromResult(user.Identity?.IsAuthenticated == true && !http.Request.Headers.ContainsKey(RefuseHeader)); } private sealed class TokenHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { var header = Request.Headers.Authorization.ToString(); var scope = header == $"Bearer {Token}" ? $"{NostosMcp.ReadScope} {NostosMcp.WriteScope}" : header == $"Bearer {ReadOnlyToken}" ? NostosMcp.ReadScope : null; if (scope is null) return Task.FromResult(AuthenticateResult.NoResult()); var identity = new ClaimsIdentity( [ new Claim(ClaimTypes.Name, "agent"), new Claim(NostosMcp.ScopeClaimType, scope), ], SchemeName); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName))); } } } public sealed class McpHttpTests : IAsyncLifetime { private readonly McpEndpointFactory _factory = new(); private HttpClient _http = null!; private McpClient _mcp = null!; public async Task InitializeAsync() { _http = _factory.CreateClient(); // Start the host before the handshake. In a full parallel test run the // machine is busy, so the client also waits longer than a real one. (await _http.GetAsync("/health/live")).EnsureSuccessStatusCode(); var authed = _factory.CreateClient(); authed.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", McpEndpointFactory.Token); _mcp = 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), }); } public async Task DisposeAsync() { await _mcp.DisposeAsync(); _http.Dispose(); await _factory.DisposeAsync(); } [Fact] public async Task Without_a_token_the_endpoint_refuses_the_call() { var response = await _http.PostAsJsonAsync(NostosMcp.Path, new { jsonrpc = "2.0", id = 1, method = "tools/list" }); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public void A_host_that_registers_no_policy_never_maps_mcp() { var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateSlimBuilder(); builder.Services.AddNostosMcp(); builder.Services.AddAuthorization(); using var app = builder.Build(); app.MapNostosMcp().Should().BeFalse(); } [Fact] public async Task A_host_gate_refuses_an_authenticated_request_before_the_server_sees_it() { var request = new HttpRequestMessage(HttpMethod.Post, NostosMcp.Path) { Content = JsonContent.Create(new { jsonrpc = "2.0", id = 1, method = "tools/list" }), }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", McpEndpointFactory.Token); request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("text/event-stream"); request.Headers.Add(McpEndpointFactory.RefuseHeader, "1"); var refused = await _http.SendAsync(request); var tools = await _mcp.ListToolsAsync(); refused.StatusCode.Should().Be(HttpStatusCode.Unauthorized); refused.Headers.WwwAuthenticate.ToString().Should().Contain("invalid_token"); tools.Should().NotBeEmpty(); } [Fact] public async Task The_app_shell_is_never_served_on_mcp() { var get = await _http.GetAsync(NostosMcp.Path); get.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.NotFound, HttpStatusCode.MethodNotAllowed); (await get.Content.ReadAsStringAsync()).Should().NotContain(" t.Name).Should().BeEquivalentTo( "search", "fetch", "find_quote", "overview", "list_books", "get_book", "list_notes", "list_topics", "get_topic", "list_drafts", "read_draft", "reading_list", "save_note", "save_passage", "create_draft", "add_to_reading_list", "add_book", "update_book"); string[] writes = ["save_note", "save_passage", "create_draft", "add_to_reading_list", "add_book", "update_book"]; foreach (var tool in tools.Where(t => !writes.Contains(t.Name))) tool.ProtocolTool.Annotations!.ReadOnlyHint.Should().BeTrue(tool.Name); foreach (var tool in tools.Where(t => writes.Contains(t.Name))) { tool.ProtocolTool.Annotations!.ReadOnlyHint.Should().BeFalse(tool.Name); // update_book can replace a detail (with replace: true), so it says so. tool.ProtocolTool.Annotations!.DestructiveHint.Should().Be(tool.Name == "update_book", tool.Name); } _mcp.ServerInstructions.Should().Contain("Quote only words a Nostos tool returned"); } [Fact] public async Task Search_finds_a_kept_quote_and_cites_it_in_the_Studios_own_format() { var bookId = await CreateBookAsync("Moby-Dick", "Herman Melville", "1851"); var noteId = await CreateNoteAsync(bookId, new CreateNoteDto( Content: "Ishmael's restlessness", SelectedText: "Call me Ishmael.", SourceAnchorKind: "physical_page", SourceAnchorValue: "3")); var result = await CallAsync("search", new() { ["query"] = "Ishmael" }); var hit = result.GetProperty("results").EnumerateArray() .Single(r => r.GetProperty("id").GetString() == $"note:{noteId:D}"); hit.GetProperty("kind").GetString().Should().Be("note"); hit.GetProperty("url").GetString().Should().EndWith($"/brain/notes/{noteId:D}").And.StartWith("http"); var citation = hit.GetProperty("citation"); citation.GetProperty("book").GetString().Should().Be("Moby-Dick"); citation.GetProperty("author").GetString().Should().Be("Herman Melville"); citation.GetProperty("year").GetString().Should().Be("1851"); citation.GetProperty("location").GetString().Should().Be("p. 3"); citation.GetProperty("link").GetString().Should().Be($"nostos:cite/{bookId:D}?p=3&as=quote&src={noteId:D}"); } [Fact] public async Task Fetch_reads_a_search_result_in_full() { var bookId = await CreateBookAsync("Walden", "Henry David Thoreau", "1854"); var noteId = await CreateNoteAsync(bookId, new CreateNoteDto( Content: "Simplicity as a discipline, not a mood.", SelectedText: "Simplify, simplify.")); var document = await CallAsync("fetch", new() { ["id"] = $"note:{noteId:D}" }); document.GetProperty("kind").GetString().Should().Be("note"); document.GetProperty("text").GetString().Should().Contain("Simplify, simplify.").And.Contain("Simplicity as a discipline"); document.GetProperty("citation").GetProperty("markdown").GetString() .Should().Be($"[Simplify, simplify.](nostos:cite/{bookId:D}?as=quote&src={noteId:D})"); } [Fact] public async Task Books_are_found_by_title_and_read_with_their_year() { var bookId = await CreateBookAsync("The Rings of Saturn", "W. G. Sebald", "1995-01-01"); var list = await CallAsync("list_books", new() { ["search"] = "Saturn" }); var book = await CallAsync("get_book", new() { ["bookId"] = bookId }); list.GetProperty("books").EnumerateArray().Select(b => b.GetProperty("id").GetGuid()).Should().Contain(bookId); book.GetProperty("year").GetString().Should().Be("1995"); book.GetProperty("citation").GetProperty("link").GetString().Should().Be($"nostos:cite/{bookId:D}?as=reference"); } [Fact] public async Task A_books_notes_come_in_page_order() { var bookId = await CreateBookAsync("Middlemarch", "George Eliot", "1871"); var later = await CreateNoteAsync(bookId, new CreateNoteDto("Later", SourceAnchorKind: "physical_page", SourceAnchorValue: "200")); var earlier = await CreateNoteAsync(bookId, new CreateNoteDto("Earlier", SourceAnchorKind: "physical_page", SourceAnchorValue: "12")); var notes = await CallAsync("list_notes", new() { ["bookId"] = bookId }); notes.GetProperty("notes").EnumerateArray().Select(n => n.GetProperty("id").GetString()) .Should().ContainInOrder($"note:{earlier:D}", $"note:{later:D}"); notes.GetProperty("total").GetInt32().Should().Be(2); } [Fact] public async Task Save_note_keeps_one_note_for_a_resent_call_marks_it_as_mcp_and_moves_the_change_feed() { var bookId = await CreateBookAsync("Pilgrim at Tinker Creek", "Annie Dillard", "1974"); var before = await StateVersionAsync(); var args = new Dictionary { ["bookId"] = bookId, ["thought"] = "Seeing is a kind of work.", ["quote"] = "The secret of seeing is, then, the pearl of great price.", }; var first = await CallAsync("save_note", args); var again = await CallAsync("save_note", args); again.GetProperty("id").GetString().Should().Be(first.GetProperty("id").GetString()); await using var db = await Contexts().CreateDbContextAsync(); var saved = await db.Notes.Where(n => n.BookId == bookId).ToListAsync(); saved.Should().ContainSingle(); saved[0].CaptureSource.Should().Be(NostosMcp.CaptureSource); saved[0].SelectedText.Should().Be("The secret of seeing is, then, the pearl of great price."); saved[0].Content.Should().Be("Seeing is a kind of work."); (await StateVersionAsync()).Should().NotBe(before); first.GetProperty("citation").GetProperty("link").GetString() .Should().Be($"nostos:cite/{bookId:D}?as=quote&src={saved[0].Id:D}"); } [Fact] public async Task Topics_are_listed_by_use_found_by_their_notes_and_read_through_fetch() { var bookId = await CreateBookAsync("Moby-Dick", "Herman Melville", "1851"); var noteId = await CreateNoteAsync(bookId, new CreateNoteDto("The whiteness of the whale terrifies.", Topics: ["Whiteness"])); var listed = await CallAsync("list_topics", new()); var found = await CallAsync("list_topics", new() { ["search"] = "terrifies" }); var topicId = listed.GetProperty("topics").EnumerateArray().Single().GetProperty("id").GetString()!; var topic = await CallAsync("fetch", new() { ["id"] = topicId }); listed.GetProperty("topics")[0].GetProperty("name").GetString().Should().Be("Whiteness"); found.GetProperty("topics").EnumerateArray().Select(t => t.GetProperty("id").GetString()).Should().Contain(topicId); topic.GetProperty("kind").GetString().Should().Be("topic"); topic.GetProperty("text").GetString().Should().Contain($"note:{noteId:D}"); } [Fact] public async Task The_overview_counts_the_brain_and_lists_the_library() { var bookId = await CreateBookAsync("Moby-Dick", "Herman Melville", "1851"); await CreateNoteAsync(bookId, new CreateNoteDto("One")); await CreateNoteAsync(bookId, new CreateNoteDto("Two")); var overview = await CallAsync("overview", new()); overview.GetProperty("brain").GetProperty("notes").GetInt32().Should().Be(2); overview.GetProperty("brain").GetProperty("booksWithMostNotes")[0].GetProperty("id").GetString().Should().Be(bookId.ToString("D")); overview.GetProperty("library").GetRawText().Should().Contain("Moby-Dick"); // Editions vs books once per work: say so, or a model reports a bug (#1486). overview.GetProperty("library").GetProperty("countsNote").GetString() .Should().Contain("every edition").And.Contain("each book once"); } [Fact] public async Task A_quote_saved_over_mcp_can_be_found_and_drawn_in_the_reader_like_one_Ask_Nostos_saved() { var bookId = await CreateBookAsync("Moby-Dick", "Herman Melville", "1851"); await CallAsync("save_note", new() { ["bookId"] = bookId, ["quote"] = "Call me Ishmael." }); await using var db = await Contexts().CreateDbContextAsync(); var saved = await db.Notes.SingleAsync(n => n.BookId == bookId); Nostos.Backend.Services.Notes.FoundNoteLocation.IsAskNostosQuote(saved).Should().BeTrue(); } [Fact] public async Task A_topic_comes_with_its_own_words_and_cited_notes() { var bookId = await CreateBookAsync("Moby-Dick", "Herman Melville", "1851"); var noteId = await CreateNoteAsync(bookId, new CreateNoteDto( "Ahab's obsession", SelectedText: "He piled upon the whale's white hump the sum of all the general rage.", SourceAnchorKind: "physical_page", SourceAnchorValue: "184", Topics: ["Obsession"])); var topicId = (await CallAsync("list_topics", new())).GetProperty("topics")[0].GetProperty("id").GetString()!; var topic = await CallAsync("get_topic", new() { ["topicId"] = topicId }); topic.GetProperty("name").GetString().Should().Be("Obsession"); var note = topic.GetProperty("linkedNotes").EnumerateArray().Single(); note.GetProperty("id").GetString().Should().Be($"note:{noteId:D}"); note.GetProperty("citation").GetProperty("link").GetString().Should().Be($"nostos:cite/{bookId:D}?p=184&as=quote&src={noteId:D}"); } [Fact] public async Task A_draft_written_with_citation_links_is_one_Studio_document_with_live_citations_and_a_resend_keeps_one() { var bookId = await CreateBookAsync("Walden", "Henry David Thoreau", "1854"); var noteId = await CreateNoteAsync(bookId, new CreateNoteDto("", SelectedText: "Simplify, simplify.")); var quote = (await CallAsync("fetch", new() { ["id"] = $"note:{noteId:D}" })) .GetProperty("citation").GetProperty("markdown").GetString(); var args = new Dictionary { ["name"] = "On simplicity", ["content"] = $"# On simplicity\n\nThoreau's advice is short: {quote}\n", }; var first = await CallAsync("create_draft", args); var again = await CallAsync("create_draft", args); var drafts = await CallAsync("list_drafts", new() { ["query"] = "simplicity" }); var read = await CallAsync("read_draft", new() { ["writingId"] = first.GetProperty("id").GetGuid() }); again.GetProperty("id").GetGuid().Should().Be(first.GetProperty("id").GetGuid()); again.GetProperty("duplicate").GetBoolean().Should().BeTrue(); first.GetProperty("citations").GetInt32().Should().Be(1); first.GetProperty("link").GetString().Should().StartWith("http").And.Contain("/studio"); drafts.GetProperty("items").EnumerateArray().Should().ContainSingle(); read.GetProperty("content").GetString().Should().Contain($"src={noteId:D}"); await using var db = await Contexts().CreateDbContextAsync(); (await db.Set().CountAsync(c => c.NoteId == noteId)).Should().Be(1); } [Fact] public async Task A_book_goes_on_To_read_once() { var args = new Dictionary { ["title"] = "Austerlitz", ["author"] = "W. G. Sebald", ["readNext"] = true }; var first = await CallAsync("add_to_reading_list", args); var again = await CallAsync("add_to_reading_list", args); var list = await CallAsync("reading_list", new()); first.GetProperty("duplicate").GetBoolean().Should().BeFalse(); again.GetProperty("duplicate").GetBoolean().Should().BeTrue(); list.GetProperty("total").GetInt32().Should().Be(1); list.GetProperty("link").GetString().Should().StartWith("http").And.EndWith("/library/to-read"); } [Fact] public async Task An_app_adds_a_paper_book_and_edits_its_title_author_and_description_and_the_app_shows_it() { var added = await CallAsync("add_book", new() { ["title"] = "The Rings of Saturn", ["author"] = "W. G. Sebald", ["publishedDate"] = "1995", }); added.GetProperty("outcome").GetString().Should().Be("created"); added.GetProperty("duplicate").GetBoolean().Should().BeFalse(); var book = added.GetProperty("book"); var bookId = book.GetProperty("id").GetGuid(); book.GetProperty("type").GetString().Should().Be("physical"); book.GetProperty("url").GetString().Should().StartWith("http").And.EndWith($"/library/{bookId:D}"); // Without replace, a detail the book has is kept; an empty one is filled. var filled = await CallAsync("update_book", new() { ["bookId"] = bookId, ["title"] = "Die Ringe des Saturn", ["description"] = "A walk along the Suffolk coast.", }); filled.GetProperty("changed").EnumerateArray().Select(c => c.GetProperty("field").GetString()).Should().Equal("description"); filled.GetProperty("kept")[0].GetProperty("field").GetString().Should().Be("title"); filled.GetProperty("kept")[0].GetProperty("value").GetString().Should().Be("The Rings of Saturn"); var replaced = await CallAsync("update_book", new() { ["bookId"] = bookId, ["title"] = "The Rings of Saturn: An English Pilgrimage", ["author"] = "W.G. Sebald", ["description"] = "A walk along the Suffolk coast, and what it recalls.", ["replace"] = true, }); var changes = replaced.GetProperty("changed").EnumerateArray() .ToDictionary(c => c.GetProperty("field").GetString()!, c => c.GetProperty("from").GetString()); changes.Should().BeEquivalentTo(new Dictionary { ["title"] = "The Rings of Saturn", ["author"] = "W. G. Sebald", ["description"] = "A walk along the Suffolk coast.", }); replaced.GetProperty("undo").GetProperty("replace").GetBoolean().Should().BeTrue(); // The app reads the same book through its own API. var shown = await _http.GetFromJsonAsync($"/api/books/{bookId}"); shown!.Title.Should().Be("The Rings of Saturn: An English Pilgrimage"); shown.Author.Should().Be("W.G. Sebald"); shown.Description.Should().Be("A walk along the Suffolk coast, and what it recalls."); shown.PublishedDate.Should().Be("1995"); // undo puts every old value back. var undo = replaced.GetProperty("undo").EnumerateObject().ToDictionary(p => p.Name, p => (object?)JsonSerializer.Deserialize(p.Value.GetRawText())); await CallAsync("update_book", undo); var restored = await _http.GetFromJsonAsync($"/api/books/{bookId}"); restored!.Title.Should().Be("The Rings of Saturn"); restored.Author.Should().Be("W. G. Sebald"); restored.Description.Should().Be("A walk along the Suffolk coast."); } [Fact] public async Task Adding_a_book_that_is_on_To_read_moves_it_and_keeps_the_reason_on_the_book() { await CallAsync("add_to_reading_list", new() { ["title"] = "Vertigo", ["author"] = "W. G. Sebald", ["why"] = "Mara said it is the strangest of the four.", }); var added = await CallAsync("add_book", new() { ["title"] = "Vertigo", ["author"] = "W. G. Sebald" }); var resent = await CallAsync("add_book", new() { ["title"] = "Vertigo", ["author"] = "W. G. Sebald" }); var again = await CallAsync("add_book", new() { ["title"] = "Vertigo", ["author"] = "W. G. Sebald", ["publishedDate"] = "1990" }); var bookId = added.GetProperty("book").GetProperty("id").GetGuid(); added.GetProperty("movedFromToRead").EnumerateArray().Should().ContainSingle() .Which.GetProperty("reasonKeptAsNote").GetBoolean().Should().BeTrue(); resent.GetProperty("book").GetProperty("id").GetGuid().Should().Be(bookId, "a resend returns the first answer"); again.GetProperty("outcome").GetString().Should().Be("matched"); again.GetProperty("duplicate").GetBoolean().Should().BeTrue(); again.GetProperty("book").GetProperty("id").GetGuid().Should().Be(bookId); again.TryGetProperty("movedFromToRead", out _).Should().BeFalse(); (await CallAsync("reading_list", new())).GetProperty("total").GetInt32().Should().Be(0); await using var db = await Contexts().CreateDbContextAsync(); (await db.Books.CountAsync()).Should().Be(1); (await db.Notes.SingleAsync(n => n.BookId == bookId)).Content.Should().Contain("Mara said it is the strangest of the four."); } [Fact] public async Task A_title_alone_that_may_be_another_book_adds_nothing_and_offers_the_candidates() { var existing = await CreateBookAsync("Austerlitz", "W. G. Sebald", "2001"); var before = await StateVersionAsync(); var asked = await CallAsync("add_book", new() { ["title"] = "Austerlitz" }); asked.GetProperty("outcome").GetString().Should().Be("choose"); asked.GetProperty("candidates").EnumerateArray().Select(c => c.GetProperty("id").GetGuid()).Should().Equal(existing); (await StateVersionAsync()).Should().Be(before); var chosen = await CallAsync("add_book", new() { ["title"] = "Austerlitz", ["confirmedBookId"] = existing }); var separate = await CallAsync("add_book", new() { ["title"] = "Austerlitz", ["forceCreate"] = true }); chosen.GetProperty("duplicate").GetBoolean().Should().BeTrue(); chosen.GetProperty("book").GetProperty("id").GetGuid().Should().Be(existing); separate.GetProperty("outcome").GetString().Should().Be("created"); separate.GetProperty("book").GetProperty("id").GetGuid().Should().NotBe(existing); } [Fact] public async Task Tool_errors_come_back_as_errors_the_model_can_read() { var missingBook = await _mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = Guid.NewGuid(), ["thought"] = "A thought for no book.", }); var badId = await _mcp.CallToolAsync("fetch", new Dictionary { ["id"] = "nonsense" }); var empty = await _mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = Guid.NewGuid() }); missingBook.IsError.Should().BeTrue(); badId.IsError.Should().BeTrue(); Text(badId).Should().Contain("not a Nostos result id"); empty.IsError.Should().BeTrue(); Text(empty).Should().Contain("thought"); } // ------------------------------------------------------------------ private async Task CallAsync(string tool, Dictionary args) { var result = await _mcp.CallToolAsync(tool, args); result.IsError.Should().NotBe(true, Text(result)); return JsonDocument.Parse(Text(result)).RootElement.Clone(); } private static string Text(CallToolResult result) => string.Concat(result.Content.OfType().Select(c => c.Text)); private async Task CreateBookAsync(string title, string author, string publishedDate) { var response = await _http.PostAsJsonAsync("/api/books", new { type = "physical", title, author, publishedDate }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); return body.GetProperty("id").GetGuid(); } private async Task CreateNoteAsync(Guid bookId, CreateNoteDto note) { var response = await _http.PostAsJsonAsync($"/api/books/{bookId}/notes", note); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!.Id; } private IDbContextFactory Contexts() => _factory.Services.GetRequiredService>(); private async Task StateVersionAsync() { await using var db = await Contexts().CreateDbContextAsync(); var state = await db.Set().AsNoTracking().SingleOrDefaultAsync(); return state?.StateVersion ?? string.Empty; } }