Skip to content

Add a get_changes MCP tool for changes since Last left #345

Description

@HMarzban

Parent

#328. Related: #230, #238

What to build

A person comes back after a week and asks their AI app "What changed in the offsite plan since I last looked?". The AI app sees only the current text, so it cannot answer. docs.plus already computes this per reader: the Change digest starts its Change window at Last left.

After this change, a read-only MCP tool get_changes(slug, since?) returns what changed since the caller's Last left, or since a given time. It lists each changed Section with its status, size, a short excerpt and a heading link. It uses the digest's clock and compute, and adds no second clock.

Acceptance criteria

  • get_changes is registered, read-only (readOnlyHint: true, openWorldHint: false), and takes slug plus an optional ISO 8601 since.
  • With no since, the window starts at the caller's Last left for that document, clamped by the same rule as the digest.
  • The caller may have no Last left: no live membership row, or a null stamp. Then the tool refuses and asks for an explicit since.
  • An explicit since is clamped by the same rule. A future since is clamped to now.
  • The result names its start and says whether it is Last left (from_last_left). from_last_left is true only when the clamp did not move the start, as in the digest.
  • The result ends at the last save: "Up to the last save at ."
  • Each changed Section gives status, words added, words removed, section_id, and a heading link from the Tell self-hosters to set APP_URL, because email and connected-app links use it #337 helper. A removed Section links to the document.
  • A Section whose magnitude is null (a formatting-only edit) still shows, with its status and no word counts.
  • Unchanged and nameless Sections are left out. runs are never returned.
  • People are listed once for the whole window as "people who edited in this window", by display_name only. The caller shows as "you". A person with no display_name is not named; the line ends "and N others". No person is tied to a single Section.
  • No documentId appears in the text or in structuredContent.
  • Heading text, excerpts and names sit inside frameDocumentText. Guidance and the cap note sit outside it.
  • The text and structuredContent list the same first 50 Sections at most. The text then says how many were left out and points to read_document with section_id.
  • A Private document refuses a non-owner with the existing text, before any compute.
  • The tool never changes Last left.
  • docs/mcp/reference.md, docs/mcp/README.md and apps/hocuspocus.server/API.md §MCP connector describe the tool.

Blocked by

Agent brief

Type: AFK — an agent can finish this alone.

Category: enhancement

Current behavior:

  • No MCP tool reaches the compute. mcp.init gets no compute (apps/hocuspocus.server/src/modules/mcp/module.ts:14-30).
  • documentChanges.init builds the compute inside its router and returns only router (apps/hocuspocus.server/src/modules/document-changes/module.ts:11-21). src/index.ts:98-103 calls it with prisma, logger, verifyServiceRole and getOwnerProfiles.
  • The compute is a pure read over two stored versions (createComputeDocumentChanges, apps/hocuspocus.server/src/modules/document-changes/domain/computeDocumentChanges.ts:66). With scope: 'headings' it returns a SectionNode tree. SectionChange has no author field (apps/hocuspocus.server/src/modules/document-changes/types.ts:51-61). Contributors belong to the whole window (ChangeSummary.contributors). ProfileLite has display_name, full_name, avatar_* and status (apps/hocuspocus.server/src/lib/profiles.ts:9-16).
  • The result carries documentId (types.ts:84-86).
  • The digest clamps its start in resolveDigestSince (apps/hocuspocus.server/src/lib/email/digestContentChanges.ts:51-66): not before the retention floor, not after now. It reports fromLastLeft only when the clamp did not move the start (digestContentChanges.ts:191).
  • The digest reads Last left from workspace_members.last_connection_closed_at, by exact-case workspace_id, member_id, and left_at is null (readDigestLastVisits in apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:120-147). parseLastVisitStamp parses it (apps/hocuspocus.server/src/lib/email/digestMessage.ts:43).
  • placeSections walks the tree, skips unchanged and nameless rows, and links a removed Section to the document (digestContentChanges.ts:77-115).
  • Only mark_document_connection_closed writes Last left. The collab server calls it when a live session closes (apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts:145). An MCP call never opens a live session.

Desired behavior:

  1. documentChanges.init also returns its compute. src/index.ts passes it into mcp.init, with retentionDays: config.worker.autosaveRetentionDays (the value the digest uses, pgmqConsumer.ts:192). Add both to MCP InitDeps and ServerFactoryDeps, so registerDocumentTools(server, deps, context) receives them. Export the ComputeDocumentChanges type from modules/document-changes/index.ts. Import through that barrel, never by deep path (see Collapse the digest payload contract, batch the last-visit read, and stop deep-importing document-changes #238 item 3).
  2. Move the clamp out of resolveDigestSince into one shared function in apps/hocuspocus.server/src/lib/, for example clampChangeWindowStart(start, now, retentionDays). Its file imports nothing that opens Redis, so unit tests can load it. resolveDigestSince calls it. The digest tests pass unchanged.
  3. A small Last left reader in apps/hocuspocus.server/src/modules/mcp/infra/. It uses the service-role client already in InitDeps.supabase and the same filters as the digest. member_id is always caller.sub; never take a member id as input. With no Supabase client, a call without since refuses and asks for since.
  4. Register get_changes in apps/hocuspocus.server/src/modules/mcp/tools/documentTools.ts, beside read_document, so it reuses frameDocumentText. Order: openDocument(slug, 'read'), resolve and clamp since, compute with until: now and scope: 'headings', then render.
  5. Map compute failures: not-found → notFoundText; anchor-missing → "No saved version covers that time. Try a later since."; undecodable → the generic failure text.
  6. Text result: one summary line (Sections added, removed, changed, moved; words added and removed; versions). Then the people line. Then one line per changed Section: status, magnitude, heading, excerpt or removed excerpt, section_id, link. End with "Up to the last save at . Call read_document for live text."
  7. structuredContent: slug, since, head_at, from_last_left, changed, the counts, and per Section section_id, status, words_added, words_removed, url. No heading text, excerpt or name.
  8. Tool description: "What changed in a document since you last left it, up to the last save. …" Add it to the docs tables. Add one example prompt in docs/mcp/README.md: "What changed in my launch plan since I last looked?"

Where to start: init in apps/hocuspocus.server/src/modules/document-changes/module.ts; resolveDigestSince and placeSections in apps/hocuspocus.server/src/lib/email/digestContentChanges.ts; readDigestLastVisits in apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts; read_document in apps/hocuspocus.server/src/modules/mcp/tools/documentTools.ts; InitDeps and ServerFactoryDeps in apps/hocuspocus.server/src/modules/mcp/types.ts. Use z.iso.datetime({ offset: true }) for since, as apps/hocuspocus.server/src/modules/document-changes/http/schema.ts:12 does. Line numbers are hints as of 2026-09-28; the agent searches by symbol.

Rules that apply:

  • apps/hocuspocus.server/CLAUDE.md §MCP Connector, §HTTP Modules, and §Digest Email Links And Counts ("fromLastLeft must agree with the clamp").
  • CONTEXT.md §Document changes (Section, Change window, Magnitude, Change digest, Last left): one clock, one compute.
  • apps/hocuspocus.server/CLAUDE.md §Hocuspocus Server, the two "Last left" bullets: only a live session moves Last left, after 10 s of visible reading.
  • AGENTS.md §Test Policy (c): since resolution and the Section projection branch densely, so they get one unit test. No other new test.
  • Docs and tool text follow .cursor/skills/tech-writer/SKILL.md §Simplified English (house standard).

Verify:

  • Add one unit test in apps/hocuspocus.server/src/modules/mcp/__tests__/unit/ for the pure parts. since resolution cases: Last left, explicit, clamped to the floor, future, missing. Section projection cases: unchanged skipped, nameless skipped, removed links to the document, cap at 50. Prove it by sabotage: remove the clamp call, then the unchanged filter. Each removal must fail the test.
  • cd apps/hocuspocus.server && bun test src/modules/mcp tests/unit/contentChangeDigest.test.ts src/modules/document-changes passes.
  • bun run --filter @docs.plus/hocuspocus typecheck exits 0. bun run lint and bun run format pass at the repo root.
  • Local stack (make dev-local). Connect an MCP client to http://localhost:4000/api/mcp as a member of a document. Add the MCP inspector's origin to ALLOWED_ORIGINS if you use it.
    1. Open the document in the pad signed in. Keep the tab visible for at least 10 s, then close it, so Last left is stored. Edit it as another user and wait about a minute for the save.
    2. Call get_changes with no since. It lists the changed Sections with links, and from_last_left is true.
    3. Call it on a document you never opened. It refuses and asks for since. Call it again with since. It answers.
    4. Call it on another person's Private document. It refuses with the private text.
    5. Check Last left in local Supabase Studio. It did not move.
    6. On the largest local document, read the durationMs of the "Document changes computed" log line. Put the number in your final report. Never run a decode inside a production container.

Out of scope

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions