Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,37 @@ public async Task<VocabularyPageJson> GetVocabularyAsync(
return EmptyVocab;
}

// ── save_highlight (Bearer, WRITE) ───────────────────────────────────────────

/// <summary>
/// <c>POST /me/highlights</c> with <c>{ editionId, chapterId, anchorJson, color,
/// selectedText, noteText }</c> — 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 <paramref name="anchorJson"/> (exact = selectedText) so the
/// jsonb column is satisfied and the web reader can best-effort re-anchor.
/// 401 → <see cref="McpUnauthorizedException"/> (write NEVER issued without a
/// usable token, enforced in <see cref="AuthorizedRequestAsync"/>); other
/// non-success (2xx-but-not-201, 4xx) → null (handler maps to a clean error).
/// </summary>
public async Task<HighlightJson?> 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<HighlightJson>(JsonOptions, ct);

return null;
}

// ── ask_book (Bearer) ────────────────────────────────────────────────────────

/// <summary>
Expand Down Expand Up @@ -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,
Expand All @@ -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<VocabWordJson> Items);

Expand Down
111 changes: 108 additions & 3 deletions backend/src/Ai/TextStack.Ai.Mcp/Tools/McpToolCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ namespace TextStack.Ai.Mcp.Tools;

/// <summary>
/// The runtime catalog of MCP tools the server exposes. AI-047 shipped
/// <c>search_books</c>; AI-048a appends 5 READ tools (<c>get_book</c>,
/// <c>search_books</c>; AI-048a appended 5 READ tools (<c>get_book</c>,
/// <c>get_chapter</c>, <c>list_my_highlights</c>, <c>list_my_vocabulary</c>,
/// <c>ask_book</c>). <c>tools/list</c> and <c>tools/call</c> are served from this
/// list, so adding a tool is a single append + its handler.
/// <c>ask_book</c>); AI-048b appends the first WRITE tool (<c>save_highlight</c>),
/// completing the 7-tool surface. <c>tools/list</c> and <c>tools/call</c> 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 <see cref="InvokeAsync"/> so every tool
Expand All @@ -35,6 +36,7 @@ public McpToolCatalog(TextStackApiClient api)
BuildListMyHighlights(api),
BuildListMyVocabulary(api),
BuildAskBook(api),
BuildSaveHighlight(api),
};
_byName = tools.ToDictionary(t => t.Name, StringComparer.Ordinal);
}
Expand Down Expand Up @@ -401,6 +403,109 @@ private static async Task<CallToolResult> 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(
Expand Down
6 changes: 3 additions & 3 deletions tests/TextStack.UnitTests/McpReadToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading