From cf9684ad3bf5a002b56fe45baffbf05bfc70cd18 Mon Sep 17 00:00:00 2001 From: Jacob Jove Date: Fri, 29 May 2026 15:07:03 -0400 Subject: [PATCH 1/8] 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 From 9dba7a7c585e734bf30cb6e5a1a07e2fd0b93a6f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 15 Jul 2026 22:01:24 +0300 Subject: [PATCH 2/8] feat(mcp): add live collaboration demo --- apps/mcp/src/__tests__/intent-schema.test.ts | 2 +- apps/mcp/src/create-server.ts | 36 ++ apps/mcp/src/server.ts | 33 +- apps/mcp/src/session-manager.ts | 11 +- apps/mcp/tsconfig.json | 8 +- demos/manifest.json | 18 + .../mcp-collaboration/IMPLEMENTATION_PLAN.md | 199 +++++++++++ demos/mcp-collaboration/Makefile | 37 ++ demos/mcp-collaboration/README.md | 106 ++++++ demos/mcp-collaboration/client/index.html | 12 + demos/mcp-collaboration/client/package.json | 38 +++ .../client/public/blank.docx | Bin 0 -> 13705 bytes .../client/public/sample.docx | Bin 0 -> 123260 bytes demos/mcp-collaboration/client/src/App.tsx | 12 + .../src/components/editor/editor-layout.tsx | 17 + .../components/editor/editor-workspace.tsx | 113 ++++++ .../src/components/editor/room-header.tsx | 41 +++ .../components/landing/create-room-form.tsx | 121 +++++++ .../src/components/landing/join-room-form.tsx | 35 ++ .../components/mcp/mcp-connect-sidebar.tsx | 63 ++++ .../client/src/components/ui/button.tsx | 46 +++ .../client/src/components/ui/card.tsx | 42 +++ .../client/src/components/ui/input.tsx | 21 ++ .../client/src/components/ui/label.tsx | 20 ++ .../client/src/components/ui/tabs.tsx | 52 +++ .../client/src/hooks/use-room-status.ts | 11 + .../client/src/hooks/use-start-room.ts | 16 + demos/mcp-collaboration/client/src/index.css | 42 +++ demos/mcp-collaboration/client/src/lib/cn.ts | 6 + .../client/src/lib/mcp-snippets.test.ts | 52 +++ .../client/src/lib/mcp-snippets.ts | 62 ++++ .../client/src/lib/room-api.ts | 23 ++ .../client/src/lib/room-names.ts | 46 +++ demos/mcp-collaboration/client/src/main.tsx | 21 ++ .../client/src/pages/landing.tsx | 41 +++ .../client/src/pages/room.tsx | 35 ++ .../client/src/types/room.ts | 10 + .../client/src/vite-env.d.ts | 1 + .../client/tsconfig.app.json | 25 ++ demos/mcp-collaboration/client/tsconfig.json | 4 + demos/mcp-collaboration/client/vite.config.ts | 16 + .../mcp-collaboration/mcp-server/package.json | 25 ++ .../mcp-server/src/http-server.test.ts | 320 +++++++++++++++++ .../mcp-server/src/http-server.ts | 192 +++++++++++ .../mcp-collaboration/mcp-server/src/index.ts | 15 + .../mcp-server/src/yjs-relay-fixture.cjs | 47 +++ .../mcp-server/tsconfig.json | 8 + .../room-server/package.json | 23 ++ .../room-server/src/editor.ts | 48 +++ .../room-server/src/index.ts | 28 ++ .../room-server/src/room-manager.ts | 88 +++++ .../room-server/src/rooms.ts | 74 ++++ .../room-server/tsconfig.json | 12 + package.json | 1 + pnpm-lock.yaml | 322 +++++++++++++++++- pnpm-workspace.yaml | 1 + 56 files changed, 2655 insertions(+), 43 deletions(-) create mode 100644 apps/mcp/src/create-server.ts create mode 100644 demos/mcp-collaboration/IMPLEMENTATION_PLAN.md create mode 100644 demos/mcp-collaboration/Makefile create mode 100644 demos/mcp-collaboration/README.md create mode 100644 demos/mcp-collaboration/client/index.html create mode 100644 demos/mcp-collaboration/client/package.json create mode 100644 demos/mcp-collaboration/client/public/blank.docx create mode 100644 demos/mcp-collaboration/client/public/sample.docx create mode 100644 demos/mcp-collaboration/client/src/App.tsx create mode 100644 demos/mcp-collaboration/client/src/components/editor/editor-layout.tsx create mode 100644 demos/mcp-collaboration/client/src/components/editor/editor-workspace.tsx create mode 100644 demos/mcp-collaboration/client/src/components/editor/room-header.tsx create mode 100644 demos/mcp-collaboration/client/src/components/landing/create-room-form.tsx create mode 100644 demos/mcp-collaboration/client/src/components/landing/join-room-form.tsx create mode 100644 demos/mcp-collaboration/client/src/components/mcp/mcp-connect-sidebar.tsx create mode 100644 demos/mcp-collaboration/client/src/components/ui/button.tsx create mode 100644 demos/mcp-collaboration/client/src/components/ui/card.tsx create mode 100644 demos/mcp-collaboration/client/src/components/ui/input.tsx create mode 100644 demos/mcp-collaboration/client/src/components/ui/label.tsx create mode 100644 demos/mcp-collaboration/client/src/components/ui/tabs.tsx create mode 100644 demos/mcp-collaboration/client/src/hooks/use-room-status.ts create mode 100644 demos/mcp-collaboration/client/src/hooks/use-start-room.ts create mode 100644 demos/mcp-collaboration/client/src/index.css create mode 100644 demos/mcp-collaboration/client/src/lib/cn.ts create mode 100644 demos/mcp-collaboration/client/src/lib/mcp-snippets.test.ts create mode 100644 demos/mcp-collaboration/client/src/lib/mcp-snippets.ts create mode 100644 demos/mcp-collaboration/client/src/lib/room-api.ts create mode 100644 demos/mcp-collaboration/client/src/lib/room-names.ts create mode 100644 demos/mcp-collaboration/client/src/main.tsx create mode 100644 demos/mcp-collaboration/client/src/pages/landing.tsx create mode 100644 demos/mcp-collaboration/client/src/pages/room.tsx create mode 100644 demos/mcp-collaboration/client/src/types/room.ts create mode 100644 demos/mcp-collaboration/client/src/vite-env.d.ts create mode 100644 demos/mcp-collaboration/client/tsconfig.app.json create mode 100644 demos/mcp-collaboration/client/tsconfig.json create mode 100644 demos/mcp-collaboration/client/vite.config.ts create mode 100644 demos/mcp-collaboration/mcp-server/package.json create mode 100644 demos/mcp-collaboration/mcp-server/src/http-server.test.ts create mode 100644 demos/mcp-collaboration/mcp-server/src/http-server.ts create mode 100644 demos/mcp-collaboration/mcp-server/src/index.ts create mode 100644 demos/mcp-collaboration/mcp-server/src/yjs-relay-fixture.cjs create mode 100644 demos/mcp-collaboration/mcp-server/tsconfig.json create mode 100644 demos/mcp-collaboration/room-server/package.json create mode 100644 demos/mcp-collaboration/room-server/src/editor.ts create mode 100644 demos/mcp-collaboration/room-server/src/index.ts create mode 100644 demos/mcp-collaboration/room-server/src/room-manager.ts create mode 100644 demos/mcp-collaboration/room-server/src/rooms.ts create mode 100644 demos/mcp-collaboration/room-server/tsconfig.json diff --git a/apps/mcp/src/__tests__/intent-schema.test.ts b/apps/mcp/src/__tests__/intent-schema.test.ts index d38a551dae..449f8fccb2 100644 --- a/apps/mcp/src/__tests__/intent-schema.test.ts +++ b/apps/mcp/src/__tests__/intent-schema.test.ts @@ -44,7 +44,7 @@ describe('jsonSchemaPropertyToZod', () => { it('falls back to z.unknown() for top-level oneOf with non-object variant in real catalog (superdoc_edit.content)', () => { type Tool = { toolName: string; inputSchema: { properties?: Record> } }; - const catalog = MCP_TOOL_CATALOG as { tools: Tool[] }; + const catalog = MCP_TOOL_CATALOG as unknown as { tools: Tool[] }; const edit = catalog.tools.find((t) => t.toolName === 'superdoc_edit'); const content = edit?.inputSchema?.properties?.content; expect(content?.oneOf).toBeDefined(); diff --git a/apps/mcp/src/create-server.ts b/apps/mcp/src/create-server.ts new file mode 100644 index 0000000000..c7e1e4e99b --- /dev/null +++ b/apps/mcp/src/create-server.ts @@ -0,0 +1,36 @@ +import { createRequire } from 'node:module'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { MCP_SYSTEM_PROMPT } from './generated/mcp-prompt.js'; +import { SessionManager } from './session-manager.js'; +import { registerAllTools } from './tools/index.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../package.json') as { version: string }; + +export const MCP_PRESETS = ['legacy'] as const; +export type McpPreset = (typeof MCP_PRESETS)[number]; + +export interface CreateSuperDocMcpServerOptions { + preset?: McpPreset; +} + +export interface SuperDocMcpServer { + server: McpServer; + sessions: SessionManager; +} + +export function parseMcpPreset(value = 'legacy'): McpPreset { + if ((MCP_PRESETS as readonly string[]).includes(value)) return value as McpPreset; + throw new Error(`Unknown MCP preset "${value}". Supported: ${MCP_PRESETS.join(', ')}.`); +} + +export function createSuperDocMcpServer(options: CreateSuperDocMcpServerOptions = {}): SuperDocMcpServer { + const preset = options.preset ?? 'legacy'; + parseMcpPreset(preset); + + const server = new McpServer({ name: 'superdoc', version }, { instructions: MCP_SYSTEM_PROMPT }); + const sessions = new SessionManager(); + + registerAllTools(server, sessions); + return { server, sessions }; +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index b2e6376971..5b62bfeb25 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -1,40 +1,21 @@ #!/usr/bin/env node -import { createRequire } from 'node:module'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { MCP_SYSTEM_PROMPT } from './generated/mcp-prompt.js'; -import { SessionManager } from './session-manager.js'; -import { registerAllTools } from './tools/index.js'; - -const require = createRequire(import.meta.url); -const { version } = require('../package.json'); +import { createSuperDocMcpServer, MCP_PRESETS, parseMcpPreset, type McpPreset } from './create-server.js'; // Validate MCP_PRESET at startup so misconfiguration fails fast instead of // silently falling back to 'legacy'. Tool registration is wired to legacy via // the static MCP_TOOL_CATALOG + dispatchIntentTool imports in tools/intent.ts; // the resolved id is not plumbed further yet. When a non-legacy preset lands, // pass the id into registerAllTools() so it can route through the registry. -const PRESETS_SUPPORTED = new Set(['legacy']); -const requestedPreset = process.env.MCP_PRESET ?? 'legacy'; -if (!PRESETS_SUPPORTED.has(requestedPreset)) { - console.error(`SuperDoc MCP: unknown preset "${requestedPreset}". Supported: ${[...PRESETS_SUPPORTED].join(', ')}.`); +let requestedPreset: McpPreset; +try { + requestedPreset = parseMcpPreset(process.env.MCP_PRESET); +} catch { + console.error(`SuperDoc MCP: unknown preset "${process.env.MCP_PRESET}". Supported: ${MCP_PRESETS.join(', ')}.`); process.exit(2); } -const server = new McpServer( - { - name: 'superdoc', - version, - }, - { - instructions: MCP_SYSTEM_PROMPT, - }, -); - -const sessions = new SessionManager(); - -registerAllTools(server, sessions); - +const { server, sessions } = createSuperDocMcpServer({ preset: requestedPreset }); const transport = new StdioServerTransport(); async function main(): Promise { diff --git a/apps/mcp/src/session-manager.ts b/apps/mcp/src/session-manager.ts index 411bf7ed70..3969371984 100644 --- a/apps/mcp/src/session-manager.ts +++ b/apps/mcp/src/session-manager.ts @@ -1,11 +1,15 @@ import { access, readFile, writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import { resolve, basename } from 'node:path'; -import { Editor, getStarterExtensions, onCollaborationProviderSynced } from 'superdoc/super-editor'; +import { + BLANK_DOCX_BASE64, + Editor, + getDocumentApiAdapters, + 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'; @@ -49,6 +53,7 @@ export class SessionManager { documentId: absolutePath, user: { id: 'mcp', name: 'MCP Server' }, telemetry: { + enabled: false, metadata: { source: 'superdoc-mcp', }, diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json index 9d4af83609..543910d92a 100644 --- a/apps/mcp/tsconfig.json +++ b/apps/mcp/tsconfig.json @@ -5,13 +5,7 @@ "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, - "types": ["bun"], - "paths": { - "@superdoc/super-editor/document-api-adapters": [ - "../../packages/super-editor/src/editors/v1/document-api-adapters/index.ts" - ], - "@superdoc/super-editor/blank-docx": ["../../packages/super-editor/src/editors/v1/core/blank-docx.ts"] - } + "types": ["bun"] }, "include": ["src"] } diff --git a/demos/manifest.json b/demos/manifest.json index a825686e7e..ccbe3dc9f2 100644 --- a/demos/manifest.json +++ b/demos/manifest.json @@ -442,5 +442,23 @@ "liveUrl": null, "homepage": false, "stackblitz": false + }, + { + "id": "mcp-collaboration", + "section": "ai", + "subsection": "agents", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "local", + "title": "Live MCP collaboration", + "description": "Connect an external MCP agent to a live Y.js-backed SuperDoc document and watch edits appear in real time.", + "category": "AI", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "demos/mcp-collaboration", + "docs": null, + "thumbnail": null, + "liveUrl": null, + "homepage": false, + "stackblitz": false } ] diff --git a/demos/mcp-collaboration/IMPLEMENTATION_PLAN.md b/demos/mcp-collaboration/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..e60ffda599 --- /dev/null +++ b/demos/mcp-collaboration/IMPLEMENTATION_PLAN.md @@ -0,0 +1,199 @@ +# Minimal Standalone Live-Collaboration MCP Demo + +## Summary + +Create a standalone demo in `demos/mcp-collaboration`. The existing +`demos/collaborative-agent` demo remains unchanged. Copy only the editor, +upload, room, and styling pieces that the MCP demo needs; do not create a +shared UI package or refactor the original demo. + +The demo has four local processes: + +```text +React SuperDoc editor + ↕ Yjs +Room/Yjs backend + ↕ Yjs +Hosted HTTP MCP backend + ↕ Streamable HTTP +Codex / Claude Code +``` + +The MCP backend reuses the MCP server and `superdoc_attach` implementation +from PR #3569. There is no built-in LLM, model API key, or paid service. + +## Branch and dependency strategy + +- Develop on a branch created from PR #3569's head. +- Initially stack the PR on `feature/mcp-collab-attach` so its diff contains + only the transport and demo changes. +- Do not copy or reimplement `superdoc_attach`. +- Do not resolve #3569's conflict with `main` inside the demo commits. +- After #3569 merges, rebase the demo commits onto `main`, retarget the PR, + and rerun all acceptance tests. + +## Planned structure + +```text +demos/mcp-collaboration/ +├── IMPLEMENTATION_PLAN.md +├── README.md +├── Makefile +├── client/ +├── room-server/ +└── mcp-server/ +``` + +The client contains only the landing/upload flow, room navigation, +SuperDoc/Yjs editor, readiness polling, room header, MCP connection sidebar, +and required styling. The room server contains only document seeding, room +lifecycle, and Yjs connectivity. The MCP server contains the authenticated +Streamable HTTP composition root. + +## Implementation checklist + +- [x] Extract an internal `createSuperDocMcpServer()` factory used by both + stdio and HTTP without changing the published stdio behavior. +- [x] Add a demo-only Streamable HTTP MCP backend on + `http://127.0.0.1:8091/mcp`. +- [x] Create one MCP server, transport, and `SessionManager` per protocol + session and clean them up on deletion and shutdown. +- [x] Require `Authorization: Bearer superdoc-demo`, bind to localhost, and + reject non-local Host headers. +- [x] Add a minimal room server that uploads/seeds DOCX files and keeps room + state in memory without any agent or OpenAI code. +- [x] Run the Yjs relay on `ws://127.0.0.1:8081`. +- [x] Add a two-column frontend with the live editor and MCP connection + details for Codex and Claude Code. +- [x] Keep connection-snippet generation in a pure, testable function. +- [x] Provide `make install`, `make dev`, `make test`, and `make clean`. +- [x] Document local-only limitations and production follow-ups. + +## MCP connection details + +Codex: + +```bash +export MCP_DEMO_TOKEN=superdoc-demo +codex mcp add superdoc-live \ + --url http://127.0.0.1:8091/mcp \ + --bearer-token-env-var MCP_DEMO_TOKEN +``` + +Claude Code: + +```bash +claude mcp add \ + --transport http \ + --header "Authorization: Bearer superdoc-demo" \ + superdoc-live \ + http://127.0.0.1:8091/mcp +``` + +Room prompt: + +```text +Call superdoc_attach with: +- ws_url: ws://127.0.0.1:8081 +- document_id: +- user: { id: "external-agent", name: "Codex" } + +Read the open document and make the requested edits. Use tracked changes +when requested. The document is already visible in SuperDoc. +``` + +## Acceptance criteria + +### AC-1: Build and startup + +- [x] The MCP package and both demo backends typecheck. +- [x] Existing MCP tests pass. +- [x] The client production build succeeds. +- [x] The new demo neither imports OpenAI nor reads `OPENAI_API_KEY`. +- [x] `make dev` starts all local processes without an `.env` file. + +### AC-2: Authentication + +- [x] Missing or incorrect bearer credentials return `401`. +- [x] Correct credentials allow MCP initialization. +- [x] Non-local Host headers are rejected. + +### AC-3: Protocol compatibility + +- [x] The official `StreamableHTTPClientTransport` initializes successfully. +- [x] `tools/list` contains `superdoc_attach` and the grouped document tools. +- [x] The client can close its protocol session successfully. + +### AC-4: Live collaboration + +- [x] A test starts a real Yjs relay on an ephemeral localhost port. +- [x] An observer SuperDoc session and an HTTP MCP client join the same room. +- [x] MCP attaches through `superdoc_attach` and creates a paragraph containing + `MCP live collaboration test`. +- [x] Bounded polling observes that marker through the observer session, + proving live Yjs propagation. + +### AC-5: Cleanup + +- [x] Closing a document destroys its collaboration provider and editor. +- [x] Deleting an HTTP MCP session removes its transport and session manager. +- [x] Test teardown closes every HTTP server, WebSocket relay, provider, and + child process. + +### AC-6: Regression and UI data + +- [x] Existing stdio and bundled MCP protocol tests remain green. +- [x] Pure tests verify exact Codex and Claude commands, room ID, and Yjs URL. +- [x] The frontend builds without adding another frontend test framework. + +All tests use ephemeral ports, unique rooms, deterministic marker text, +bounded polling, and `finally` cleanup. Tests never require Codex, Claude, an +LLM API key, or an external model response. + +## Clean Code review gates + +- [x] **Meaningful names:** identifiers communicate MCP, room, transport, or + collaboration intent. +- [x] **Small functions:** each function performs one identifiable operation. +- [x] **Single responsibility:** transport, authentication, construction, + room lifecycle, and UI rendering stay separate. +- [x] **One abstraction level:** HTTP parsing is not mixed with tool + registration or document operations. +- [x] **Few arguments:** cohesive configuration uses typed option objects. +- [x] **No hidden side effects:** startup and shutdown explicitly own and + return disposable resources. +- [x] **No duplication:** stdio and HTTP share MCP construction and tool + registration. +- [x] **Purposeful comments:** comments explain protocol or lifecycle reasons, + not visible code. +- [x] **Explicit errors:** authentication, initialization, attachment, and + cleanup failures are actionable. +- [x] **Clean tests:** tests are fast, independent, repeatable, + self-validating, and readable as specifications. + +## Explicit limitations + +This is a localhost-only demo. OAuth, TLS, filesystem-tool restrictions, +WebSocket destination allowlisting, persistent rooms, expiring credentials, +rate limiting, ChatGPT connectivity, and public deployment are future work. + +## Plan deviations + +Record intentional deviations here with a date, reason, and affected +acceptance criteria. Do not rewrite completed requirements during review. + +- **2026-07-15 — SuperDoc facade imports:** #3569 imported the blank DOCX and + document adapters through internal `@superdoc/super-editor` subpaths. The + current stacked branch already exposes both from `superdoc/super-editor`, so + the MCP session manager now uses that single runtime/type facade and disables + telemetry explicitly. This fixes current type identity/runtime resolution + without changing tool behavior. Affects AC-1 and AC-6. +- **2026-07-15 — Isolated relay fixture:** AC-4 starts the real y-websocket + relay in a child process instead of importing its CommonJS server utility + beside the MCP client's ESM Yjs runtime. This avoids duplicate Yjs + constructors while retaining an ephemeral real relay and explicit child + cleanup. Affects AC-4 and AC-5. +- **2026-07-15 — Self-contained regression runner:** the demo installs a local + Bun binary and approves its install script so `make test` can execute #3569's + unchanged Bun-based MCP regressions on machines without a global Bun + installation. Affects AC-1 and AC-6. diff --git a/demos/mcp-collaboration/Makefile b/demos/mcp-collaboration/Makefile new file mode 100644 index 0000000000..c507c81b6c --- /dev/null +++ b/demos/mcp-collaboration/Makefile @@ -0,0 +1,37 @@ +.PHONY: install dev test clean + +DEMO_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +ROOT := $(abspath $(DEMO_DIR)/../..) +PNPM := pnpm +BUN := $(DEMO_DIR)/mcp-server/node_modules/.bin/bun + +install: ## Install all monorepo and demo dependencies + cd $(ROOT) && $(PNPM) install + +dev: ## Start the client, room server, Yjs relay, and MCP server + $(PNPM) exec concurrently -k \ + -n CLIENT,ROOMS,COLLAB,MCP \ + -c green,magenta,cyan,blue \ + "cd $(DEMO_DIR)/client && $(PNPM) run dev" \ + "cd $(DEMO_DIR)/room-server && $(PNPM) run dev" \ + "cd $(DEMO_DIR)/room-server && HOST=127.0.0.1 PORT=8081 $(PNPM) exec y-websocket" \ + "cd $(DEMO_DIR)/mcp-server && $(PNPM) run dev" + +test: ## Run deterministic checks; no model key or external service required + cd $(ROOT) && $(PNPM) --filter superdoc run build + cd $(ROOT) && $(PNPM) --filter @superdoc-dev/mcp run typecheck + PATH="$(DEMO_DIR)/mcp-server/node_modules/.bin:$$PATH" NODE_ENV=test \ + $(BUN) test $(abspath $(ROOT)/apps/mcp/src) + $(PNPM) --dir $(DEMO_DIR)/mcp-server run typecheck + $(PNPM) --dir $(DEMO_DIR)/mcp-server run test + $(PNPM) --dir $(DEMO_DIR)/room-server run typecheck + $(PNPM) --dir $(DEMO_DIR)/client run test + $(PNPM) --dir $(DEMO_DIR)/client run build + @if rg -n "OPENAI_API_KEY|from ['\"]openai['\"]" $(DEMO_DIR)/client \ + $(DEMO_DIR)/room-server $(DEMO_DIR)/mcp-server; then \ + echo "OpenAI dependency found in MCP demo"; exit 1; \ + fi + +clean: ## Remove demo-local installations and client build output + rm -rf $(DEMO_DIR)/client/node_modules $(DEMO_DIR)/room-server/node_modules \ + $(DEMO_DIR)/mcp-server/node_modules $(DEMO_DIR)/client/dist diff --git a/demos/mcp-collaboration/README.md b/demos/mcp-collaboration/README.md new file mode 100644 index 0000000000..0b2d7cb803 --- /dev/null +++ b/demos/mcp-collaboration/README.md @@ -0,0 +1,106 @@ +# SuperDoc Live MCP Collaboration + +Open a DOCX in SuperDoc, connect Codex or Claude Code through MCP, and watch +the agent's edits appear live in the browser. The demo contains no LLM and +requires no model API key: your MCP client supplies the agent. + +> **Local demo only.** The MCP server exposes local file tools and uses a fixed +> bearer token. It binds to `127.0.0.1`; do not publish it to a network. + +The implementation and review checklist is maintained in +[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md). + +## Architecture + +```text +Browser (React + SuperDoc) + │ + │ Yjs WebSocket + ▼ +Room server + y-websocket relay + ▲ + │ Yjs WebSocket via superdoc_attach + │ +Hosted SuperDoc MCP server + ▲ + │ Streamable HTTP + demo bearer token + │ +Codex / Claude Code +``` + +The room server imports the DOCX and keeps a headless SuperDoc SDK document in +the Yjs room. The browser and MCP server join that same room. Yjs—not the +original file—is the live source of truth while the room is active. + +## Quick start + +Prerequisites: Node.js 20+, pnpm, and either Codex or Claude Code. `make +install` also installs the demo's local Bun test runner for the existing MCP +regression suite. + +```bash +cd demos/mcp-collaboration +make install +make dev +``` + +No `.env` file or model API key is used. + +Open , create a room from the sample document, and use +the connection sidebar. The services are: + +| Service | Address | +| --------- | --------------------------- | +| Client | `http://127.0.0.1:5173` | +| Room API | `http://127.0.0.1:8090` | +| Yjs relay | `ws://127.0.0.1:8081` | +| MCP | `http://127.0.0.1:8091/mcp` | + +## Connect Codex + +```bash +export MCP_DEMO_TOKEN=superdoc-demo +codex mcp add superdoc-live \ + --url http://127.0.0.1:8091/mcp \ + --bearer-token-env-var MCP_DEMO_TOKEN +``` + +## Connect Claude Code + +```bash +claude mcp add \ + --transport http \ + --header "Authorization: Bearer superdoc-demo" \ + superdoc-live \ + http://127.0.0.1:8091/mcp +``` + +After connecting, paste the room-specific prompt shown in the sidebar. It asks +the agent to call `superdoc_attach` with the Yjs URL, room ID, and agent +identity. The returned `session_id` works with every other SuperDoc MCP tool. + +## Saving DOCX + +MCP edits update Yjs immediately and render in every connected SuperDoc +editor. They do not continuously rewrite the uploaded file. Use **Download +DOCX** in the room header to export the room's current state. An MCP client can +also use `superdoc_save`, but its output path belongs to the MCP server process. + +## Tests + +```bash +make test +``` + +The suite typechecks all components, preserves the stdio MCP regression suite, +tests HTTP authentication and protocol behavior with the official MCP client, +verifies live edits across a real Yjs relay, tests generated connection +snippets, and builds the frontend. It makes no model requests. + +## Production follow-ups + +A public deployment would need OAuth or expiring capability tokens, TLS/WSS, +filesystem-tool restrictions, WebSocket destination allowlisting, per-user +session isolation, persistent rooms, rate limiting, audit logging, and a +deliberate ChatGPT deployment model. Those concerns are intentionally outside +this small local integration demo. diff --git a/demos/mcp-collaboration/client/index.html b/demos/mcp-collaboration/client/index.html new file mode 100644 index 0000000000..d355f40352 --- /dev/null +++ b/demos/mcp-collaboration/client/index.html @@ -0,0 +1,12 @@ + + + + + + SuperDoc Live MCP + + +
+ + + diff --git a/demos/mcp-collaboration/client/package.json b/demos/mcp-collaboration/client/package.json new file mode 100644 index 0000000000..65b7373664 --- /dev/null +++ b/demos/mcp-collaboration/client/package.json @@ -0,0 +1,38 @@ +{ + "name": "superdoc-mcp-collaboration-client", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "test": "node --import tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@superdoc-dev/react": "^1.0.0-rc.1", + "@tanstack/react-query": "^5.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "lucide-react": "^0.511.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.0.0", + "tailwind-merge": "^2.0.0", + "y-websocket": "catalog:", + "yjs": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "tsx": "catalog:", + "typescript": "~5.5.0", + "vite": "^5.4.0" + } +} diff --git a/demos/mcp-collaboration/client/public/blank.docx b/demos/mcp-collaboration/client/public/blank.docx new file mode 100644 index 0000000000000000000000000000000000000000..7deeb3f0c037df43e46dc1b2dbb376e564563dd2 GIT binary patch literal 13705 zcmbuGbyytBwzmg&4esvl5+pbT*Wm8X;2JbR0>K>ugKKaJ8rBB-x0jaVK2_J)SZ?Yu4~f{`^jDWS(X9V(-( zq&7s;@Kfj>b)JNWgTjPn*?7fV&qk6vY-~a@oOQV%Ii>?j-q16yww3eOGC{V0qf9}J zS+@H|f*#gO#rF{|g(ep37kK4vw9u0TuXhCb(oHRaOmPCV6K^DJsk7Lw$5xAG_)&<+ za)loc7A2~MeT0j&;vn6~(od6)_6U`qT!r}LCIBGbOhNazFMoU>Jh{ru(Nx*R(Fw@< zn}foLj;-E2m49>455!;ICdaGPHh=P64;Xfe$dL>10g;=S~1kq!hlpe zNpHX8cB{8G$}&VjW1Pq~`lb1~2~*|GExujwC0<SvrDziQ9K~)C(#+_M3#qGsMTs>cc=5}A5ILE~i`o}AC(w!<^RmWH;{Y=B=w%YXG}+j2N`;U-wG^CFy^SjLxJNLE1|0u=bg2_cZgow?Rteu-+CD(34wbSS;f z9|N`GctFjfguAX`Rr!SI-BMZa+nQ9R(0A2pdeg?4wV@O@Z%~n(LNKJADDe(nU}RF^ z6H{G{s+R*mcns)dnLV-z>zs!j@IKBTj=xdBcu1q*PPFll!wtcGtT-&nsx4X*=_HLT zoVzX|PkyI`rJTx{#(%;<2lPetGxIQ96V=xu!6a3K>ZVr{1~#Ku7*Q4L6jbs?J8ryU z>?+G9F()xKD&APU^*E}PhEdY$ZVnBY$A;q(jE=coa-cyz^15Ultfda)5=mVyhh;k3 zG0RN!+x5FRYs>&~yqijAlkM7aNG;IzP479ktXZ*oWTI0<`lefjyN65=Fn$5|zlSL7 zlfw*M%L-0ha%3>!TNRica zd4qjJQ{@K#J&D`U|^Z9v5{e5HUp~)_c5PWtM$9p58@j+-r3M! zF%j6Yu~KKmnv@|DoV83Gc!Sytd%{DR5ONU0S?_%<^e}ZEcO!Q2MR-ohwQCJG90@b@ zUtB``?{^#K>27&Ax|sc!$uQ?BpB_1lo1j14=vp`c0Q1l07Eg~qbz>8|zrri)DXIP_ z|CU%=<2xNdZ0KR9iFc?;rzm0Xxg^OomhAIdkBn!8Uz^H*#98X!4B0xK!+VQW{fYUw zge(OA@iyhQkLeVxTEfRc)GG0dlNj5Vh<*7Y`v^nBSze^54%Ng7RmQ9@lyJW7&22if zvK5IiTNl_=aruoO>*ay~uR5ti!z&tHSb_Fbbte;cP_iSH`fgpdWkuM8Du_PF6kIjR|ndPh6zQ`%~9wV z9I$B9uCcirX#C(qhAn#dEJQj#4CE)_U_~G?kw2vh~{kjNoB9uEbzgma&BEY`hp;E6;9C zkQq;(8auBatJ}lJr(_Jn-EmAGiVVY_nEU+t^xgLZQB{PY2!pCZDK26(NwRvy>R=Sv zb53$9Z-Yu_LIkyMTNDkE3#Pc?X>#A3=OhaX;jJl#f!|E$T~c;4xE|(`UmUI*e)F9a zq-?Y%@|zmc-CfHB*Pe4c)+Vc3E`M7Z)+6$~#-QEY*Al#4?=@)K-vBMeRDnMVi5!wo zL#}^6>U&e^US;_CfcMwTorKfKIP)~N-Xi^;xqlm)PY-N2`=`0pKV|OxBwfcPGOWHe z+GD|2{*LF&iYDCUwHE1jY{fbaXriyXi(r|^8vO2vk8Y~HN{PS-!1Zr8UHn8quoGse zhtZ-FwU)mp;SEW=gbIrX40OShp zOap^s<;H7nr8o8O3qm;5lK#$1_&aS+CaxPd?y#H#top37yV!(nx;VcU<%yl zQstr=DUAI>Zq5FVONN$R-bsT4Ay_vbrha@zkm{rVN-{5irD~hdz{v;J;yn-E@b)3J zCFv}xvd3$`;%wuxFl~GTlxp5P<39T;P(#-+8rPilP z*3f(b?mTUX-J-k!uLVsw_C(lVFDU*NHvpWkb>T(o=%VPG;xUfC<2ROr8tN2{s&DSc;WncOu~n9qx?U zZKuUe`Das5;}05d=^R`edz-+o!gSIM-`JXjWU$ur_0gO@TW}-HsB5g*%k*gSS@2xvlz^&c+oUZckUN-{ z`~j6lrqWaB`*d1X9cQoc`{(`+?`-RP?mwdzihyY-X}j1=vaeqGQ~QE1kYIC6LRAu= z9Vn<=`E)yYOK5$&qOGv8X4#4ht=fj{=T_)!6Zh_5mae6MVf4XnyDBp)4CX#7E329- zLoO27uw@0^Au~V*&XzarHn?$R>v2NBm!jqwnxLWuk1K6yXg8(3`?X)=V{q3zf)N@3 zXhZ%fK7i(~uGS8gz<-L5IxYK{bUyS)|H%iq?%koVIjYFC@(b@zGdnto*i1r3h)FSJF2UswT0KwNhv1}g^bAJ=09o)< z&ls*v!@7YQ(W;|zc`{;jDuq?Me9u1RZva1GN=~ar;mN`zKi8_0?Y_*&YxKiIat>eX zH5-|V*buXhF%6{e?m+S)%jdE?vRnW>$BRoQwf_>T0`hRpM5_1Mq7c;8!!)p0M$9sy z-*}D34H1_MBx<1$xNn14JI3M0PWcpVa7Bq$NqJ^~A+P%FN+4Q$h#huF7;T_pGd06Y zW_I(o$;ca8+YLK(H=XZ5+J1F2E_D!fZX%VFV6I*!2P*E3FlC!V@J8xMceC$HZ&?_g zaY|We%tHU$DlYGNT)ht=VtmSBIO=ivpqeyL8L6fkane9=e!T5k=k#qae@(PXxFljZ z9OV%3$Qebn^YXc-41@~ROZnm~WBMNWZzP~rQRIS{SgUm8-J;wB1nR0U)O4+6^Q+U! z1#nc#B_H3uOn_X9z?E~d`O5K#tHqdN21!s}Hx4Y6?;fc}YG$4E?H>>(;@n{FH&9Y+6_qI&) zac~Wlg3wZ^C3|ErT@lW7Y1Mo5xIifLTK<)KfQ>Jvg$8eZ4?!fhNjO>m;tVO}rVb^> z*h^Ks=bdc%&ZOa`-LF9^P%>e3D`MgJ9oAd;vD*Z@-U=QvM}n)G;>dEwgZ_NL88yO4 z5tzzBSRq7p9u!;kWil&f*&GrCP8V(Jsw#$cgZ&p8v3|Z70!sSc(Xm0WrX#GMTulKgKIW*{KepvgyOc zbjO`Ie6nVfqZ-b^ta#BA+W9!1LMVZ+M0#*9V72vVf>D7xS{o?t=f@yaLROdU+$Mo( z$!sCt)%!JjpgXnk#D)jaB7JGXf&dzn`l*Fjp>TBis-a?cEF)k^m`#MlQ3$5Dy_8cCcb z$q9uCPUeXubboj(^S(L1RADhvTf>|@vjLZ_?l__bVR{;Vkr8Ce{`O2X(rkp;q_?~D zAT?sWwB|6Y?k@=}1$WsoVBElpZOBPH6R8PxLy+Eg&#Ox%QcZH^eq`q+<#^<@H>FHz z2jS@2R(M_sWRRCH)@ zjV)stAeFBElxIX#tfrW!yUrx8t~iQMtK1*sYmDBX9dRTU#7Q@)9idj0BtKQk&X%u4 z`8-Y&C2;=SD98~VhtjT(J5=E+Xv)bnU}Bzeu4~Z$(uxWF1gvx093GejGb(96biY-> z@x41Yh>T&d44=q2Ra)wjY9wtcFe-mwATNw*O|B!(sFi;~bWGFd*y>*0z6)o*?_p#W zL(l7x2O~wuU5%U|Qkdnat>!wUA91zKQ{6^q07eKco508oU=ZSMfWu8W&lY91v=R}2 zZC9GM$(_OF;M~hU1=-z07Ggfw>07vDGClrX0?-xIQiwS?)F;p&$5f}2qj zY5P7E6%c)(619`Qy>O~0q+0S>y-RJSK~4!%1~;FqUP!H+JFUm}ve8m~i=oLYVNl>( zJq>5r%PHH`hRnbOIj5){WeW=^MtZ6J`62n&iZn+q604RMs(KW4U-pJS=6FFwbF#2E zl&#Z$S=6evG2iX6O7Or%9ZH)SNV|X3zCV9B&GD*v&CB8#Ffel^9;AXCd)`?X)Nt$iDKkiCb?KOL5hg;w&;7s6IZK~8EX^GZ%$X};VZT{{inolo=#-RWJtUs5o z9_A)$|K$uSb4tbWGZ{Ky9ZY?X7UGfLY%Ooft&Nl1%Jj1S0f4CW5>^kY@xcGVs;(lE z)m=$+$ggSBsA-i@p{>I!lm`^=EQatZP<6pxN@SH61Vb(C#MmT-gfH~E@NnjMq*~Fo ze~m1paVoy(RW?EF1V;#i`EVpAGT5}Nqcs0o9h4E75QK}KJEGDU<*XXpg5_;er4s{#Ie=4BAt_R!u0$cu_NotCH6XLGTSE>Z(GysVsZT61 z7FM5ql9FE}bv0Hy4{`p3Fkh@1YITP?qxRf?WyjS`0QFv8>>ptz=bl?tE zfcB@4fUaJ4=KnasUCFcuF7aUYt*s6F!9C6K4Dzb8%#~K6kEwQtsf%?5=jPB6w2N*( zP!{u@6p8?R(etV4SQ=HP-Wy&UcuwAL?*=?>7Vbt;`JH=xkr7SNgFZ2LOe{Ez^cQi~ zdTuljGm-Y@V#pi|jwz9Jv(LZdOX0{}A|Z@zQbg#P`Z-RJZpH3fXW8_x_GF49q%$@A~{JT`phZAdH};9)O0Qdv1922dY8%s>XnGO zfibngLAg_-7^tnIi1$uKStj(-w?2*sTPd-{8Z z{HwyfAW&aExF(8))y43Vdg;bGSGd>1`H}s5-ght$;#9X+n^8DpxA`dpjy&r5C5)x4 zcOv{XnJx<&Df~j*QYjn8!BFBIsJbRy@0vt{O#GHIz}-Iv5=THpPkJ>H2>f1YU`V_d z-j}m{$$81@(`Bc2q19xpXV)Zq-Ehm~%T#}UHdA_@a9Ww ze)=n(mwuBWOc*_@?~ZuB$2pkYZ;w9sKEJyM)v}0~`y8Vb%j0qQEn8~K`wu0bQi8*M zz{TIbRC~-0@%Vdt;6~CVr5;p!_-8q`>;^VsXZ3XrMsLV@ev83I6QN#Sa`=8+KFV_o z%exqIaXMjFVt)PXbOblKQsX3UElhs@N(JA_p2x(;$^Nk&lBXZ8an!#jRc?LMh}1b+ z3OP|yb>ChHSThHPxDy4L5?lF}K`BJLeb}!;g3ARx87$J-Kl_)!R4Z0@XGl*wsR@H>CG61`*K$y zwBX4G=WAlH)8~dwlF`$rQ~S%&K72k)&S_KlVn*Z1UJQt0ZVNJZV(*dC8YNV3*fT>itBT z;P53?3_2cATNq!eySLD^ZHpaEUvYolRoO6P8G*MmsDpRzC^o7`JjU||kwR9LjyTPJ zG7%B;#riY^aPzIv=;!{6E}vLeQbri(3f{J{@K>bPdh#{RbFc(ctz6?NCy9Z*uB^xm zX(}ixij)M!8B$zE{O_!mIlku+G&~(R{5WvY0XzGk7mtB0iG#0gJ$U^Sl&M;L&hZYe zrbhF8N2Z%h*?poGytj+YcY`^ZzdC=7yM*fDFQ-x&IjoB;(y+vEXhz#ZeaFx!N221H z_&gWD=(Qq#6r4d*im=G6AOvjJ&Z~Q|)W*qS^Iki=){8&xL!$qd@I>CmQZjdpx%TyR>Q|nNhrLuE*Y10+ zXb!{Duy0QD0$Xd)huAMAAkmMNu6l@(N8##EmU9HN!{19a@K!?7_`Y#~b-q8_XuU)) z*GMgf4TJS0&bP*T&wiQMLHf8J88E?hCWq6xfu!PC`8Med*O3%@h8c(I7DntAms&{B z7Ey`U_YKy_w>=~%$1FPw3gYy%6st8x$7(%E!;xbg-_eJ|xK7)daBTG7G8B=>7$ur_ znbq9He_Byn>FX!g#=momKe&<>nmP8b^MIT4OZymnC@eW{GdLA|VHh-evfk|B4ktac zK$uM(&SoH1x1-|Lu&x4b@HtPuq!5WnCd_sKAdxM=t~R7EqoI}vVyM8= zbL2~*j&ut5t&;X8eum?$r*Ruwg^*o5Wnz*W3!m*pZlrb2Vr<(m<+lPcYt8jPm#d{J>Gov#8fYn&TwN zVvS-?PiQ|jHGI_P*X7WL_^_SKe(sRr(hI`!t7`UBGUhUN(@>+c>@|5w`iQ? zsjSi{llMFo7~&%IKvea&qHqinAE2-)#sh8m8+g-nVHom$KSX>Y5_=%4Tg4!$TRA=} zdT;Vnf_`c7qe>{e`Ugm0sc0yc>xUn|L=H(@6dxUkc>;sL?Vieb;O|xcs)8Pf zsBYwd{WH;RkzF}Qh($T4RpEnJ#8W~1o%V~F%7KW(<$n?1(tICgRY`NGAzjxJdgpOO z>AxG#eE>dCYVw#>UIImErfMGtL^=^mDk&bLp-F?T(W}lR^OB2Dl4nYn@$WwGR`QOt zpIM*d#nkH$1R&*9rb6B{mQ%k{!{=@Cox#NN+N4pc)CtnwZKS`~WbU+fT263v)5GD- z@1Me8bCBc2-VE$)q1V+K-azNw-ish7+9(*-V-JB^PKaR)dnQf2>vt$SBhw+b zavoyWJ^vC#W%j9E=CqT`n?wT4Kdyd^BQQG352LevYN%+VObyBpgD#tX|N5!MPduAA zoyy#Kt(VxqWvVihw}Rwa$j$>zCRQrU8fDC-`G}p>Izc)M8ZR{_j!N=NyxnJ1HKab- zAnaLdG0z~{K7BCQX)&kf_iDU!Q(b~9(XUgd<*v}-kb75}MDXM6JJrn+>YC|Bn_@*9 zy{C&${i!caih4SmFIDV3qY8SuG{E%Kq$Hl;i6;Bh?jY~PQ|zhI-?sm=CjOuB-!O67 zzrp{!_#5We-`Ef4!hnu>-Hi4sL%?+`@{{=hgZT=n;thS27v^nt)9$$Yl~76Y!*1rB zA$`4nX7;j3qG>_aYlGXSxMU#+9=HSIHLq>kl7uRo@c8y!# zpZe*VMz53>zFPC(O}D!aA zxB@BC6xF+quibtfO!r%R;YKbMkH><6-cysaFeD+~!KX6`9Z(ihXelb*9Y>Cb5L1-U zkFng@gxuL`Ho_wGBRSPIpuTITg2%dvTkf8W)8(Q|{`}Eh+KS6>@EG!yWsGo%O}9`p zD2Il(k+Flc_j_{`$F>fhK5;K9UkJ7iA1gkMU-9%S2K&v=`i~#oRfj}Y zLkDxRE9K7W?iN12M+e?B&~! zLcCgPk|lOTY}hN}52V97H%3XQXPxtO?>>H#-5TH9UVXSeE(<(qxTt^S;8yQt!RkEI z8%VE@f||zwytp-(1vN8yP*4-|$O%xzxxN*=_x+COq3tZe@Z3h-QzGwd&zAn1hv8T8 z5^e`|Tlu`%o9StR>(7L$iLxMuXA~MVg$08H`)u~p#&UUGU#DI94s5VgF_B z=ap^EmLyMFkv1#5{d$sJyS{ysy?Zxg+Q$@FuQ0pft?~6fyl;Emfp*B;JLB{IWe6XJ z19gd8aK(n^Id#olp>{oqVa**Gvte#4tMV<3WD(BCUMu2P61I&9)gmEir*9bAq1lB} zNE)e6)8$29Ya!FD#%uJCP5IMGR&B7w2)RFj*P^Rr^-Oh)G>^#>Jq(V`i%EgIJB&l&|YK+~Sk z2O-|`g3+GY8p1eN;SOMXIxK`r@5X`_-fDWf**Sx4$l_Jj*sO$ht$S9tn2dOH=?dC% zMd72Q!_Dpd=}O+!_r2-PKvHwZ-U9OoaZn^a zF3xKylnrCETqx`AF%{a?jXQ4!<06lWZmp;}if?%B6gb_DrpLfzKMRRogk|+QSKD)^uo!UP`|^`Do6o;VT&c zR14ViAlB;S)$;TJzUy)t2V=E-`s_n^6TR))RS*Q$^bCMHMHOJS{ILE~Sw0M7eM>1A=lPnT&djS?PC^Vp} zZS%Xh*pJ6u^Sjzris3#(4Zhd7AyLGD;~mFnR8VUY9f7vWZq4jAfOOW#jqf3{`1pR} z1003QIIqQfQIv{Eb-`704*k8?S_N>38!I6SFtdP4+G5D%oCH_;sfvAXcC8u2DW+Ma z(E^QSdw4WAAb7UrdCCsQNtL2G>kGsYU5RF*UOI6|EK}E@_fkhBy%G8zPTh2crVuB_ z_#Gt4!WUeJnq8*D*fUl8e6FwSU;+J>G)WX17#m74=dz)c$VTVB7w9d-%?T}6} zodZ{Nj~>E;KGqb0uwYGHH)pUmqITvE;WKVC*n4>gDgBl>tK2-*;RuCNTIcw}Gj$zD zYGs7d+>xtu0n>%06Lxyw87jwKxw)B%NS*yP*^9f^3&D7Hhm+-4sRxn~$LbLZbuOlE zQx(fb71%=bqpQjg54?$EaHU?OZHvV(=?=LSi5*(2_f>d4lLi=1y_q_~iL)H=(gzyM zeIq?gYku%G7|GD$+?^S2*BmKB#H;aMP_vP>b@cB|d9Tk-X_cOwkCyBcz?G0`4#Ltg z;MxxN)srt=H-R0p>?pb+iZtU5Y|7|mv3z?g<~IC7v56@=N@vCVoLj+T&&oN*GJDxx z4uVeeA*tagIX^80d{;grxu@#45aE^hT8lwQD16Y2A-o_=qSQ+Ov))aUNem^j!=D(b z<_oqLAH*xYNS~98SY%%ONzi8{;O(>1tfs=lsA86-2_{z8vEeDiaJj}}DhA_}{w~wj z`zSx+FmEkE9fI<8@teivVH7Gg-F2ChrUKd-2{uPU)7u(Z;ew{=zDRi(*^Ih(GEB%1 zEUDC%dSA(vc0RgTwLM}Xbptw!qpnH_W$M3J2r?gTQse57W^WI>(SpwLBy$TaXA(?- z)Qb~fXj~M5#5|Aq35TFutyEBO^dni3{O;WRNb%KC4KMOs>IS@4Kzqi=wt;MM6VL{d`IsbJSf zeD?>b`6FaPhjv1)&sl5sUmpxgMUKENWJJPj9mWXmf+JanXhC6)U~iPU2E@A;^HxpK zQ_i}XM(ge*Om@s#{uh2Gt8sJr0l#Td|KV}@f7LnGv_%p>KIt6mpY|_^{(M~i4*6Lm z^{epl?f(?vmB)|#ke`Q~hPDOI`!EaIizCDW=>zq2!4PIX58_v*c?vr1ev)#SZ(yf_ zqd841+d^l&FUzq7KU}tQLsnD4gcVe~IqE==_FQkkq|jR16qmh#p!A;K0(%_ts>gkP ze|pzh^WLQ8b+pwh=aW*@y078H;~~kt0?hO>a~Cf(v#TZBWTvP+rjYw9ITk8#*4?it zKbjDoi7eDx-ys^>*Uee&Nx^_+uy`)X)tYf+I|gLMaL$(CBa7g<7&0JAtr z7o^HBAs|Vh{@ULDRm+d}n^gR1lL(L(_~Yx}D*Iu6SN8v#w()oH-}@W^@H_a2fc*GH z=@b9SV}I(_P8~eol8(r+b56WMakUw*^5dJXn=aAP>|4%*t%iF&S$bQCpJW0xa>-n#8vi}bI zt3u>w>@oTu*niWD{CC`6H*A0A)?@#{{U6)6{~h_)ZN{I;I=Fw{h@Unc{}c4@2R+g+ z;|h}Y59psOq$l8i5%RyTkAAjN`d8r3tEK;6TiIFuu=W2IrN2I$ezw*9SK!a%_8-C) V<|&4LC>=2X#ZQqw$ok{x{{bLlgAD)x literal 0 HcmV?d00001 diff --git a/demos/mcp-collaboration/client/public/sample.docx b/demos/mcp-collaboration/client/public/sample.docx new file mode 100644 index 0000000000000000000000000000000000000000..3dfdba3cb2e7458c789c2e8ff29569de4a8af4bc GIT binary patch literal 123260 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 CmJad& literal 0 HcmV?d00001 diff --git a/demos/mcp-collaboration/client/src/App.tsx b/demos/mcp-collaboration/client/src/App.tsx new file mode 100644 index 0000000000..a7d4907f27 --- /dev/null +++ b/demos/mcp-collaboration/client/src/App.tsx @@ -0,0 +1,12 @@ +import { Routes, Route } from 'react-router-dom'; +import { LandingPage } from './pages/landing'; +import { RoomPage } from './pages/room'; + +export function App() { + return ( + + } /> + } /> + + ); +} diff --git a/demos/mcp-collaboration/client/src/components/editor/editor-layout.tsx b/demos/mcp-collaboration/client/src/components/editor/editor-layout.tsx new file mode 100644 index 0000000000..f998fbef31 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/editor/editor-layout.tsx @@ -0,0 +1,17 @@ +import { EditorWorkspace } from './editor-workspace'; +import { RoomHeader } from './room-header'; +import { McpConnectSidebar } from '@/components/mcp/mcp-connect-sidebar'; + +export function EditorLayout({ roomId, displayName }: { roomId: string; displayName: string }) { + return ( +
+ +
+
+ +
+ +
+
+ ); +} diff --git a/demos/mcp-collaboration/client/src/components/editor/editor-workspace.tsx b/demos/mcp-collaboration/client/src/components/editor/editor-workspace.tsx new file mode 100644 index 0000000000..3b6867c8d0 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/editor/editor-workspace.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react'; +import { SuperDocEditor, type SuperDocModules } from '@superdoc-dev/react'; +import { Doc as YDoc } from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { Loader2 } from 'lucide-react'; + +interface EditorWorkspaceProps { + roomId: string; + displayName: string; +} + +const COLLAB_URL = import.meta.env.VITE_COLLAB_WS_URL ?? 'ws://127.0.0.1:8081'; + +// ─── Module-level cache ────────────────────────────────────────────────────── +// Survives Vite HMR so document state isn't lost when editing frontend code. + +interface CachedRoom { + roomId: string; + ydoc: YDoc; + provider: WebsocketProvider; +} + +let cached: CachedRoom | null = null; + +function getOrCreateRoom(roomId: string): CachedRoom { + if (cached && cached.roomId === roomId) return cached; + + // Different room — tear down the old one + if (cached) { + cached.provider.disconnect(); + cached.provider.destroy(); + cached.ydoc.destroy(); + } + + const ydoc = new YDoc(); + const provider = new WebsocketProvider(COLLAB_URL, roomId, ydoc); + cached = { roomId, ydoc, provider }; + return cached; +} + +// Clean up on full page unload (not HMR) +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + if (cached) { + cached.provider.disconnect(); + cached.provider.destroy(); + cached.ydoc.destroy(); + cached = null; + } + }); +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function EditorWorkspace({ roomId, displayName }: EditorWorkspaceProps) { + const [synced, setSynced] = useState(false); + + const room = useMemo(() => getOrCreateRoom(roomId), [roomId]); + + useEffect(() => { + // Already synced from a previous mount (HMR) + if (room.provider.synced) { + setSynced(true); + return; + } + + const onSync = (isSynced: boolean) => { + if (isSynced) setSynced(true); + }; + + room.provider.on('sync', onSync); + return () => { + room.provider.off('sync', onSync); + }; + }, [room]); + + const modules = useMemo( + () => ({ + collaboration: { + ydoc: room.ydoc, + provider: room.provider, + }, + }), + [room], + ); + + const user = useMemo( + () => ({ + name: displayName, + email: `${displayName.toLowerCase().replace(/\s+/g, '-')}@example.com`, + }), + [displayName], + ); + + if (!synced) { + return ( +
+ + Syncing document... +
+ ); + } + + return ( + + ); +} diff --git a/demos/mcp-collaboration/client/src/components/editor/room-header.tsx b/demos/mcp-collaboration/client/src/components/editor/room-header.tsx new file mode 100644 index 0000000000..43131884fc --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/editor/room-header.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { ArrowLeft, Check, Copy, Download } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { getDownloadUrl } from '@/lib/room-api'; + +export function RoomHeader({ roomId }: { roomId: string }) { + const [copied, setCopied] = useState(false); + + return ( +
+ + Home + + + ); +} diff --git a/demos/mcp-collaboration/client/src/components/landing/create-room-form.tsx b/demos/mcp-collaboration/client/src/components/landing/create-room-form.tsx new file mode 100644 index 0000000000..bab5b32ee2 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/landing/create-room-form.tsx @@ -0,0 +1,121 @@ +import { useCallback, useRef, useState } from 'react'; +import { Check, FilePlus2, FileText, RefreshCw, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useStartRoom } from '@/hooks/use-start-room'; +import { cn } from '@/lib/cn'; +import { generateRoomName } from '@/lib/room-names'; + +export function CreateRoomForm() { + const [roomId, setRoomId] = useState(generateRoomName); + const [displayName, setDisplayName] = useState('User'); + const [file, setFile] = useState(null); + const [quickAction, setQuickAction] = useState<'sample' | 'blank' | null>('sample'); + const [isDragOver, setIsDragOver] = useState(false); + const fileInput = useRef(null); + const startRoom = useStartRoom(); + + const selectFile = useCallback((selected: File | undefined) => { + if (!selected) return; + setFile(selected); + setQuickAction(null); + }, []); + + return ( +
{ + event.preventDefault(); + sessionStorage.setItem('displayName', displayName); + startRoom.mutate({ roomId, useSample: quickAction === 'sample', file }); + }} + > +
+ +
+ setRoomId(event.target.value)} /> + +
+
+ +
+ +
fileInput.current?.click()} + onDragOver={(event) => { + event.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragOver(false); + selectFile(event.dataTransfer.files[0]); + }} + > + selectFile(event.target.files?.[0])} + /> + {file ? ( +
+ + {file.name} +
+ ) : ( + <> + +

Drop a .docx or click to browse

+ + )} +
+
+ + +
+
+ +
+ + setDisplayName(event.target.value)} /> +
+ + {startRoom.error &&

{startRoom.error.message}

} + +
+ ); +} diff --git a/demos/mcp-collaboration/client/src/components/landing/join-room-form.tsx b/demos/mcp-collaboration/client/src/components/landing/join-room-form.tsx new file mode 100644 index 0000000000..a91b41ff4b --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/landing/join-room-form.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function JoinRoomForm() { + const [roomId, setRoomId] = useState(''); + const [displayName, setDisplayName] = useState('User'); + const navigate = useNavigate(); + + return ( +
{ + event.preventDefault(); + if (!roomId.trim()) return; + sessionStorage.setItem('displayName', displayName); + navigate(`/room/${roomId.trim()}`); + }} + > +
+ + setRoomId(event.target.value)} /> +
+
+ + setDisplayName(event.target.value)} /> +
+ +
+ ); +} diff --git a/demos/mcp-collaboration/client/src/components/mcp/mcp-connect-sidebar.tsx b/demos/mcp-collaboration/client/src/components/mcp/mcp-connect-sidebar.tsx new file mode 100644 index 0000000000..605fa09a9e --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/mcp/mcp-connect-sidebar.tsx @@ -0,0 +1,63 @@ +import { useMemo, useState } from 'react'; +import { AlertTriangle, Check, Copy, PlugZap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { createMcpSnippets } from '@/lib/mcp-snippets'; + +export function McpConnectSidebar({ roomId }: { roomId: string }) { + const [copied, setCopied] = useState(null); + const snippets = useMemo(() => createMcpSnippets({ roomId }), [roomId]); + + async function copy(label: string, value: string): Promise { + await navigator.clipboard.writeText(value); + setCopied(label); + window.setTimeout(() => setCopied(null), 1500); + } + + return ( + + ); +} + +interface SnippetProps { + title: string; + value: string; + copied: string | null; + onCopy(label: string, value: string): Promise; +} + +function Snippet({ title, value, copied, onCopy }: SnippetProps) { + return ( +
+
+

{title}

+ +
+
+        {value}
+      
+
+ ); +} diff --git a/demos/mcp-collaboration/client/src/components/ui/button.tsx b/demos/mcp-collaboration/client/src/components/ui/button.tsx new file mode 100644 index 0000000000..df37775f12 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/ui/button.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/cn'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/demos/mcp-collaboration/client/src/components/ui/card.tsx b/demos/mcp-collaboration/client/src/components/ui/card.tsx new file mode 100644 index 0000000000..5625e821a7 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/ui/card.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { cn } from '@/lib/cn'; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/demos/mcp-collaboration/client/src/components/ui/input.tsx b/demos/mcp-collaboration/client/src/components/ui/input.tsx new file mode 100644 index 0000000000..a1ae3458cf --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { cn } from '@/lib/cn'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/demos/mcp-collaboration/client/src/components/ui/label.tsx b/demos/mcp-collaboration/client/src/components/ui/label.tsx new file mode 100644 index 0000000000..8ee5c9a384 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { cn } from '@/lib/cn'; + +const Label = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +