From cf9684ad3bf5a002b56fe45baffbf05bfc70cd18 Mon Sep 17 00:00:00 2001 From: Jacob Jove Date: Fri, 29 May 2026 15:07:03 -0400 Subject: [PATCH] feat(mcp): attach to live Yjs collaboration rooms with attributed tracked changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/mcp/package.json | 4 + .../src/__tests__/collab-attach-user.test.ts | 40 ++++ apps/mcp/src/__tests__/collab-attach.test.ts | 188 +++++++++++++++++ apps/mcp/src/__tests__/collab-export.test.ts | 46 +++++ apps/mcp/src/__tests__/protocol.test.ts | 14 +- apps/mcp/src/session-manager.ts | 190 +++++++++++++++++- apps/mcp/src/tools/collab.ts | 53 +++++ apps/mcp/src/tools/index.ts | 2 + pnpm-lock.yaml | 12 ++ 9 files changed, 538 insertions(+), 11 deletions(-) create mode 100644 apps/mcp/src/__tests__/collab-attach-user.test.ts create mode 100644 apps/mcp/src/__tests__/collab-attach.test.ts create mode 100644 apps/mcp/src/__tests__/collab-export.test.ts create mode 100644 apps/mcp/src/tools/collab.ts diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 64960fbf0e..4c902e8c9c 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -19,6 +19,9 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "ws": "^8.18.3", + "y-websocket": "catalog:", + "yjs": "catalog:", "zod": "^4.3.6" }, "devDependencies": { @@ -27,6 +30,7 @@ "superdoc": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/ws": "catalog:", "typescript": "catalog:" }, "publishConfig": { diff --git a/apps/mcp/src/__tests__/collab-attach-user.test.ts b/apps/mcp/src/__tests__/collab-attach-user.test.ts new file mode 100644 index 0000000000..4ee218f6bc --- /dev/null +++ b/apps/mcp/src/__tests__/collab-attach-user.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'bun:test'; +import { Doc as YDoc } from 'yjs'; +import { buildAttachEditor } from '../session-manager.js'; + +/** + * Tracked-change authoring over a collab attach requires a configured user. + * Without one, `forceTrackChanges` rejects the edit ("forceTrackChanges requires + * a user to be configured on the editor instance") because the gate reads + * `editor.options.user`, which is null on a bare attach. + * + * `buildAttachEditor` accepts an optional user and wires it into the headless + * Editor config so suggested edits can be attributed to a reviewer. + */ +describe('collab attach user identity (tracked-change user wiring)', () => { + // Scope: this asserts buildAttachEditor wires `user` into the Editor config — + // the input the forceTrackChanges gate reads. The gate's own behavior (rejecting + // tracked edits when no user is set) belongs to super-editor and is tested there. + it('configures the tracked-change user on the attach editor when supplied', async () => { + const ydoc = new YDoc({ gc: false }); + const user = { id: 'reviewer-1', name: 'Reviewer', email: 'reviewer@example.com' }; + + const editor = await buildAttachEditor(ydoc, 'test-room', user); + + // This is the exact value the forceTrackChanges gate reads. + expect(editor.options.user).toEqual(user); + + editor.destroy(); + }); + + it('leaves the user unset when none is supplied (default preserved)', async () => { + const ydoc = new YDoc({ gc: false }); + + const editor = await buildAttachEditor(ydoc, 'test-room'); + + // Editor default for `user` is null; the no-arg attach path must not invent one. + expect(editor.options.user ?? null).toBeNull(); + + editor.destroy(); + }); +}); diff --git a/apps/mcp/src/__tests__/collab-attach.test.ts b/apps/mcp/src/__tests__/collab-attach.test.ts new file mode 100644 index 0000000000..4b4c3174c9 --- /dev/null +++ b/apps/mcp/src/__tests__/collab-attach.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, mock, afterEach } from 'bun:test'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { readFile, unlink } from 'node:fs/promises'; +import { SessionManager } from '../session-manager.js'; + +/** + * `openRoom` orchestration (provider sync, sync timeout, awareness presence) and + * the `save()` room-guard run against a live Yjs WebSocket server in production, + * but the orchestration itself is socket-independent. Following the repo's collab + * test idiom (createProviderStub in Editor.replace-file.test.ts), we inject a stub + * provider so the logic is exercised without a server. The real socket transport + * is the only part left to the end-to-end check in the PR description. + */ + +type ProviderEvent = 'sync' | 'synced'; +type SyncHandler = (synced?: boolean) => void; + +/** + * Provider stub mirroring the repo's collab test idiom (`createProviderStub` in + * `Editor.replace-file.test.ts`): inert `on()`/`off()` that only register/deregister, + * an explicit `emit()` the test drives by hand, and `synced`/`isSynced` fields. This is + * what lets the suite exercise the real sync contract — the prior stub auto-emitted + * `sync(true)` from inside `on()` and exposed no `synced`/`isSynced`, so it could only + * test the one event shape and silently passed regardless of how the wait was wired. + * + * `synced: true` seeds an already-synced provider (pooled/reused) so openRoom's + * already-synced precheck resolves with no emit. + */ +function providerStub({ synced = false }: { synced?: boolean } = {}) { + const listeners: Record> = { + sync: new Set(), + synced: new Set(), + }; + const setLocalStateField = mock((_field: string, _value: unknown) => {}); + const destroy = mock(() => {}); + + const provider = { + awareness: { + setLocalStateField, + getStates: () => new Map(), + on() {}, + off() {}, + }, + synced, + isSynced: synced, + on(event: ProviderEvent, handler: SyncHandler) { + listeners[event]?.add(handler); + }, + off(event: ProviderEvent, handler: SyncHandler) { + listeners[event]?.delete(handler); + }, + emit(event: ProviderEvent, value?: boolean) { + for (const handler of listeners[event]) handler(value); + }, + destroy, + }; + + return provider; +} + +/** Flush microtasks so openRoom reaches its suspended sync await and wires listeners. */ +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe('superdoc_attach openRoom orchestration (stubbed provider)', () => { + const sm = new SessionManager(); + const opened: string[] = []; + const tempFiles: string[] = []; + + afterEach(async () => { + for (const id of opened.splice(0)) await sm.close(id).catch(() => {}); + for (const f of tempFiles.splice(0)) await unlink(f).catch(() => {}); + }); + + type Stub = ReturnType; + type AttachUser = { id?: string; name?: string; email?: string }; + + /** + * Kick off openRoom, let it reach its suspended sync await and wire the provider + * listener, then drive the provider to synced. `drive` defaults to the `sync(true)` + * edge; pass a custom driver to exercise the `synced` (no-arg) path. + */ + async function attach( + documentId: string, + stub: Stub, + { user, drive = (s: Stub) => s.emit('sync', true) }: { user?: AttachUser; drive?: (s: Stub) => void } = {}, + ) { + const p = sm.openRoom('ws://test/doc', documentId, undefined, user, { + createProvider: () => stub as unknown as never, + }); + await tick(); + drive(stub); + return p; + } + + it('returns a registered room session once the provider syncs', async () => { + const stub = providerStub(); + const session = await attach('room-sync', stub); + opened.push(session.id); + + expect(session.id).toMatch(/^room-/); + expect(session.filePath).toBeNull(); + expect(sm.list().some((s) => s.id === session.id)).toBe(true); + }); + + it('returns immediately when the provider is already synced before attach', async () => { + // Pooled/reused providers can be synced when handed to openRoom — no `sync`/`synced` + // re-emit follows. The bespoke wait would hang here until a spurious timeout; the + // helper's already-synced precheck resolves with no event. No emit is driven. + const stub = providerStub({ synced: true }); + const session = await sm.openRoom('ws://test/doc', 'room-presynced', undefined, undefined, { + createProvider: () => stub as unknown as never, + }); + opened.push(session.id); + + expect(session.id).toMatch(/^room-/); + expect(sm.list().some((s) => s.id === session.id)).toBe(true); + expect(stub.destroy).not.toHaveBeenCalled(); + }); + + it('resolves when the provider emits a no-arg synced event without a sync edge', async () => { + // Some providers emit only `synced` (no boolean), never `sync(boolean)`. The prior + // `sync`-only wait never resolved for these — this case is what makes the suite catch + // that regression. The helper listens to `synced` too. + const stub = providerStub(); + const session = await attach('room-synced-only', stub, { drive: (s) => s.emit('synced') }); + opened.push(session.id); + + expect(session.id).toMatch(/^room-/); + expect(sm.list().some((s) => s.id === session.id)).toBe(true); + }); + + it('broadcasts awareness presence when a user is supplied', async () => { + const stub = providerStub(); + const user = { id: 'reviewer-1', name: 'Reviewer', email: 'reviewer@example.com' }; + const session = await attach('room-presence', stub, { user }); + opened.push(session.id); + + expect(stub.awareness.setLocalStateField).toHaveBeenCalledWith('user', user); + }); + + it('does not touch awareness when no user is supplied', async () => { + const stub = providerStub(); + const session = await attach('room-nopresence', stub); + opened.push(session.id); + + expect(stub.awareness.setLocalStateField).not.toHaveBeenCalled(); + }); + + it('rejects and tears down the provider when initial sync times out', async () => { + // Never synced, never emits — openRoom's own timeout fires. + const stub = providerStub(); + await expect( + sm.openRoom('ws://test/doc', 'room-timeout', undefined, undefined, { + createProvider: () => stub as unknown as never, + syncTimeoutMs: 10, + }), + ).rejects.toThrow(/sync timeout/); + + expect(stub.destroy).toHaveBeenCalled(); + }); + + it('refuses to save a room session without an explicit output path', async () => { + const stub = providerStub(); + const session = await attach('room-save-guard', stub); + opened.push(session.id); + + await expect(sm.save(session.id)).rejects.toThrow(/without specifying an output path/); + }); + + it('saves a room session to an explicit output path', async () => { + const stub = providerStub(); + const session = await attach('room-save-ok', stub); + opened.push(session.id); + + const out = join(tmpdir(), `mcp-collab-${randomBytes(6).toString('hex')}.docx`); + tempFiles.push(out); + + const result = await sm.save(session.id, out); + expect(result.path).toBe(out); + expect(result.byteLength).toBeGreaterThan(0); + + const bytes = await readFile(out); + expect(bytes[0]).toBe(0x50); // 'P' — PK zip magic + expect(bytes[1]).toBe(0x4b); // 'K' + }); +}); diff --git a/apps/mcp/src/__tests__/collab-export.test.ts b/apps/mcp/src/__tests__/collab-export.test.ts new file mode 100644 index 0000000000..22a5b858b6 --- /dev/null +++ b/apps/mcp/src/__tests__/collab-export.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'bun:test'; +import { Doc as YDoc } from 'yjs'; +import { Editor } from 'superdoc/super-editor'; +import { buildAttachEditor } from '../session-manager.js'; + +/** + * Regression: `superdoc_save` over a collab attach reported + * "Exported document data is not binary (got undefined)". + * + * Root cause: the attach editor was built with no docx source, so + * `converter.convertedXml` carried none of the base OOXML parts that + * `Editor.exportDocx` unguarded-derefs (docProps/custom.xml, word/styles.xml, + * word/_rels/document.xml.rels). The deref threw; the swallowing catch in + * exportDocx returned `undefined`. + * + * A collab-joiner editor must export a valid .docx even with an empty Yjs doc. + */ +describe('collab attach export (superdoc_save over a room)', () => { + it('exports binary .docx bytes from a collab-joiner editor with no docx source', async () => { + const ydoc = new YDoc({ gc: false }); + const editor = await buildAttachEditor(ydoc, 'test-room'); + + const exported = await editor.exportDocument(); + + // The pre-fix failure mode: exportDocx throws on the missing parts and the + // catch swallows to undefined. + expect(exported).toBeDefined(); + + const bytes = exported instanceof Uint8Array ? exported : new Uint8Array(await (exported as Blob).arrayBuffer()); + + expect(bytes.byteLength).toBeGreaterThan(0); + // Valid .docx is a ZIP — "PK" local-file-header magic. + expect(bytes[0]).toBe(0x50); // 'P' + expect(bytes[1]).toBe(0x4b); // 'K' + + // Round-trip: the exported bytes must re-open as a structurally valid docx + // carrying the base OOXML parts that were previously missing. + const [parts] = (await Editor.loadXmlData(Buffer.from(bytes), true))!; + const names = new Set(parts.map((p: { name: string }) => p.name)); + expect(names.has('word/document.xml')).toBe(true); + expect(names.has('word/styles.xml')).toBe(true); + expect(names.has('word/_rels/document.xml.rels')).toBe(true); + + editor.destroy(); + }); +}); diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts index 4d7718b6d3..0c75d16d55 100644 --- a/apps/mcp/src/__tests__/protocol.test.ts +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -6,10 +6,11 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blank.docx'); const SERVER_ENTRY = resolve(import.meta.dir, '../index.ts'); -// 3 lifecycle + 10 intent tools from the generated catalog +// 4 lifecycle + 10 intent tools from the generated catalog const EXPECTED_TOOLS = [ // Lifecycle 'superdoc_open', + 'superdoc_attach', 'superdoc_save', 'superdoc_close', // Intent tools (from catalog.json) @@ -77,8 +78,10 @@ describe('MCP protocol integration', () => { const { tools } = await client.listTools(); // Multi-action intent tools should have an "action" property with an enum + // superdoc_attach, like superdoc_open, is a session-creating lifecycle tool — no action enum. const multiActionTools = tools.filter( - (t) => !['superdoc_open', 'superdoc_save', 'superdoc_close', 'superdoc_search'].includes(t.name), + (t) => + !['superdoc_open', 'superdoc_attach', 'superdoc_save', 'superdoc_close', 'superdoc_search'].includes(t.name), ); for (const tool of multiActionTools) { @@ -93,8 +96,11 @@ describe('MCP protocol integration', () => { await ready; const { tools } = await client.listTools(); - // All intent tools (not lifecycle open) should require session_id - const intentTools = tools.filter((t) => !['superdoc_open', 'superdoc_save', 'superdoc_close'].includes(t.name)); + // All intent tools (not session-creating lifecycle tools) should require session_id. + // superdoc_open and superdoc_attach both produce a session_id rather than consuming one. + const intentTools = tools.filter( + (t) => !['superdoc_open', 'superdoc_attach', 'superdoc_save', 'superdoc_close'].includes(t.name), + ); for (const tool of intentTools) { const schema = tool.inputSchema as { properties?: Record; required?: string[] }; diff --git a/apps/mcp/src/session-manager.ts b/apps/mcp/src/session-manager.ts index 399c2a62d6..411bf7ed70 100644 --- a/apps/mcp/src/session-manager.ts +++ b/apps/mcp/src/session-manager.ts @@ -1,17 +1,32 @@ import { access, readFile, writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import { resolve, basename } from 'node:path'; -import { Editor } from 'superdoc/super-editor'; +import { Editor, getStarterExtensions, onCollaborationProviderSynced } from 'superdoc/super-editor'; +import type { CollaborationProvider } from 'superdoc/super-editor'; import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; +import { Doc as YDoc } from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; export interface Session { id: string; - filePath: string; + filePath: string | null; editor: Editor; api: DocumentApi; openedAt: number; + provider?: WebsocketProvider; +} + +export interface OpenRoomOptions { + /** + * Factory for the collaboration provider, given the room's Yjs doc. Defaults to + * a real `y-websocket` `WebsocketProvider`. Injected in tests with a stub so the + * sync / timeout / presence orchestration can be exercised without a live socket. + */ + createProvider?: (ydoc: YDoc) => WebsocketProvider | Promise; + /** Milliseconds to await initial sync before rejecting. Default 10000. */ + syncTimeoutMs?: number; } export class SessionManager { @@ -65,12 +80,111 @@ export class SessionManager { return session; } + async openRoom( + wsUrl: string, + documentId: string, + token?: string, + user?: AttachUser, + opts: OpenRoomOptions = {}, + ): Promise { + const ydoc = new YDoc({ gc: false }); + const syncTimeoutMs = opts.syncTimeoutMs ?? 10_000; + + const createProvider = + opts.createProvider ?? + (async (doc: YDoc) => { + // y-websocket needs a WebSocket constructor; Node has no global one, so supply `ws`. + const { default: WebSocket } = await import('ws'); + // Auth token is passed as a `params` query entry — the same mechanism SuperDoc's + // own y-websocket provider uses (createSuperDocProvider in + // packages/superdoc/src/core/collaboration/collaboration.js). The y-websocket + // protocol has no header channel, so the token rides the connect URL. + return new WebsocketProvider(wsUrl, documentId, doc, { + WebSocketPolyfill: WebSocket as unknown as typeof globalThis.WebSocket, + params: token ? { token } : {}, + }); + }); + + const provider = await createProvider(ydoc); + + // Presence: advertise the attach in the room's awareness so collaborators see + // who is suggesting changes (SuperDoc surfaces `awareness.user` via + // awarenessStatesToArray → the participant list; color is assigned on the + // viewer's side from its palette, so id/name/email is enough here). Only the + // `user` field is broadcast — the attach editor is built without + // `collaborationProvider`, so CollaborationCursor stays inert and no cursor is + // published. A "follow the agent" cursor is deliberately out of scope: a + // headless agent's ProseMirror selection jumps per mutation, so echoing it raw + // would teleport the caret rather than signal the region under review; a + // meaningful cursor needs curated/throttled position broadcasting. See PR. + if (user) { + provider.awareness.setLocalStateField('user', user); + } + + // Await initial sync before editor construction so the fragment is populated. + // Delegate to the codebase's canonical sync waiter (onCollaborationProviderSynced): + // it pre-checks an already-synced provider, listens to BOTH `sync(boolean)` and the + // no-arg `synced` event, reads `synced`/`isSynced`, and re-checks after wiring to + // close the register-after-sync race. The previous `sync`-only wait handled none of + // these — adequate for the default WebsocketProvider (whose `sync(true)` is strictly + // async, fired from `websocket.onmessage` after registration), 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. + await new Promise((resolve, reject) => { + let cleanup = () => {}; + const timeout = setTimeout(() => { + cleanup(); + provider.destroy(); + reject(new Error(`sync timeout (${syncTimeoutMs}ms)`)); + }, syncTimeoutMs); + // If the provider is already synced, this invokes the callback synchronously + // (before `cleanup` is reassigned) — `timeout` is already set, so it's cleared. + cleanup = onCollaborationProviderSynced(provider as unknown as CollaborationProvider, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + // Post-sync construction can throw (buildAttachEditor's `loadXmlData!` non-null + // assertion, an adapter error). Without this guard, the now-synced provider — a live + // socket plus `_checkInterval`/`_resyncInterval` timers and a `process.on('exit')` + // handler — would leak: it's neither stored on a session nor destroyed (only the + // timeout path above tore it down). Destroy before rethrowing so a failed openRoom + // leaves nothing live. Applies to the default provider too, not just injected ones. + try { + const editor = await buildAttachEditor(ydoc, documentId, user); + + const adapters = getDocumentApiAdapters(editor); + const api = createDocumentApi(adapters); + + const id = generateRoomSessionId(documentId); + + const session: Session = { + id, + filePath: null, + editor, + api, + openedAt: Date.now(), + provider, + }; + + this.sessions.set(id, session); + return session; + } catch (err) { + provider.destroy(); + throw err; + } + } + async save(sessionId: string, outputPath?: string): Promise<{ path: string; byteLength: number }> { const session = this.get(sessionId); - const targetPath = outputPath ? resolve(outputPath) : session.filePath; + if (!outputPath && !session.filePath) { + throw new Error('Cannot save a room session without specifying an output path.'); + } + const targetPath = outputPath ? resolve(outputPath) : resolve(session.filePath!); const exported = await session.editor.exportDocument(); - const bytes = toUint8Array(exported); + const bytes = await toBytes(exported); await writeFile(targetPath, bytes); @@ -81,18 +195,20 @@ export class SessionManager { const session = this.sessions.get(sessionId); if (!session) return; + session.provider?.destroy(); session.editor.destroy(); this.sessions.delete(sessionId); } async closeAll(): Promise { for (const session of this.sessions.values()) { + session.provider?.destroy(); session.editor.destroy(); } this.sessions.clear(); } - list(): Array<{ id: string; filePath: string; openedAt: number }> { + list(): Array<{ id: string; filePath: string | null; openedAt: number }> { return Array.from(this.sessions.values()).map((s) => ({ id: s.id, filePath: s.filePath, @@ -101,6 +217,60 @@ export class SessionManager { } } +/** Identity for attributing tracked changes authored over a collab attach. */ +export interface AttachUser { + id?: string; + name?: string; + email?: string; +} + +/** + * Build the headless Editor for a collaborative attach session. + * + * The document body arrives via the Yjs fragment (`ydoc`); `content` only seeds + * the base OOXML parts (`converter.convertedXml`) that `Editor.exportDocx` derefs + * on save. A bare `content: []` leaves those parts empty, so export throws and the + * swallowing catch returns `undefined` ("not binary (got undefined)"). Seeding the + * blank-docx template gives export valid scaffolding while Yjs still drives the body + * (Editor only seeds the initial PM doc from `content` when no `ydoc` is present). + * + * An optional `user` configures the tracked-change author so an MCP client can + * author attributable tracked (suggested) edits over the attach; without it, + * `forceTrackChanges` rejects tracked edits. The file-open path (`open`) already + * sets a default user; this brings the attach path to parity. + */ +export async function buildAttachEditor(ydoc: YDoc, documentId: string, user?: AttachUser): Promise { + const blankBytes = Buffer.from(BLANK_DOCX_BASE64, 'base64'); + const [content, , mediaFiles, fonts] = (await Editor.loadXmlData(blankBytes, true))!; + + return new Editor({ + isHeadless: true, + mode: 'docx', + documentId, + extensions: getStarterExtensions(), + ydoc, + content, + mediaFiles, + fonts, + fileSource: blankBytes, + // Without a user, `forceTrackChanges` rejects tracked edits. Supplying one + // lets the caller author attributable tracked changes over the attach. + ...(user ? { user } : {}), + }); +} + +function generateRoomSessionId(documentId: string): string { + const stem = documentId.replace(/\//g, '-').replace(/[^a-zA-Z0-9._-]+/g, '-') || 'room'; + const normalized = + stem + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') || 'room'; + const suffix = randomBytes(4).toString('hex').slice(0, 6); + return `room-${normalized.slice(0, 50)}-${suffix}`; +} + function generateSessionId(filePath: string): string { const stem = basename(filePath).replace(/\.[^.]+$/, ''); const normalized = @@ -113,11 +283,17 @@ function generateSessionId(filePath: string): string { return `${normalized.slice(0, 57)}-${suffix}`; } -function toUint8Array(data: unknown): Uint8Array { +async function toBytes(data: unknown): Promise { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); if (ArrayBuffer.isView(data)) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } - throw new Error('Exported document data is not binary.'); + // Blob (incl. cross-realm/polyfilled instances where `instanceof Blob` is false): duck-type + // on arrayBuffer(). Headless/collab exportDocument() returns a Blob. + if (data && typeof (data as { arrayBuffer?: unknown }).arrayBuffer === 'function') { + return new Uint8Array(await (data as Blob).arrayBuffer()); + } + const desc = data == null ? String(data) : `${typeof data}/${(data as object).constructor?.name ?? 'unknown'}`; + throw new Error(`Exported document data is not binary (got ${desc}).`); } diff --git a/apps/mcp/src/tools/collab.ts b/apps/mcp/src/tools/collab.ts new file mode 100644 index 0000000000..c261158a09 --- /dev/null +++ b/apps/mcp/src/tools/collab.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SessionManager } from '../session-manager.js'; + +export function registerCollabTools(server: McpServer, sessions: SessionManager): void { + server.registerTool( + 'superdoc_attach', + { + title: 'Attach to Collaboration Room', + description: + 'Attach to a live SuperDoc Yjs collaboration room via WebSocket and return a session_id for use with all other superdoc tools. Awaits initial sync before returning.', + inputSchema: { + ws_url: z.string().describe('WebSocket URL base, e.g. ws://localhost:4444/doc'), + document_id: z.string().describe('Document/room identifier'), + token: z.string().optional().describe('Optional auth token passed as query param'), + user: z + .object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + }) + .optional() + .describe( + 'Optional identity for attributing tracked changes. Required to author tracked (suggested) edits over the attach; direct edits work without it.', + ), + }, + annotations: { readOnlyHint: false }, + }, + async ({ ws_url, document_id, token, user }) => { + try { + const session = await sessions.openRoom(ws_url, document_id, token, user); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ session_id: session.id }), + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to attach to room: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/apps/mcp/src/tools/index.ts b/apps/mcp/src/tools/index.ts index 566c966464..dfcb62b834 100644 --- a/apps/mcp/src/tools/index.ts +++ b/apps/mcp/src/tools/index.ts @@ -2,8 +2,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { SessionManager } from '../session-manager.js'; import { registerLifecycleTools } from './lifecycle.js'; import { registerIntentTools } from './intent.js'; +import { registerCollabTools } from './collab.js'; export function registerAllTools(server: McpServer, sessions: SessionManager): void { registerLifecycleTools(server, sessions); registerIntentTools(server, sessions); + registerCollabTools(server, sessions); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fd9c69405..b4e38b511e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,15 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.28.0(zod@4.3.6) + ws: + specifier: ^8.18.3 + version: 8.20.0 + y-websocket: + specifier: 'catalog:' + version: 3.0.0(yjs@13.6.30) + yjs: + specifier: 'catalog:' + version: 13.6.30 zod: specifier: ^4.3.6 version: 4.3.6 @@ -593,6 +602,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.2 + '@types/ws': + specifier: 'catalog:' + version: 8.18.1 superdoc: specifier: workspace:* version: link:../../packages/superdoc