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