Files
com.gregkilmer.www/docs/content-service.md
T
Claude 3f646048a2 Add the content service and MCP server (phase 2)
services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 19:10:38 +00:00

6.2 KiB

Content service

The content service sits between the vault and everything that shows or changes content. It is the only thing that reads or writes the Site folder.

Obsidian ──edits──▶ Site folder ──watch──▶ rules ──▶ accepted store ──▶ site, admin page
                        ▲                                  ▲
                        └──── accepted API changes ────────┤
                                                    write API (HTTP)
                                                      ▲        ▲
                                              MCP server   admin page, later editors
  • The vault is the source of truth. Greg edits in Obsidian; the service watches the folder and takes each change in.
  • Every change runs the same rules, whether it came from Obsidian, an agent or the admin page. A change that passes is accepted. A change that fails is held for review, and the site keeps showing the last accepted version, so a bad edit never reaches a page.
  • Changes through the API are written back into the vault once accepted, so Obsidian always shows what the site shows. The service marks its own writes as seen before writing, so the watcher does not take them in twice.
  • Deleting or moving a file unpublishes the note. Its history stays, and any version can be rolled back.

The rules

Pure functions in services/content/src/rules.ts, run in this order. The rules live in code, not in the vault, so no agent can loosen them.

Code Holds a change when
invalid The note fails its type's schema.
misplaced A settings note (_site, Now/_kinds, Store/_settings) is not at its fixed path.
duplicate-name, duplicate-slug Another live note already has the file name, or the same type already uses the address.
text-loss A body of 400 or more characters loses more than half in one edit.
secret New text looks like a key, token or password.
contact An agent adds an email address or phone number.
agent-edit An agent changes a note it did not write, unless the note has agentEdits: true.
agent-publish An agent publishes anything but a status or a Library item.
batch An agent makes more than 10 changes in 10 minutes.

Invalid input through the API is turned down at once with the problems, rather than held, so callers fix it themselves. A change from Obsidian that fails is held, because the file is already on disk.

Addresses

A slug change or a rename keeps the old address working as a redirect. When a note disappears and a note of the same type with the same text or title appears within ten minutes, that is treated as a move: redirects to the old path are pointed at the new one, so redirects never chain. A slug that comes back into use belongs to its note, not to the redirect.

The write API

ContentService.apply takes one change, create (type, fields, body) or update (path, the fields to change, null to remove one, and optionally the body), along with who made it. Every client uses it:

  • the MCP server, as agent;
  • the admin page and later editing tools, as greg (editor token) or admin;
  • the review queue: acceptHeld, rejectHeld (optionally restoring the live version into the vault), and rollback to any earlier revision.

An update first takes in any unsaved Obsidian edit to that file, so an API change never overwrites one. A note with a change waiting for review takes no other API change until that one is decided. A note's type never changes.

HTTP

Every route but /health needs Authorization: Bearer <token>. Tokens map to roles in CONTENT_TOKENS.

Route Who What
GET /health anyone Note, published and held counts.
GET /api/schema all Every note type with its JSON Schema.
GET /api/notes?type=&q=&limit=&offset=&full= all List or search. Readers see published notes only.
GET /api/note?path= or ?type=&slug= all One note with its backlinks. An old slug answers with { redirect }.
GET /api/backlinks?name= all Notes linking to a note.
GET /api/redirects all Every redirect.
GET /attachments/<file> all An image from _attachments.
GET /api/note/history?path=, /api/note/revision?path=&rev= agent, editor, admin History and old versions.
GET /api/problems agent, editor, admin Broken links and other whole-folder problems.
GET /api/held, /api/held/<id> agent, editor, admin The review queue; one entry comes with the current text for comparison.
POST /api/changes agent, editor, admin One change. 422 with reasons when turned down.
POST /api/held/<id>/accept, /reject editor, admin Decide a held change. { "restore": true } on reject puts the live version back into the vault.
POST /api/note/rollback admin { path, rev }
POST /api/scan editor, admin Compare the whole folder now.
/mcp agent, editor, admin The MCP server.

MCP

Streamable HTTP at /mcp, stateless. Tools: search, get_note, get_schema, list_held, set_status, add_library_item, update_fields and draft_post (always unpublished). There is no tool to delete, rename, move or write a raw file.

Running it

Variable Default
SITE_DIR required The vault's Site folder.
DATA_DIR data Holds content.db. Back it up: history and the review queue cannot be rebuilt from the vault.
PORT, HOST 4310, 0.0.0.0
CONTENT_TOKENS none role:token pairs, comma separated. Roles: reader, agent, editor, admin. Tokens are 24 characters or more.
ALLOWED_EMAILS none Addresses an agent may add without a hold.
HEALTHCHECK_URL none Pinged after every full scan.
NOTIFY_WEBHOOK_URL none Receives { event: "held", held } when a change is held.
SCAN_MINUTES 5 Full scan interval, on top of the file watch.
SITE_DIR=/path/to/Site CONTENT_TOKENS="reader:…,agent:…,editor:…,admin:…" npm run content

The store is SQLite (node:sqlite) with full-text search, so the service has no database server to run.