Skip to content

feat(mcp): attach to live Yjs collaboration rooms (co-edit + attributed tracked changes)#3569

Open
jacobjove wants to merge 2 commits into
superdoc-dev:mainfrom
jacobjove:feature/mcp-collab-attach
Open

feat(mcp): attach to live Yjs collaboration rooms (co-edit + attributed tracked changes)#3569
jacobjove wants to merge 2 commits into
superdoc-dev:mainfrom
jacobjove:feature/mcp-collab-attach

Conversation

@jacobjove
Copy link
Copy Markdown

@jacobjove jacobjove commented May 29, 2026

Closes #3568.

Opened as a draft alongside #3568.

What this adds

A collaboration-attach path for apps/mcp, alongside the existing file-open path, so an MCP client can join a live Yjs room instead of only round-tripping a local .docx. The motivating use case is agent-assisted review: suggest tracked redlines into the document a human has open, attributed to a named reviewer, for accept/reject.

Capabilities, all kept in the MCP layer — no super-editor core edits:

  1. superdoc_attach({ ws_url, document_id, token?, user? }) — connects to a Yjs room over WebSocket, awaits initial sync, returns a session_id usable with every existing tool. Like superdoc_open, it creates a session rather than consuming one.
  2. Tracked-change attribution. user: { id?, name?, email? } is threaded into the headless Editor config. Without it, superdoc_mutations({ changeMode: "tracked" }) over an attach throws forceTrackChanges requires a user to be configured on the editor instance (the gate reads exactly this.options.user). The file-open path already defaulted a user; attach was the only path missing the seam. Caller-supplied so changes attribute to the real reviewer, not a hardcoded identity.
  3. Presence. When user is supplied, the attach advertises it via provider.awareness.setLocalStateField('user', …), so collaborators see who is suggesting changes (SuperDoc surfaces this through awarenessStatesToArray → the participant list; the viewer assigns a color from its palette). Only the participant-list presence is published — see the design note below on why no cursor.
  4. Collab-aware save export. A joiner editor is built with no docx source (content arrives via the Yjs fragment), so convertedXml lacks the base OOXML parts and exportDocx throws on the first unguarded deref. Fix: seed the blank-docx template (Editor.loadXmlData(blankDocxBytes, true)) so SuperConverter populates the standard parts. With a ydoc present, #createInitialState only uses the seeded content as export scaffolding — Yjs still drives the live body.

Sync-wait hardening + provider lifecycle

openRoom awaits sync via the codebase's onCollaborationProviderSynced helper (exported from superdoc/super-editor) instead of a bespoke on('sync') wait. The default WebsocketProvider was already safe — its sync(true) fires async from websocket.onmessage, after the handler is registered — but the createProvider seam admits alternate providers the bespoke wait would hang on until a spurious timeout: ones already synced when returned (pooled/reused), or ones that emit only the no-arg synced event. The helper pre-checks already-synced, listens to both sync(boolean) and synced, and re-checks after wiring to close the register-after-sync race.

Post-sync editor/adapter construction is wrapped so a throw there (e.g. buildAttachEditor's loadXmlData! assertion) destroys the provider before rethrowing, rather than orphaning a live socket plus its resync timers and a process.on('exit') handler. The attach error path also reports a non-Error throw as its string form instead of undefined.

Commit

Single squashed commit, rebased onto current main: feat(mcp): attach to live Yjs collaboration rooms with attributed tracked changes.

Dependencies added: yjs, y-websocket (via catalog:), ws + @types/ws. The lockfile delta is exactly the four apps/mcp importer entries — no transitive drift (pnpm install --frozen-lockfile clean on the repo-pinned pnpm@10.25.0, against current main).

Open design question — presence cursor (input wanted)

This PR publishes participant-list presence but not a cursor, deliberately. Two reasons, and I'd like your call on whether to take either further in this PR or a follow-up:

  • Cursor. CollaborationCursor activates only when editor.options.collaborationProvider is set; the attach editor is built with ydoc only, so the cursor plugin is inert and the attach broadcasts no caret. That's intentional: a headless agent's ProseMirror selection jumps per superdoc_mutations call, so echoing it raw would teleport the caret rather than signal "the agent is reviewing §7." A useful "follow the agent" cursor needs curated/throttled position broadcasting (park at the region under review), which is a small design rather than a flag flip.
  • Identity label. Presence currently reuses the attribution user. If that identity is a real person (e.g. the operating attorney, for the redline author record), the participant list would imply a human is present. You may want a distinct presence label (e.g. {name}'s AI) decoupled from the redline author. Happy to wire whichever you prefer.

(#3568 raised awareness as an open question — "whether you'd prefer user sourced from the Yjs awareness state rather than a tool param." Note that's the read direction; for an agent, awareness holds other participants' identities, so the agent must declare its own — hence the tool param. The presence above is the write direction.)

Testing

  • apps/mcp/src/__tests__/collab-attach.test.tsopenRoom orchestration against a stubbed provider (following the repo's createProviderStub idiom — inert on/off, explicit emit, synced/isSynced fields): returns a registered room session on the sync edge, on an already-synced provider with no emit, and on a no-arg synced event with no sync edge; broadcasts awareness presence iff a user is supplied; rejects + tears down the provider on sync timeout; save() refuses a room session without an output path and writes a valid PK-zip with one.
  • apps/mcp/src/__tests__/collab-export.test.ts — joiner-editor export → valid PK-zip with word/document.xml + word/styles.xml + word/_rels/document.xml.rels.
  • apps/mcp/src/__tests__/collab-attach-user.test.ts — asserts editor.options.user is set with a user, null without.
  • protocol.test.ts updated to include superdoc_attach in the tool-list / action-enum / session_id assertions (it's a session-creating lifecycle tool like superdoc_open).
  • Full apps/mcp suite green: 46 pass / 0 fail (pnpm run build:superdoc then pnpm --prefix apps/mcp run test, matching ci-mcp.yml), run against current main after rebase.
  • Verified end-to-end against a live collab server: attach with user → tracked mutation succeeds (no "requires a user") → superdoc_track_changes list shows the change attributed to the supplied author → save exports a valid OOXML file with the live-room body intact.

Left to the end-to-end check

The stubbed-provider tests cover the orchestration (sync resolution, timeout, presence wiring, save-guard, export scaffolding, user wiring). The only thing they don't exercise is the real WebSocket transport — actual connect/sync against a running y-websocket server — which is what the live-server check above covers.

Note for reviewers (latent, left untouched here)

Editor.exportDocx() wraps its whole body in try { … } catch (e) { this.emit('exception', …); console.error(e); } with no return in the catch, so on any export error it silently returns undefined and the real TypeError only reaches stderr. For a collab joiner the throw originates earlier (SuperConverter header/footer export with empty convertedXml). Seeding the template avoids the throw; I left the swallowing catch alone since it's on the shared export path and changing its contract is out of scope for this PR. Filed separately as #3579.

Checklist

…cked changes

Add `superdoc_attach` so an MCP client can join a live SuperDoc Yjs
collaboration room over WebSocket (openRoom + WebsocketProvider, awaiting
initial sync) instead of only round-tripping a local .docx. The returned
session_id works with every existing tool. The motivating use case is
agent-assisted review: suggesting tracked redlines into a document a human has
open, attributed to a named reviewer, for accept/reject.

Tracked-change attribution: `superdoc_attach` accepts an optional
user ({ id, name, email }) threaded through openRoom into buildAttachEditor's
headless Editor config. Without a configured user, forceTrackChanges rejects
tracked edits, so suggested edits over an attach could not be attributed. The
file-open path already sets a default user; this brings the attach path to
parity, scoped to a caller-supplied identity.

Collab-aware save export: a joiner editor is built with no docx source, so
converter.convertedXml carried none of the base OOXML parts that
Editor.exportDocx dereferences. The deref threw and (via exportDocx's catch)
surfaced as "not binary (got undefined)". buildAttachEditor now seeds the
blank-docx template via Editor.loadXmlData so export has valid scaffolding;
Yjs still drives the body (the initial ProseMirror doc is seeded from `content`
only when no ydoc is present). save() rejects room saves without an explicit
output path.

Sync-wait robustness: the initial-sync wait delegates to the codebase's
canonical onCollaborationProviderSynced helper instead of a bespoke on('sync')
wait. The helper pre-checks an already-synced provider, listens to both
sync(boolean) and the no-arg synced event, and re-checks after wiring to close
the register-after-sync race. The default WebsocketProvider was already safe
(its sync(true) fires strictly async from websocket.onmessage), but the
createProvider seam admits alternate providers — pooled/already-synced, or
synced-only emitters — that the bespoke wait would hang on until a spurious
timeout. Post-sync editor/adapter construction is now wrapped so a throw there
destroys the provider before rethrowing, rather than leaking a live socket, its
resync timers, and a process exit handler (this applies to the default provider
too). The attach error path also reports non-Error throws as their string form
instead of "undefined".

Tests: protocol.test.ts now covers superdoc_attach in the tool-list,
action-enum, and session_id assertions (like superdoc_open, it creates a
session rather than consuming one). New collab-export.test.ts asserts a binary
PK-zip round-trip (word/document.xml + styles.xml + document.xml.rels) from a
collab-joiner editor, and collab-attach-user.test.ts asserts the tracked-change
user is configured when supplied and left unset otherwise. collab-attach.test.ts
is reshaped to the repo's provider-stub idiom (inert on(), explicit emit,
synced/isSynced fields) and adds already-synced and synced-only sync cases, so
the suite exercises the sync contract rather than a single auto-emitted edge.

Adds yjs, y-websocket, and ws dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jacobjove jacobjove force-pushed the feature/mcp-collab-attach branch from 12c6f6e to cf9684a Compare May 29, 2026 21:38
@jacobjove jacobjove marked this pull request as ready for review May 30, 2026 03:27
@jacobjove jacobjove requested a review from a team as a code owner May 30, 2026 03:27
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 9 files

Re-trigger cubic

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP: attach to live Yjs collaboration rooms (co-edit + attributed tracked changes)

1 participant