Skip to content

Library: five tools an agent may hold a library by - #221

Merged
manager merged 4 commits into
devfrom
feat/library-mcp
Sep 10, 2026
Merged

Library: five tools an agent may hold a library by#221
manager merged 4 commits into
devfrom
feat/library-mcp

Conversation

@manager

@manager manager commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Five tools an agent holds a library by: list books with their notes, create or edit a tag, put a tag on a book or take it off, set the order the tag's books stand in, write a book's note and rating. Nothing here deletes anything.

Nothing is deployed by this PR. The tools need keepsimple-cms-new#feat/library-agent-session on the target CMS before a key means anything.

Proof

All five run against a live library, staging library 6 (LibraryReview), and every write is taken back:

PASS  read the library — 4 books
PASS  create a tag — MCP probe (mcp-probe)
PASS  rename and recolour it — description saved
PASS  put it on two books — Thinking, Fast and Slow, Good to Great
PASS  set their order — Good to Great -> Thinking, Fast and Slow
PASS  write a note and a rating — written and read back
PASS  restore the note and rating — as it was
PASS  take the tag off and delete it — cleared

Re-read afterwards: book 26 back at rating 5, difficulty easy, its five original tags and its 1261-character note. The protocol itself was exercised over stdin: initialize, tools/list, tools/call.

NOT TESTED: the key exchange end to end, because the CMS side is not deployed. Its own logic is covered by a probe in that PR.

What it costs

No dependency, no change under src/, nothing in the Next build. mcp/ is plain Node, run by an agent's MCP client.

🤖 Generated with Claude Code

The backend already knew how to do this: a tag has a name, a description, a
colour and its own order of books, and a book has a note and a rating. What
was missing was a key a machine may hold and a wrapper that reads as tools.

Books and tags are named the way a person names them, by title or by word,
and a word that matches two books is refused with both rather than guessed
at. A partial order is enough: what is named takes the front and the rest
keep their sequence behind it. Nothing here deletes a book, a shelf or a
tag; that asks the owner first, in the Library.

The key buys the owner's ordinary session and no exemption: the same
ownership checks, feature flag and limits decide every write, and a call
that would reach another library fails here before it is sent. Every call
leaves one line in logs/library-mcp.jsonl, the key and the session never in
it. probe.mjs runs all five against a live library and takes back every
write it makes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 3m 11s —— View job


Review: Library MCP tools (PR #221)

  • Gather context (PR diff, AGENTS.md/CLAUDE.md conventions)
  • Read all changed files under mcp/library/
  • Review for correctness, security, and project conventions
  • Post inline comments for specific issues
  • Post final summary

Scope note

This PR is entirely outside src/ — plain Node ESM scripts under mcp/library/ (server, config, journal, keygen, library client, tools, probe) plus a CLAUDE.md doc addition. No React, no SCSS, no Pages Router, no state libs, no src/ imports — nothing in the standard KeepSimple frontend checklist applies. Reviewed as a standalone Node/MCP server against its own stated design (README.md, doc comments, the probe's own proof).

Findings (posted inline)

  1. tools.mjs:440-448 — the post-write verification for library_book does a strict !== compare on every field, including note. If the CMS ever sanitizes/normalizes the rich-text note (the tool explicitly supports <strong>/<em>/<s>/links), a successful save could be reported back as failed. Worse, the thrown error stringifies the full field value into error.message, and that message is logged as detail in the journal — journal.mjs's 120-character truncation only covers args, not detail, so a spurious mismatch on a long note would write the entire note into the plaintext logs/library-mcp.jsonl, contradicting the stated "not a second copy of the library" journal design and the CLAUDE.md claim that arguments are cut to 120 characters.
  2. tools.mjs:317-343 (library_tag_books) — the add/remove loop isn't transactional. If a later book throws (e.g. exceeds MAX_TAGS_PER_OBJECT), earlier writes in the same call already landed, but the error response carries no changed list, so the caller can't tell partial success happened. A retry is safe (idempotent), but silent.
  3. tools.mjs:27-30 (minor) — MAX_TAG_NAME_LENGTH/MAX_TAG_DESCRIPTION_LENGTH/MAX_TAGS_PER_OBJECT/MAX_NOTE_LENGTH are hardcoded copies of src/constants/library/common.ts values, inconsistent with palette() a few lines below which deliberately reads src/constants/library/tags.ts from disk at runtime to avoid drift. Currently in sync (20/180/10/5000), but nothing prevents future drift.
  4. tools.mjs:276-303 (nit) — tag creation validates name/description length locally but not the 13-tag-per-library cap the tool's own description and CLAUDE.md advertise, unlike the other limits which are checked before the round trip.

What looked solid

  • Blast radius is well-contained: library.mjs scopes every request to live.libraryId from the exchanged session, so a mistyped id can't reach another library; the key model (digest-only in CMS config, exchanged for a real owner session, same ownership/feature-flag/limit checks downstream) matches the "key is not a second authority" framing in the new CLAUDE.md section.
  • Ambiguous name resolution (resolveBook/resolveTag) fails closed with candidates listed rather than guessing — good for an agent-facing tool where a wrong write is worse than an error.
  • No secrets in the diff; the key is read from a file or env var, never hardcoded, and the journal explicitly excludes the key/session (modulo finding docs: fixed the path of reamde cover image #1 above).
  • Nothing here can delete a book, shelf, or tag (matches the PR description); deleteTag exists only for the probe's own cleanup, not exposed as a tool.
  • probe.mjs is a genuinely good piece of proof: it exercises all five tools against a live library and reverses every write it makes, matching the release-evidence-gate spirit of this repo's CLAUDE.md.
  • Duplicated validation constants and the missing tag-cap check (findings 3-4) are low severity since the CMS remains the source of truth and will reject anything that slips past local checks — they just cost the round trip the local checks exist to avoid.

Nothing here blocks merging on its own; #1 is the one worth fixing before this is wired to a real agent, since it can leak note content into a log file that's presumably meant to be safe to share/inspect.

Comment thread mcp/library/tools.mjs
Comment on lines +440 to +448
// The response is the only proof the write landed. A 200 that carries the
// old value is a failed save, not a saved one.
for (const [field, value] of Object.entries(data)) {
if (after[field] !== undefined && after[field] !== value) {
throw new LibraryError(
`The server kept ${field} as ${JSON.stringify(after[field])}`,
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related issues in this post-write check:

  1. False failures on server-side normalization. after[field] !== value is a strict comparison. note is the field most likely to be transformed by the CMS (HTML sanitization/normalization of the <strong>/<em>/<s>/link markup the tool description promises, trailing-whitespace trimming, entity encoding). If the CMS normalizes the note even slightly, a successful write will still fail this check and be reported back to the agent as a failed save.
  2. Content leak into the plaintext journal. JSON.stringify(after[field]) embeds the full field value in the thrown LibraryError message — for note that can be up to MAX_NOTE_LENGTH (5000) characters. That message becomes detail in the record() call at the bottom of call(). journal.mjs's truncation (LONGEST = 120, the brief/briefArgs helpers) only applies to line.args, not to detail — so a spurious "kept different value" failure on a long note writes the entire note into logs/library-mcp.jsonl in the clear. That directly undercuts the stated design goal in journal.mjs ("A trail, not a second copy of the library... without copying the library into a log") and the CLAUDE.md claim that arguments are "cut to 120 characters."

Consider comparing only the non-text fields (rating, difficulty) strictly, treating note verification more loosely (or not at all — the reread-based verification the probe does is a better model), and truncating detail the same way args is truncated before it's journaled.

Comment thread mcp/library/tools.mjs
Comment on lines +317 to +343
const changed = [];

for (const book of add) {
if (book.tags.some(carried => carried.id === tag.id)) continue;

if (book.tags.length >= MAX_TAGS_PER_OBJECT) {
throw new LibraryError(
`"${book.title}" already carries ${MAX_TAGS_PER_OBJECT} tags`,
);
}

await writeObject(book.id, {
tags: [...book.tags.map(carried => carried.id), tag.id],
});
changed.push({ book: book.title, tagged: true });
}

for (const book of remove) {
if (!book.tags.some(carried => carried.id === tag.id)) continue;

await writeObject(book.id, {
tags: book.tags
.filter(carried => carried.id !== tag.id)
.map(carried => carried.id),
});
changed.push({ book: book.title, tagged: false });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop writes sequentially and isn't transactional: if a later book in add throws (e.g. it already carries MAX_TAGS_PER_OBJECT tags), everything written before that point has already landed on the CMS, but the function throws before returning changed. call()'s catch block only reports error.message — the caller gets no indication that some books were already tagged. A retry of the same call is safe (already-tagged books are skipped), but the agent has no way to know partial success happened without a separate library_books read. Worth returning the partial changed list alongside the error, e.g. via a custom error subclass or by catching per-book and continuing rather than letting one bad reference abort the batch.

Comment thread mcp/library/tools.mjs Outdated
Comment on lines +27 to +30
const MAX_TAG_NAME_LENGTH = 20;
const MAX_TAG_DESCRIPTION_LENGTH = 180;
const MAX_TAGS_PER_OBJECT = 10;
const MAX_NOTE_LENGTH = 5000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor maintainability note: MAX_TAG_NAME_LENGTH/MAX_TAG_DESCRIPTION_LENGTH/MAX_TAGS_PER_OBJECT/MAX_NOTE_LENGTH are hardcoded copies of src/constants/library/common.ts and addObjectSchema.ts (currently in sync: 20/180/10/5000). That's inconsistent with palette() a few lines down, which deliberately reads src/constants/library/tags.ts from disk at runtime specifically "so the two cannot drift." If one of these limits changes in src/constants/library/common.ts without a matching edit here, the MCP tool silently goes stale in one of two ways: rejecting input the CMS would now accept, or accepting input the CMS will now reject (paying exactly the round trip this local validation exists to avoid, per the comment on line 26). Since mcp/ intentionally has no dependency on the Next build, reading the source file the way palette() does (or extracting these four constants to a tiny JSON/constants file both sides import) would keep this from drifting silently.

Comment thread mcp/library/tools.mjs
Comment on lines +276 to +303
if (!args.name) throw new LibraryError('A new tag needs a name');

const taken = new Set(tags.map(tag => tag.color?.toUpperCase()));
const color =
args.color ||
palette().find(hex => !taken.has(hex.toUpperCase())) ||
null;

if (!color) {
throw new LibraryError('Name a colour for this tag, as a hex value');
}

const created = await createTag({
name: args.name,
color,
description: args.description,
library: (await readLibrary()).libraryId,
});

return {
created: {
id: created?.data?.id,
name: created?.data?.attributes?.name,
color: created?.data?.attributes?.color,
slug: created?.data?.attributes?.slug,
},
};
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the create-tag path validates name length and description length locally (lines 243-256) but doesn't check the 13-tag-per-library cap that the tool's own description field advertises ("A library keeps at most 13 tags") and that CLAUDE.md documents. tags (the full list) is already in scope here — a tags.length >= 13 check before calling createTag would match the pattern already used for the other limits and save the round trip the comment on line 26 says local validation exists to avoid.

A production write through the MCP is the owner's word relayed. Each write
tool now takes an optional instruction, the words the change was asked in,
and every journal line names the client that asked, from the handshake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 1m 14s —— View job


Review: Library MCP tools (PR #221) — re-check after new commit

  • Gather context (diff since last review, new commit 7a5e384)
  • Check whether prior findings (journal leak, partial-write visibility) were addressed
  • Review new/changed code for correctness, security, conventions
  • Post summary

What changed since the last review

One new commit, 7a5e384 — "the journal names the caller and carries the instruction". It:

  • Adds setCaller() in journal.mjs, called from server.mjs's initialize handler with clientInfo.name from the MCP handshake, so every journal line now carries caller alongside target.
  • Adds an optional instruction string parameter to the four write tools (library_tag, library_tag_books, library_tag_order, library_book) — free text describing what the write was asked to do. It isn't read by any handler; it rides along in args purely so call()'s existing record({ tool, args, ... }) journals it (and gets the same 120-char briefArgs truncation as every other arg).

This is small, additive, and does what it says. No new correctness or security issues:

  • caller is set once per stdio session (one process = one client), so no cross-request leakage.
  • instruction never reaches the CMS payload — each handler still only reads the specific fields it uses (name, color, description, note, rating, difficulty), so it can't accidentally get written into a tag or book.
  • It goes through the same truncation path as other args, so it doesn't reopen the journal-size concern from the original review.

Prior findings — still open (unchanged by this commit)

This commit didn't touch the handler bodies, so the four items from the original review still apply at their new line numbers:

  1. tools.mjs:465 (library_book) — the post-write check still does after[field] !== value including on note; a CMS-side normalization would report a successful save as failed, and the thrown message still embeds the full field value via JSON.stringify. That message becomes detail in the journal, and journal.mjs's brief() truncation is still only applied to line.args, not line.detail — so a long note could still land in logs/library-mcp.jsonl in the clear on a spurious mismatch.
  2. tools.mjs:339-365 (library_tag_books) — the add/remove loop is still sequential and non-transactional; a failure partway through still throws without surfacing the partial changed list.
  3. tools.mjs:26-30MAX_TAG_NAME_LENGTH/MAX_TAG_DESCRIPTION_LENGTH/MAX_TAGS_PER_OBJECT/MAX_NOTE_LENGTH are still hardcoded copies rather than read from src/constants/library the way palette() reads tags.ts.
  4. tools.mjs:308 (tag creation) — still no local check against the 13-tag-per-library cap before the round trip, despite the tool's own description and CLAUDE.md advertising it.

None of these are new, and none block merging on their own — the CMS remains the source of truth and will reject anything that slips past local validation. #1 is the one worth fixing before this is wired to a real agent, since it's the one that can leak note content into a log file meant to be safe to inspect/share.

The limits are read from the constants the forms read, so they cannot
drift; a new tag is refused locally at the thirteen-tag cap; tagging many
books answers for each by name and fails as a whole only when nothing
changed; the post-write check folds whitespace before comparing a note and
puts no value in its message, and the journal cuts details as it cuts
arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Review: Library MCP tools (PR #221) — re-check after commit 04210d3

  • Gather context (diff since last review, new commit 04210d3 "the MCP answers the review")
  • Check whether all 4 prior findings were actually addressed
  • Review the new diff for correctness, security, conventions
  • Post summary

@manager

manager commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Review dispositions, commit 04210d3:

  1. Post-write check: fixed. The note is compared with whitespace folded; the message carries no value; the journal now cuts detail as it cuts arguments.
  2. Partial batch in library_tag_books: fixed. Each book is answered for by name (changed and failed); the call fails as a whole only when nothing changed.
  3. Hardcoded limits: fixed. Read from src/constants/library/common.ts at runtime like the palette, with the 2026-09-10 figures as the out-of-checkout fallback. The note cap stays 5000: it is the CMS schema's own and has no frontend constant.
  4. Thirteen-tag cap on create: fixed, checked locally before the round trip.

The probe ran again against staging library 6 after the change: all eight steps pass, every write taken back.

@manager
manager merged commit 99fdadc into dev Sep 10, 2026
1 check passed
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 1m 9s —— View job


Review: Library MCP tools (PR #221) — re-check after commit 04210d3

  • Gather context (diff since last review, new commit 04210d3 "the MCP answers the review")
  • Check whether all 4 prior findings were actually addressed
  • Review the new diff for correctness, security, conventions
  • Post summary

Verification of the four prior findings

Read mcp/library/tools.mjs and mcp/library/journal.mjs in full and checked each claim in @manager's disposition comment against the code, not just the summary:

  1. Post-write check (note comparison / journal leak) — confirmed fixed. library_book's verification (tools.mjs:512-534) now folds whitespace before comparing description (fold(after[field]) === fold(value)), and strict !== only remains for rating/difficulty, which are non-text. The thrown message no longer embeds the field's value (The server did not keep the ${field} as sent, vs. the old JSON.stringify(after[field])). journal.mjs:51 now runs detail through the same brief() truncation as args, so even an unrelated long error string can no longer land unclipped in logs/library-mcp.jsonl.
  2. library_tag_books partial-batch visibility — confirmed fixed. tools.mjs:352-420: each add/remove is now wrapped in its own try/catch, appending to changed or failed (with a reason) rather than letting one throw abort the loop. The call only throws when changed.length === 0 && failed.length > 0; otherwise it returns both lists plus carries, so a caller can always tell what landed.
  3. Hardcoded limits — confirmed fixed. tools.mjs:38-56: MAX_TAG_NAME_LENGTH, MAX_TAG_DESCRIPTION_LENGTH, MAX_TAGS_PER_OBJECT, MAX_TAGS_PER_LIBRARY are now read at runtime from src/constants/library/common.ts via the same disk-read pattern palette() already used, with the 2026-09-10 values as the outside-a-checkout fallback. Verified the source constant names actually match (grep on src/constants/library/common.ts:103,107,111,118) — the regex export const ${name} = (\d+); lines up with all four. MAX_NOTE_LENGTH staying a literal 5000 is reasonable since it's the CMS schema's own object.description cap with no frontend constant to read.
  4. 13-tag-per-library cap on create — confirmed fixed. tools.mjs:319-323 checks tags.length >= MAX_TAGS_PER_LIBRARY before the createTag round trip, matching the pattern used for the other limits.

No new issues found in this diff. The fixes are targeted at exactly the four points raised and don't touch unrelated handler logic (library_books, library_tag_order, resolveBook/resolveTag in library.mjs are unchanged from the first review).

Assessment

All four findings from the original review are resolved in the code, not just described as resolved. Combined with the probe re-run @manager reported (all eight steps passing against staging library 6, every write taken back), this PR looks solid. No blockers from this review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant