diff --git a/CHANGELOG.md b/CHANGELOG.md index 15505e23..d2b854c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Phase 8 — `save_highlight` MCP write tool (AI-048b) (2026-06-16) + +The first WRITE tool on the MCP↔HTTP bridge, completing the 7-tool surface (the 6 reads + `save_highlight`). Now safe to ship because AI-050b gives a per-user consented token, so the write runs on the user's OWN identity, not a shared static secret. All in `backend/src/Ai/TextStack.Ai.Mcp/` + unit tests — **no backend change**. + +- **`save_highlight` tool** (`Tools/McpToolCatalog.cs`). Args (tight schema, `additionalProperties:false`): `editionId` (uuid, required), `chapterId` (uuid, required), `selectedText` (string 1..5000, required), optional `color` (enum `yellow|green|blue|pink` — matched to the web reader's palette per QA, default `yellow`) and `noteText` (≤2000). Authorized via the now-`DeviceFlowTokenProvider` Bearer; validation + the shared `InvokeAsync` upstream-error wrapper mirror the AI-048a read tools. Honest description: it is a WRITE on the user's own account and requires sign-in. +- **Anchor approach (b) — synthesized text-quote, no backend change.** The API's `POST /me/highlights` stores `AnchorJson` opaquely in a **non-null `jsonb`** column; the web/mobile reader builds it as `JSON.stringify(TextAnchor)` (`{ prefix, exact, suffix, startOffset, endOffset, chapterId }`) and re-anchors via `findTextByAnchor` (matches on `exact` first, offset fallback). An MCP client has **no DOM**, so the bridge synthesizes a minimal W3C text-quote anchor server-side from the agent's args — `exact = selectedText`, empty prefix/suffix, quote-relative offsets, `chapterId`, `source:"mcp"` — satisfying the jsonb column and letting the web reader best-effort re-anchor. **Limitation**: a highlight saved via MCP always saves and is retrievable via `list_my_highlights`; it visually anchors in the web reader when its `selectedText` appears (typically once) in the chapter, but without real DOM offsets it may not pin precisely on ambiguous/duplicate text. The endpoint hard-requires a non-null anchor, so this synthesis (not a backend nullable change) is the correct, backward-compatible path — the web/mobile clients still send their full anchor. +- **Client method** (`Http/TextStackApiClient.cs`). `SaveHighlightAsync(...)` → authorized `POST /me/highlights` with `{ editionId, chapterId, anchorJson, color, selectedText, noteText }`; accepts 201 (or 200) → maps the created `HighlightDto` to a success text confirming the new id; 401 → `McpUnauthorizedException` (auth-required path); other non-success → clean tool error; transport/parse → shared wrapper. The write is **never issued** when unauthenticated (Pending/Failed token throws before the HTTP call). +- **Tests** (`tests/TextStack.UnitTests/McpSaveHighlightTests.cs`, fake `HttpMessageHandler`, CI-safe): `tools/list` now exposes **7** tools with the tight schema; happy path asserts the authorized POST shape + Bearer + the synthesized anchor body + 201 → success id; color/note defaults + null-note omission; 200-also-accepted; arg validation (missing required, bad guids, empty/oversized text, oversized note, bad color enum, extra prop) → clean `IsError`, never hits HTTP; unauthenticated (null token) → auth-required, **no write issued** (`LastRequest` null); API 401 → auth-required; transport/timeout/parse → clean error; genuine cancellation propagates. No `ITool` added (StudyBuddy set-equality stays green). + ### Phase 8 — MCP device-flow token provider (AI-050b) (2026-06-16) CLI-side completion of the device flow built in AI-050a: the headless MCP bridge now obtains a per-user TextStack JWT on its own — no `TEXTSTACK_MCP_TOKEN` needed. All in `backend/src/Ai/TextStack.Ai.Mcp/` + its unit tests. diff --git a/backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs b/backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs index b214df2f..5e9fa9b5 100644 --- a/backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs +++ b/backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs @@ -159,6 +159,37 @@ public async Task GetVocabularyAsync( return EmptyVocab; } + // ── save_highlight (Bearer, WRITE) ─────────────────────────────────────────── + + /// + /// POST /me/highlights with { editionId, chapterId, anchorJson, color, + /// selectedText, noteText } — the first WRITE tool. An MCP client has no DOM, + /// so it cannot produce a real reader anchor; the caller synthesizes a minimal + /// W3C text-quote (exact = selectedText) so the + /// jsonb column is satisfied and the web reader can best-effort re-anchor. + /// 401 → (write NEVER issued without a + /// usable token, enforced in ); other + /// non-success (2xx-but-not-201, 4xx) → null (handler maps to a clean error). + /// + public async Task SaveHighlightAsync( + Guid editionId, Guid chapterId, string anchorJson, string color, string selectedText, string? noteText, CancellationToken ct) + { + using var request = await AuthorizedRequestAsync(HttpMethod.Post, "/me/highlights", ct); + request.Content = JsonContent.Create( + new CreateHighlightJson(editionId, chapterId, anchorJson, color, selectedText, noteText), + options: JsonOptions); + + using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + + if (response.StatusCode is HttpStatusCode.Unauthorized) + throw new McpUnauthorizedException(); + + if (response.StatusCode is HttpStatusCode.Created or HttpStatusCode.OK) + return await response.Content.ReadFromJsonAsync(JsonOptions, ct); + + return null; + } + // ── ask_book (Bearer) ──────────────────────────────────────────────────────── /// @@ -306,6 +337,7 @@ public sealed record ChapterJson( public sealed record ChapterNavJson(string? Slug, string Title); // GET /me/highlights/{editionId} → HighlightDto[] (subset). +// Also the POST /me/highlights response body (Created → HighlightDto). public sealed record HighlightJson( Guid Id, Guid? ChapterId, @@ -314,6 +346,17 @@ public sealed record HighlightJson( string? NoteText, DateTimeOffset CreatedAt); +// POST /me/highlights request → CreateHighlightRequest (edition-scoped subset). +// AnchorJson is a JSON string stored opaquely in the API's jsonb column; the MCP +// bridge sends a synthesized W3C text-quote anchor (no DOM available client-side). +public sealed record CreateHighlightJson( + Guid EditionId, + Guid ChapterId, + string AnchorJson, + string Color, + string SelectedText, + string? NoteText); + // GET /me/vocabulary/words → { total, items: VocabWordDto[] } (subset of items). public sealed record VocabularyPageJson(int Total, IReadOnlyList Items); diff --git a/backend/src/Ai/TextStack.Ai.Mcp/Tools/McpToolCatalog.cs b/backend/src/Ai/TextStack.Ai.Mcp/Tools/McpToolCatalog.cs index d03034b7..59c98679 100644 --- a/backend/src/Ai/TextStack.Ai.Mcp/Tools/McpToolCatalog.cs +++ b/backend/src/Ai/TextStack.Ai.Mcp/Tools/McpToolCatalog.cs @@ -6,10 +6,11 @@ namespace TextStack.Ai.Mcp.Tools; /// /// The runtime catalog of MCP tools the server exposes. AI-047 shipped -/// search_books; AI-048a appends 5 READ tools (get_book, +/// search_books; AI-048a appended 5 READ tools (get_book, /// get_chapter, list_my_highlights, list_my_vocabulary, -/// ask_book). tools/list and tools/call are served from this -/// list, so adding a tool is a single append + its handler. +/// ask_book); AI-048b appends the first WRITE tool (save_highlight), +/// completing the 7-tool surface. tools/list and tools/call are served +/// from this list, so adding a tool is a single append + its handler. /// /// Tool handlers contain only validation + mapping. Upstream timeout / transport /// / parse faults are handled centrally by so every tool @@ -35,6 +36,7 @@ public McpToolCatalog(TextStackApiClient api) BuildListMyHighlights(api), BuildListMyVocabulary(api), BuildAskBook(api), + BuildSaveHighlight(api), }; _byName = tools.ToDictionary(t => t.Name, StringComparer.Ordinal); } @@ -401,6 +403,109 @@ private static async Task InvokeAsync( }, }; + // ── save_highlight (Bearer, WRITE) ─────────────────────────────────────────── + + // The agent-providable fields only. No DOM-anchor blob is accepted — Claude + // Desktop has no reader DOM, so the bridge synthesizes a minimal text-quote + // anchor server-side (see SynthesizeAnchor). color is a small enum (reader's + // palette) defaulting to "yellow". + private static readonly JsonElement SaveHighlightSchema = JsonDocument.Parse( + """ + { + "type": "object", + "properties": { + "editionId": { "type": "string", "format": "uuid" }, + "chapterId": { "type": "string", "format": "uuid" }, + "selectedText": { "type": "string", "minLength": 1, "maxLength": 5000 }, + "color": { "type": "string", "enum": ["yellow", "green", "blue", "pink"] }, + "noteText": { "type": "string", "maxLength": 2000 } + }, + "required": ["editionId", "chapterId", "selectedText"], + "additionalProperties": false + } + """).RootElement; + + // Match the web reader's HighlightColor palette (offlineDb.ts) — no "orange". + private static readonly string[] HighlightColors = ["yellow", "green", "blue", "pink"]; + + private static McpToolDescriptor BuildSaveHighlight(TextStackApiClient api) => new() + { + Name = "save_highlight", + Description = + "Saves a highlight to YOUR TextStack library for the given catalog book chapter " + + "(WRITE on your own account — requires you to be signed in). Pass the editionId " + + "and chapterId (from get_book), the exact selected text, and optionally a color " + + "and a note. The highlight is stored and listable via list_my_highlights.", + InputSchema = SaveHighlightSchema, + Handler = (args, ct) => + { + if (!ArgReader.TryObject(args, out var obj, out var err, "editionId", "chapterId", "selectedText", "color", "noteText") + || !ArgReader.TryRequiredGuid(obj, "editionId", out var editionId, out err) + || !ArgReader.TryRequiredGuid(obj, "chapterId", out var chapterId, out err) + || !ArgReader.TryRequiredString(obj, "selectedText", 1, 5000, out var selectedText, out err) + || !ArgReader.TryOptionalString(obj, "noteText", 2000, out var noteText, out err) + || !TryReadColor(obj, out var color, out err)) + return Task.FromResult(Error(err)); + + // No DOM → synthesize a W3C text-quote anchor from the agent's args. + // The web reader's findTextByAnchor matches on `exact` first, so a + // single-occurrence quote re-anchors; otherwise it stays saved + listable. + var anchorJson = SynthesizeAnchor(chapterId, selectedText); + + return InvokeAsync("save_highlight", ct, async () => + { + var saved = await api.SaveHighlightAsync(editionId, chapterId, anchorJson, color, selectedText, noteText, ct); + if (saved is null) + return Error("save_highlight failed: the book or chapter was not found, or the save was rejected"); + + var mapped = new + { + id = saved.Id, + chapterId = saved.ChapterId, + color = saved.Color, + selectedText = saved.SelectedText, + noteText = saved.NoteText, + createdAt = saved.CreatedAt, + saved = true, + }; + return Text(JsonSerializer.Serialize(mapped)); + }); + }, + }; + + // Optional color: absent → "yellow"; present must be one of the reader palette. + private static bool TryReadColor(JsonElement obj, out string color, out string error) + { + color = "yellow"; + if (!ArgReader.TryOptionalString(obj, "color", 20, out var raw, out error)) + return false; + if (raw is null) + return true; + if (Array.IndexOf(HighlightColors, raw) < 0) + { + error = $"'color' must be one of: {string.Join(", ", HighlightColors)}."; + return false; + } + color = raw; + return true; + } + + // Minimal W3C text-quote anchor (matches the reader's TextAnchor shape) the API + // stores in its jsonb anchor column. No DOM offsets are available from an MCP + // client, so prefix/suffix are empty and offsets are quote-relative; the reader + // re-anchors by `exact`. + private static string SynthesizeAnchor(Guid chapterId, string selectedText) => + JsonSerializer.Serialize(new + { + prefix = "", + exact = selectedText, + suffix = "", + startOffset = 0, + endOffset = selectedText.Length, + chapterId = chapterId.ToString(), + source = "mcp", + }); + // ── search_books arg validation (mirrors the input schema) ─────────────────── private static bool TryReadSearchArgs( diff --git a/tests/TextStack.UnitTests/McpReadToolsTests.cs b/tests/TextStack.UnitTests/McpReadToolsTests.cs index 927e39e6..53098e36 100644 --- a/tests/TextStack.UnitTests/McpReadToolsTests.cs +++ b/tests/TextStack.UnitTests/McpReadToolsTests.cs @@ -74,17 +74,17 @@ private static HttpResponseMessage Json(string body, HttpStatusCode status = Htt private const string Edition = "33333333-3333-3333-3333-333333333333"; - // ── tools/list: all 6 exposed with correct schemas ────────────────────────── + // ── tools/list: all 7 exposed with correct schemas (6 reads + save_highlight) ─ [Fact] - public void ListTools_ExposesAllSixTools() + public void ListTools_ExposesAllSevenTools() { var (catalog, _) = BuildCatalog(Json("{}")); var names = catalog.ListTools().Select(t => t.Name).OrderBy(n => n).ToArray(); Assert.Equal( - ["ask_book", "get_book", "get_chapter", "list_my_highlights", "list_my_vocabulary", "search_books"], + ["ask_book", "get_book", "get_chapter", "list_my_highlights", "list_my_vocabulary", "save_highlight", "search_books"], names); } diff --git a/tests/TextStack.UnitTests/McpSaveHighlightTests.cs b/tests/TextStack.UnitTests/McpSaveHighlightTests.cs new file mode 100644 index 00000000..5967dd54 --- /dev/null +++ b/tests/TextStack.UnitTests/McpSaveHighlightTests.cs @@ -0,0 +1,479 @@ +using System.Net; +using System.Text.Json; +using ModelContextProtocol.Protocol; +using TextStack.Ai.Mcp; +using TextStack.Ai.Mcp.Auth; +using TextStack.Ai.Mcp.Http; +using TextStack.Ai.Mcp.Tools; + +namespace TextStack.UnitTests; + +/// +/// AI-048b — the first WRITE MCP tool, save_highlight, appended to the AI-048a +/// catalog (completing the 7-tool surface). Verifies tools/list discovery + schema, +/// the authorized POST /me/highlights request shape (method/path/Host/Bearer) and +/// the synthesized W3C text-quote anchor (no DOM client-side), JSON→MCP success +/// mapping, arg validation gates, the auth-required fail-clean path (write NEVER +/// issued without a token), and the shared upstream-error wrapper — all against a +/// fake HTTP layer (CI-safe, no network). +/// +/// Introduces NO ITool (StudyBuddy set-equality stays green). +/// +public class McpSaveHighlightTests +{ + private const string SiteHost = "textstack.test"; + private const string Edition = "33333333-3333-3333-3333-333333333333"; + private const string Chapter = "44444444-4444-4444-4444-444444444444"; + + private sealed class CapturingHandler(HttpResponseMessage response) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + if (request.Content is not null) + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + return response; + } + } + + private sealed class ThrowingHandler(Func factory) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw factory(cancellationToken); + } + + private static (McpToolCatalog catalog, CapturingHandler handler) BuildCatalog( + HttpResponseMessage response, string? token = "tok-123") + { + var handler = new CapturingHandler(response); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.example/") }; + var options = new McpBridgeOptions { ApiBaseUrl = "https://api.example", SiteHost = SiteHost, McpToken = token }; + var api = new TextStackApiClient(http, options, new StaticEnvTokenProvider(options)); + return (new McpToolCatalog(api), handler); + } + + private static McpToolCatalog BuildCatalogThatThrows(Func factory, string? token = "tok-123") + { + var http = new HttpClient(new ThrowingHandler(factory)) { BaseAddress = new Uri("https://api.example/") }; + var options = new McpBridgeOptions { ApiBaseUrl = "https://api.example", SiteHost = SiteHost, McpToken = token }; + return new McpToolCatalog(new TextStackApiClient(http, options, new StaticEnvTokenProvider(options))); + } + + private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + private static JsonElement Args(string json) => JsonDocument.Parse(json).RootElement; + + private static string TextOf(CallToolResult result) => ((TextContentBlock)result.Content[0]).Text; + + private static string? BearerOf(HttpRequestMessage req) => req.Headers.Authorization?.ToString(); + + // A representative 201 Created body (HighlightDto shape). + private static string CreatedBody(string color = "yellow", string text = "a curious thing", string? note = "note!") => + $$""" + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "editionId": "{{Edition}}", + "chapterId": "{{Chapter}}", + "userBookId": null, "userChapterId": null, + "anchorJson": "{}", "color": "{{color}}", + "selectedText": "{{text}}", "noteText": {{(note is null ? "null" : $"\"{note}\"")}}, + "version": 1, + "createdAt": "2026-01-01T00:00:00+00:00", + "updatedAt": "2026-01-01T00:00:00+00:00" + } + """; + + // ── tools/list: schema is tight (required + bounds + additionalProperties:false) + + [Fact] + public void ListTools_SaveHighlight_HasTightSchema() + { + var (catalog, _) = BuildCatalog(Json("{}")); + var tool = Assert.Single(catalog.ListTools(), t => t.Name == "save_highlight"); + + var schema = tool.InputSchema; + Assert.Equal("object", schema.GetProperty("type").GetString()); + Assert.False(schema.GetProperty("additionalProperties").GetBoolean()); + + var required = schema.GetProperty("required").EnumerateArray().Select(e => e.GetString()!).ToArray(); + Assert.Equal(["editionId", "chapterId", "selectedText"], required); + + var props = schema.GetProperty("properties"); + Assert.Equal("uuid", props.GetProperty("editionId").GetProperty("format").GetString()); + Assert.Equal("uuid", props.GetProperty("chapterId").GetProperty("format").GetString()); + + var sel = props.GetProperty("selectedText"); + Assert.Equal(1, sel.GetProperty("minLength").GetInt32()); + Assert.Equal(5000, sel.GetProperty("maxLength").GetInt32()); + + Assert.Equal(2000, props.GetProperty("noteText").GetProperty("maxLength").GetInt32()); + + var colors = props.GetProperty("color").GetProperty("enum").EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Contains("yellow", colors); + } + + [Fact] + public void ListTools_SaveHighlight_DescribesItAsAuthenticatedWrite() + { + var (catalog, _) = BuildCatalog(Json("{}")); + var tool = Assert.Single(catalog.ListTools(), t => t.Name == "save_highlight"); + + Assert.Contains("WRITE", tool.Description); + Assert.Contains("signed in", tool.Description); + } + + // ── happy path: authorized POST, correct body + synthesized anchor, 201 → id ── + + [Fact] + public async Task SaveHighlight_IssuesAuthorizedPost_WithSynthesizedAnchor_201_ReturnsId() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"a curious thing","color":"yellow","noteText":"note!"}"""), + CancellationToken.None); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); + Assert.Equal("/me/highlights", handler.LastRequest.RequestUri!.PathAndQuery); + Assert.Equal(SiteHost, handler.LastRequest.Headers.Host); + Assert.Equal("Bearer tok-123", BearerOf(handler.LastRequest)); // WRITE → Bearer + + // Body carries the agent's fields + a synthesized text-quote anchor. + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + Assert.Equal(Edition, sent.GetProperty("editionId").GetString()); + Assert.Equal(Chapter, sent.GetProperty("chapterId").GetString()); + Assert.Equal("a curious thing", sent.GetProperty("selectedText").GetString()); + Assert.Equal("yellow", sent.GetProperty("color").GetString()); + Assert.Equal("note!", sent.GetProperty("noteText").GetString()); + + // anchorJson is a JSON STRING (jsonb column on the API); parse + verify shape. + var anchorRaw = sent.GetProperty("anchorJson").GetString(); + Assert.NotNull(anchorRaw); + var anchor = JsonDocument.Parse(anchorRaw!).RootElement; + Assert.Equal("a curious thing", anchor.GetProperty("exact").GetString()); + Assert.Equal(Chapter, anchor.GetProperty("chapterId").GetString()); + Assert.Equal(0, anchor.GetProperty("startOffset").GetInt32()); + Assert.Equal("a curious thing".Length, anchor.GetProperty("endOffset").GetInt32()); + + // Success result confirms the new highlight id. + var root = JsonDocument.Parse(TextOf(result)).RootElement; + Assert.Equal("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", root.GetProperty("id").GetString()); + Assert.True(root.GetProperty("saved").GetBoolean()); + } + + [Fact] + public async Task SaveHighlight_OmitsColorAndNote_DefaultsColorYellow_NullNote() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(note: null), HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hello"}"""), + CancellationToken.None); + + Assert.NotEqual(true, result.IsError); + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + Assert.Equal("yellow", sent.GetProperty("color").GetString()); + // noteText null → omitted by WhenWritingNull serializer. + Assert.False(sent.TryGetProperty("noteText", out _)); + } + + [Fact] + public async Task SaveHighlight_AlsoAccepts200_FromApi() + { + var (catalog, _) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.OK)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.NotEqual(true, result.IsError); + } + + [Fact] + public async Task SaveHighlight_NonSuccess_ReturnsCleanError() + { + var (catalog, _) = BuildCatalog(Json("Edition not found", HttpStatusCode.NotFound)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("save_highlight failed", TextOf(result)); + } + + // ── arg validation: never hits HTTP ────────────────────────────────────────── + + [Theory] + [InlineData("""{"chapterId":"44444444-4444-4444-4444-444444444444","selectedText":"hi"}""")] // missing editionId + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","selectedText":"hi"}""")] // missing chapterId + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","chapterId":"44444444-4444-4444-4444-444444444444"}""")] // missing selectedText + [InlineData("""{"editionId":"bad","chapterId":"44444444-4444-4444-4444-444444444444","selectedText":"hi"}""")] // bad editionId guid + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","chapterId":"bad","selectedText":"hi"}""")] // bad chapterId guid + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","chapterId":"44444444-4444-4444-4444-444444444444","selectedText":""}""")] // empty text + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","chapterId":"44444444-4444-4444-4444-444444444444","selectedText":"hi","color":"purple"}""")] // color not in enum + [InlineData("""{"editionId":"33333333-3333-3333-3333-333333333333","chapterId":"44444444-4444-4444-4444-444444444444","selectedText":"hi","extra":1}""")] // additionalProperties:false + public async Task SaveHighlight_InvalidArgs_ReturnsToolError_NeverHitsHttp(string args) + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + + var result = await catalog.CallAsync("save_highlight", Args(args), CancellationToken.None); + + Assert.True(result.IsError); + Assert.Null(handler.LastRequest); + } + + [Fact] + public async Task SaveHighlight_OversizedText_ReturnsToolError_NeverHitsHttp() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + var big = new string('a', 5001); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"{{big}}"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Null(handler.LastRequest); + } + + [Fact] + public async Task SaveHighlight_OversizedNote_ReturnsToolError_NeverHitsHttp() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + var bigNote = new string('n', 2001); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi","noteText":"{{bigNote}}"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Null(handler.LastRequest); + } + + // ── unauthenticated: write NEVER issued ────────────────────────────────────── + + [Fact] + public async Task SaveHighlight_NullToken_ReturnsAuthRequired_NeverWrites() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created), token: null); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("authentication required", TextOf(result)); + Assert.Null(handler.LastRequest); // write not issued + } + + [Fact] + public async Task SaveHighlight_Api401_ReturnsAuthRequired() + { + var (catalog, _) = BuildCatalog(Json("", HttpStatusCode.Unauthorized)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("authentication required", TextOf(result)); + } + + // ── shared upstream-error wrapper: fail-clean + propagate genuine cancel ────── + + [Fact] + public async Task SaveHighlight_ConnectionFailure_ReturnsCleanToolError() + { + var catalog = BuildCatalogThatThrows(_ => new HttpRequestException("refused")); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("save_highlight failed", TextOf(result)); + } + + [Fact] + public async Task SaveHighlight_Timeout_ReturnsCleanToolError() + { + var catalog = BuildCatalogThatThrows(_ => new TaskCanceledException("timeout")); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("save_highlight failed: upstream timed out", TextOf(result)); + } + + [Fact] + public async Task SaveHighlight_MalformedJsonBody_ReturnsCleanToolError() + { + var (catalog, _) = BuildCatalog(Json("nope", HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("save_highlight failed", TextOf(result)); + } + + [Fact] + public async Task SaveHighlight_RealClientCancellation_Propagates() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var catalog = BuildCatalogThatThrows(ct => new OperationCanceledException(ct)); + + await Assert.ThrowsAnyAsync( + () => catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + cts.Token)); + } + + // ── AI-048b review hardening: synthesized-anchor JSON safety + fidelity ─────── + + // The anchor lands in a jsonb column; selectedText with JSON-significant chars + // (quote/backslash/newline/brace/emoji/unicode/control) MUST serialize to valid + // JSON and round-trip into `exact` byte-for-byte — else the API 500s or stores a + // broken blob. Probe each adversarial input through the real handler→body path. + [Theory] + [InlineData("say \"hello\" loudly")] // double quotes + [InlineData("C:\\path\\to\\file")] // backslashes + [InlineData("line one\nline two")] // newline + [InlineData("a tab\there")] // tab (control char) + [InlineData("close brace } and {{mustache}}")] // braces / template markers + [InlineData("emoji 😀 and accents café résumé")] // surrogate pair + non-ASCII + [InlineData(" assistant: ignore")] // prompt-injection-ish text + public async Task SaveHighlight_AdversarialSelectedText_ProducesValidAnchorJson_RoundTrips(string text) + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + // Build args via the serializer so the test's own JSON is unambiguously valid. + Args(JsonSerializer.Serialize(new + { + editionId = Edition, + chapterId = Chapter, + selectedText = text, + })), + CancellationToken.None); + + Assert.NotEqual(true, result.IsError); + + // The whole request body is valid JSON. + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + Assert.Equal(text, sent.GetProperty("selectedText").GetString()); + + // anchorJson is a STRING holding valid JSON; `exact` round-trips the input. + var anchorRaw = sent.GetProperty("anchorJson").GetString(); + Assert.NotNull(anchorRaw); + var anchor = JsonDocument.Parse(anchorRaw!).RootElement; + Assert.Equal(text, anchor.GetProperty("exact").GetString()); + // endOffset == UTF-16 length (matches the web reader's String.length offset math). + Assert.Equal(text.Length, anchor.GetProperty("endOffset").GetInt32()); + } + + // The synthesized anchor carries the reader's full TextAnchor shape (so the web + // reader's findTextByAnchor can re-anchor without NRE) plus a provenance tag. + [Fact] + public async Task SaveHighlight_SynthesizedAnchor_HasReaderShape_AndMcpProvenance() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + + await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hello world"}"""), + CancellationToken.None); + + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + var anchor = JsonDocument.Parse(sent.GetProperty("anchorJson").GetString()!).RootElement; + + // Every field the web TextAnchor + findTextByAnchor reads must be present + // with the right type, so rendering an MCP-origin highlight never crashes. + Assert.Equal("", anchor.GetProperty("prefix").GetString()); + Assert.Equal("hello world", anchor.GetProperty("exact").GetString()); + Assert.Equal("", anchor.GetProperty("suffix").GetString()); + Assert.Equal(JsonValueKind.Number, anchor.GetProperty("startOffset").ValueKind); + Assert.Equal(JsonValueKind.Number, anchor.GetProperty("endOffset").ValueKind); + Assert.Equal(Chapter, anchor.GetProperty("chapterId").GetString()); + // Provenance: distinguishes MCP-saved highlights from reader-saved ones. + Assert.Equal("mcp", anchor.GetProperty("source").GetString()); + } + + // Whitespace-only text clears minLength:1 (TrimStart not applied) — the write + // fires, matching the web app (which also does not trim). Pin the behavior so a + // future "reject blank" decision is a conscious change, not a silent regression. + [Fact] + public async Task SaveHighlight_WhitespaceOnlyText_StillWrites_MirrorsWebApp() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(text: " "), HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":" "}"""), + CancellationToken.None); + + Assert.NotEqual(true, result.IsError); + Assert.NotNull(handler.LastRequest); // write IS issued (no trim/blank gate) + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + Assert.Equal(" ", sent.GetProperty("selectedText").GetString()); + } + + // Body fidelity: the request carries EXACTLY the 6 fields of the real + // CreateHighlightRequest subset (editionId, chapterId, anchorJson, color, + // selectedText, noteText) — no stray/renamed keys that the API would ignore. + [Fact] + public async Task SaveHighlight_RequestBody_MatchesEndpointDtoFieldNames() + { + var (catalog, handler) = BuildCatalog(Json(CreatedBody(), HttpStatusCode.Created)); + + await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi","color":"green","noteText":"n"}"""), + CancellationToken.None); + + var sent = JsonDocument.Parse(handler.LastRequestBody!).RootElement; + var keys = sent.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray(); + Assert.Equal( + new[] { "anchorJson", "chapterId", "color", "editionId", "noteText", "selectedText" }, + keys); + } + + // A 201 with an empty/unexpected body must NOT NRE out as a protocol fault — the + // shared wrapper maps the deserialization defect to a clean tool error. + [Fact] + public async Task SaveHighlight_201_EmptyBody_ReturnsCleanError_NotProtocolFault() + { + var (catalog, _) = BuildCatalog(Json("", HttpStatusCode.Created)); + + var result = await catalog.CallAsync( + "save_highlight", + Args($$"""{"editionId":"{{Edition}}","chapterId":"{{Chapter}}","selectedText":"hi there"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("save_highlight failed", TextOf(result)); + } +}