feat(mcp): attach to live Yjs collaboration rooms (co-edit + attributed tracked changes)#3569
Open
jacobjove wants to merge 2 commits into
Open
feat(mcp): attach to live Yjs collaboration rooms (co-edit + attributed tracked changes)#3569jacobjove wants to merge 2 commits into
jacobjove wants to merge 2 commits into
Conversation
5345d8c to
c0dad77
Compare
c7a264d to
12c6f6e
Compare
…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>
12c6f6e to
cf9684a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-editorcore edits:superdoc_attach({ ws_url, document_id, token?, user? })— connects to a Yjs room over WebSocket, awaits initial sync, returns asession_idusable with every existing tool. Likesuperdoc_open, it creates a session rather than consuming one.user: { id?, name?, email? }is threaded into the headlessEditorconfig. Without it,superdoc_mutations({ changeMode: "tracked" })over an attach throwsforceTrackChanges requires a user to be configured on the editor instance(the gate reads exactlythis.options.user). The file-open path already defaulted auser; attach was the only path missing the seam. Caller-supplied so changes attribute to the real reviewer, not a hardcoded identity.useris supplied, the attach advertises it viaprovider.awareness.setLocalStateField('user', …), so collaborators see who is suggesting changes (SuperDoc surfaces this throughawarenessStatesToArray→ 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.convertedXmllacks the base OOXML parts andexportDocxthrows on the first unguarded deref. Fix: seed the blank-docx template (Editor.loadXmlData(blankDocxBytes, true)) soSuperConverterpopulates the standard parts. With a ydoc present,#createInitialStateonly uses the seeded content as export scaffolding — Yjs still drives the live body.Sync-wait hardening + provider lifecycle
openRoomawaits sync via the codebase'sonCollaborationProviderSyncedhelper (exported fromsuperdoc/super-editor) instead of a bespokeon('sync')wait. The defaultWebsocketProviderwas already safe — itssync(true)fires async fromwebsocket.onmessage, after the handler is registered — but thecreateProviderseam 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-argsyncedevent. The helper pre-checks already-synced, listens to bothsync(boolean)andsynced, 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'sloadXmlData!assertion) destroys the provider before rethrowing, rather than orphaning a live socket plus its resync timers and aprocess.on('exit')handler. The attach error path also reports a non-Errorthrow as its string form instead ofundefined.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(viacatalog:),ws+@types/ws. The lockfile delta is exactly the fourapps/mcpimporter entries — no transitive drift (pnpm install --frozen-lockfileclean on the repo-pinnedpnpm@10.25.0, against currentmain).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:
CollaborationCursoractivates only wheneditor.options.collaborationProvideris set; the attach editor is built withydoconly, so the cursor plugin is inert and the attach broadcasts no caret. That's intentional: a headless agent's ProseMirror selection jumps persuperdoc_mutationscall, 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.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
usersourced 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.ts—openRoomorchestration against a stubbed provider (following the repo'screateProviderStubidiom — inerton/off, explicitemit,synced/isSyncedfields): returns a registered room session on thesyncedge, on an already-synced provider with no emit, and on a no-argsyncedevent with nosyncedge; broadcasts awareness presence iff auseris 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 withword/document.xml+word/styles.xml+word/_rels/document.xml.rels.apps/mcp/src/__tests__/collab-attach-user.test.ts— assertseditor.options.useris set with a user,nullwithout.protocol.test.tsupdated to includesuperdoc_attachin the tool-list / action-enum / session_id assertions (it's a session-creating lifecycle tool likesuperdoc_open).apps/mcpsuite green: 46 pass / 0 fail (pnpm run build:superdocthenpnpm --prefix apps/mcp run test, matchingci-mcp.yml), run against currentmainafter rebase.user→ tracked mutation succeeds (no "requires a user") →superdoc_track_changes listshows 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-websocketserver — which is what the live-server check above covers.Note for reviewers (latent, left untouched here)
Editor.exportDocx()wraps its whole body intry { … } catch (e) { this.emit('exception', …); console.error(e); }with no return in the catch, so on any export error it silently returnsundefinedand the realTypeErroronly reaches stderr. For a collab joiner the throw originates earlier (SuperConverterheader/footer export with emptyconvertedXml). 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
pnpm --prefix apps/mcp run test, afterbuild:superdoc)prettier --checkclean on changed files)