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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the archive; if it broke production, it belongs in `docs/incidents/`. See

## [Unreleased]

- **MCP** — the assistant can see where you are, and can record a chapter you finished somewhere else — backend · [details](docs/changelog-archive/2026-H2.md#2026-09-10-mcp-the-assistant-can-see-where-you-are)
- **Mobile** — the assistant handoff and the conclusions it writes back reach the phone, and a percent that was silently a fraction — mobile, web, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-08-mcp-the-book-opens-to-your-assistant-and-the-conclusions-come-back)
- **MCP** — TextStack.Mcp 1.1.0 on NuGet: the tool ships the uploaded-library and write-back tools — backend · [details](docs/changelog-archive/2026-H2.md#2026-09-08-mcp-the-book-opens-to-your-assistant-and-the-conclusions-come-back)
- **Library** — searching your own library answered 500 on every call and always had, behind an empty-looking result — backend · [details](docs/changelog-archive/2026-H2.md#2026-09-08-mcp-the-book-opens-to-your-assistant-and-the-conclusions-come-back)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ Supported formats: EPUB, PDF. Processing order: Spelling → Hyphenation → Typ

## MCP Server (`backend/src/Ai/TextStack.Ai.Mcp/`)

Thin, stateless MCP↔HTTP bridge (Phase 8) — every tool call becomes an HTTP request to the public TextStack API (no DB/EF/OpenAI). 13 tools. Public catalog: `search_books`, `get_book`, `get_chapter`. The user's own uploads (Bearer): `search_my_library`, `get_my_book`, `get_my_chapter`, `save_my_highlight`, `list_my_book_highlights` — keyed by `bookId` (`UserBook.Id`), which is NOT an `editionId` and does not work in the edition-scoped tools. Write-back, either book type (Bearer): `save_insight`, `get_my_insights` — conclusions from an outside assistant, keyed by chapter **slug** (`BookInsight`, table `book_insight`), one per (user, book, chapter) so a re-run replaces rather than accumulates. Everything else (Bearer): `list_my_highlights`, `list_my_vocabulary`, `save_highlight`.
Thin, stateless MCP↔HTTP bridge (Phase 8) — every tool call becomes an HTTP request to the public TextStack API (no DB/EF/OpenAI). 16 tools. Public catalog: `search_books`, `get_book`, `get_chapter`. The user's own uploads (Bearer): `search_my_library`, `get_my_book`, `get_my_chapter`, `save_my_highlight`, `list_my_book_highlights` — keyed by `bookId` (`UserBook.Id`), which is NOT an `editionId` and does not work in the edition-scoped tools. Write-back, either book type (Bearer): `save_insight`, `get_my_insights` — conclusions from an outside assistant, keyed by chapter **slug** (`BookInsight`, table `book_insight`), one per (user, book, chapter) so a re-run replaces rather than accumulates. Everything else (Bearer): `list_my_highlights`, `list_my_vocabulary`, `save_highlight`. Reading state (Bearer): `get_my_reading` — the shelf, **no arguments**, the only way in when the model holds no id; `get_book_progress`; `set_book_progress` — records a chapter finished ANYWHERE (audiobook, paper) and resumes the reader at the next one. The tool count is asserted in four places (`McpManifestDriftTests`, `McpStdioSmokeTests`, `McpOverTheWireTests`, `McpManifestEndpointTests`) and the descriptions are mirrored verbatim into `Contracts/Mcp/McpManifest.cs`, which the drift test compares character for character.

**There is no question-answering tool.** `ask_book` and the retrieval spine behind it were deleted 2026-09-10: 7 books of 1498 were ever indexed, and the vision PDF transcription that fed the index was 94% of the project's lifetime LLM spend. An assistant reads `get_chapter` as plain text and reasons over it better, on the reader's own subscription.

Expand Down
163 changes: 162 additions & 1 deletion backend/src/Ai/TextStack.Ai.Mcp/Http/TextStackApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,109 @@ public async Task<VocabularyPageJson> GetVocabularyAsync(
return null;
}

// ── reading state (Bearer) ───────────────────────────────────────────────────

/// <summary>
/// <c>GET /me/library/shelves</c> — the reader's shelf, both book kinds in one response with
/// titles and progress already joined. This is the only endpoint that answers "what am I
/// reading" without the caller stitching three others together.
/// </summary>
public async Task<ShelvesJson?> GetShelvesAsync(CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Get, "/me/library/shelves", ct);
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);

if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
if (response.StatusCode is HttpStatusCode.OK)
return await response.Content.ReadFromJsonAsync<ShelvesJson>(JsonOptions, ct);

return null;
}

/// <summary>
/// <c>GET /me/books</c> — every upload, not paged. The shelves response is capped and filtered to
/// in-progress, so this is what answers "everything I have", including books never opened.
/// </summary>
public async Task<IReadOnlyList<MyBookJson>?> GetMyBooksAsync(CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Get, "/me/books", ct);
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);

if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
if (response.StatusCode is HttpStatusCode.OK)
return await response.Content.ReadFromJsonAsync<IReadOnlyList<MyBookJson>>(JsonOptions, ct);

return null;
}

/// <summary><c>GET /me/progress/{editionId}</c>. 404 when the reader has never opened it.</summary>
public async Task<EditionProgressJson?> GetEditionProgressAsync(Guid editionId, CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Get, $"/me/progress/{editionId}", ct);
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);

if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
if (response.StatusCode is HttpStatusCode.OK)
return await response.Content.ReadFromJsonAsync<EditionProgressJson>(JsonOptions, ct);

return null;
}

/// <summary><c>GET /me/books/{id}/progress</c>. 404 when the reader has never opened it.</summary>
public async Task<UserBookProgressJson?> GetUserBookProgressAsync(Guid bookId, CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Get, $"/me/books/{bookId}/progress", ct);
using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);

if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
if (response.StatusCode is HttpStatusCode.OK)
return await response.Content.ReadFromJsonAsync<UserBookProgressJson>(JsonOptions, ct);

return null;
}

/// <summary>
/// <c>PUT /me/progress/{editionId}</c> — move a catalog book's position.
/// <para>
/// <c>percentUnit: "book"</c> is not optional decoration: without it <c>ProgressUnit.IsTrusted</c>
/// is false and the server stores the position while silently discarding the number.
/// </para>
/// </summary>
public async Task<bool> SetEditionProgressAsync(
Guid editionId, Guid chapterId, string locator, double? percent, CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Put, $"/me/progress/{editionId}", ct);
request.Content = JsonContent.Create(
new SetEditionProgressJson(chapterId, locator, percent, "book"), options: JsonOptions);

using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
return response.IsSuccessStatusCode;
}

/// <summary>
/// <c>PUT /me/books/{id}/progress</c> — move an upload's position. Slug-native, so no chapter-id
/// lookup is needed here; the server validates the slug against the book.
/// <para>
/// <paramref name="locatorKind"/> is what makes the write land on a book last read as a PDF in
/// Original layout: the stored position is then <c>page:&lt;n&gt;</c>, and
/// <c>LocatorSpace.MayReplace</c> drops an undeclared write from another coordinate space
/// entirely — silently, from the caller's point of view, before this change reported it.
/// </para>
/// </summary>
public async Task<bool> SetUserBookProgressAsync(
Guid bookId, string? chapterSlug, string locator, double? percent, string? locatorKind,
CancellationToken ct)
{
using var request = await AuthorizedRequestAsync(HttpMethod.Put, $"/me/books/{bookId}/progress", ct);
request.Content = JsonContent.Create(
new SetUserBookProgressJson(chapterSlug, locator, percent, "book", locatorKind), options: JsonOptions);

using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode is HttpStatusCode.Unauthorized) throw new McpUnauthorizedException();
return response.IsSuccessStatusCode;
}

// ── request builders ─────────────────────────────────────────────────────────

// Public route: Host header only (site + EN default-language resolution).
Expand Down Expand Up @@ -505,7 +608,10 @@ public sealed record BookAuthorJson(string Name);

public sealed record BookGenreJson(string Name);

public sealed record ChapterSummaryJson(int ChapterNumber, string? Slug, string Title, int? WordCount);
// `Id` is deserialized because save_highlight needs a chapterId and its description tells the model
// to take it from get_book. That was false for as long as this record omitted the field: the server
// DTO (Contracts.Books.ChapterSummaryDto) has always carried it, the bridge just dropped it.
public sealed record ChapterSummaryJson(Guid Id, int ChapterNumber, string? Slug, string Title, int? WordCount);

// GET /books/{slug}/chapters/{chapterSlug} → Contracts.Books.ChapterDto (subset).
public sealed record ChapterJson(
Expand Down Expand Up @@ -650,3 +756,58 @@ public sealed record SaveInsightJson(
string? ChapterSlug,
string Text,
string? Question);


// GET /me/library/shelves → LibraryShelvesDto. Only the shelves an assistant needs to answer
// "what am I reading" and "what did I just finish".
public sealed record ShelvesJson(
IReadOnlyList<ShelfItemJson>? ContinueReading,
IReadOnlyList<ShelfItemJson>? RecentlyAdded,
IReadOnlyList<ShelfItemJson>? FinishedThisMonth);

public sealed record ShelfItemJson(
Guid Id,
// "userbook" (an upload, addressed by bookId) or "savedbook" (a catalog edition, editionId).
// The two halves of the tool catalog split on exactly this.
string Type,
string Title,
string? Author,
string? Slug,
double ProgressPercent,
DateTimeOffset? LastOpenedAt,
string? ChapterSlug);

// GET /me/books → UserBookListDto[] (the subset that answers "what is on my shelf").
public sealed record MyBookJson(
Guid Id,
string Title,
string Slug,
string? Author,
string Status,
int ChapterCount,
double? ProgressPercent,
string? ProgressChapterSlug,
DateTimeOffset? CompletedAt);

// GET /me/progress/{editionId} → ReadingProgressDto.
public sealed record EditionProgressJson(
Guid EditionId,
Guid ChapterId,
string? ChapterSlug,
string Locator,
double? Percent,
DateTimeOffset UpdatedAt,
DateTimeOffset? CompletedAt);

// GET /me/books/{id}/progress → UserBookProgressDto.
public sealed record UserBookProgressJson(
string? ChapterSlug,
string? Locator,
double? Percent,
DateTimeOffset? UpdatedAt);

public sealed record SetEditionProgressJson(
Guid ChapterId, string Locator, double? Percent, string PercentUnit);

public sealed record SetUserBookProgressJson(
string? ChapterSlug, string? Locator, double? Percent, string PercentUnit, string? LocatorKind);
Loading
Loading