diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 3e1f318f30..ab5e38475d 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -20,6 +20,9 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "@superdoc-dev/sdk": "workspace:*", + "ws": "^8.18.3", + "y-websocket": "catalog:", + "yjs": "catalog:", "zod": "^4.3.6" }, "devDependencies": { @@ -28,6 +31,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..c3f4b54bbe --- /dev/null +++ b/apps/mcp/src/__tests__/collab-attach-user.test.ts @@ -0,0 +1,50 @@ +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(); + }); + + it('disables telemetry for headless room attach editors', async () => { + const ydoc = new YDoc({ gc: false }); + + const editor = await buildAttachEditor(ydoc, 'test-room'); + + expect(editor.options.telemetry).toEqual({ enabled: false }); + + 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__/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/__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/create-server.ts b/apps/mcp/src/create-server.ts new file mode 100644 index 0000000000..5b3224fc86 --- /dev/null +++ b/apps/mcp/src/create-server.ts @@ -0,0 +1,40 @@ +import { createRequire } from 'node:module'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getMcpPrompt } from '@superdoc-dev/sdk'; +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', 'core'] 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 async function createSuperDocMcpServer( + options: CreateSuperDocMcpServerOptions = {}, +): Promise { + const preset = options.preset ?? 'legacy'; + parseMcpPreset(preset); + + const instructions = preset === 'core' ? await getMcpPrompt('core') : MCP_SYSTEM_PROMPT; + const server = new McpServer({ name: 'superdoc', version }, { instructions }); + const sessions = new SessionManager(); + + await registerAllTools(server, sessions, preset); + return { server, sessions }; +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 7da86f46c0..f89c7df868 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -1,43 +1,25 @@ #!/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 { getMcpPrompt } from '@superdoc-dev/sdk'; -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'; +import type { SessionManager } from './session-manager.js'; // Validate MCP_PRESET at startup so misconfiguration fails fast instead of // silently falling back to 'legacy'. -// legacy (default) — the 9 grouped intent tools from the generated catalog. -// core — the actions surface (superdoc_inspect + -// superdoc_perform_action) from the SDK's core preset, -// with the SDK's MCP-flavored core prompt as instructions. -const PRESETS_SUPPORTED = new Set(['legacy', 'core']); -const requestedPreset = (process.env.MCP_PRESET ?? 'legacy') as 'legacy' | 'core'; -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 sessions = new SessionManager(); const transport = new StdioServerTransport(); +let sessions: SessionManager | undefined; async function main(): Promise { - const instructions = requestedPreset === 'core' ? await getMcpPrompt('core') : MCP_SYSTEM_PROMPT; - const server = new McpServer( - { - name: 'superdoc', - version, - }, - { - instructions, - }, - ); - await registerAllTools(server, sessions, requestedPreset); + const created = await createSuperDocMcpServer({ preset: requestedPreset }); + sessions = created.sessions; + const { server } = created; await server.connect(transport); } @@ -47,11 +29,11 @@ main().catch((err) => { }); process.on('SIGINT', async () => { - await sessions.closeAll(); + await sessions?.closeAll(); process.exit(0); }); process.on('SIGTERM', async () => { - await sessions.closeAll(); + await sessions?.closeAll(); process.exit(0); }); diff --git a/apps/mcp/src/session-manager.ts b/apps/mcp/src/session-manager.ts index 399c2a62d6..fbca4078d7 100644 --- a/apps/mcp/src/session-manager.ts +++ b/apps/mcp/src/session-manager.ts @@ -1,17 +1,36 @@ 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 { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; +import { + BLANK_DOCX_BASE64, + Editor, + getDocumentApiAdapters, + getStarterExtensions, + onCollaborationProviderSynced, +} from 'superdoc/super-editor'; +import type { CollaborationProvider } from 'superdoc/super-editor'; 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 { @@ -34,6 +53,7 @@ export class SessionManager { documentId: absolutePath, user: { id: 'mcp', name: 'MCP Server' }, telemetry: { + enabled: false, metadata: { source: 'superdoc-mcp', }, @@ -65,12 +85,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 +200,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 +222,61 @@ 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, + telemetry: { enabled: false }, + // 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 +289,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 cd2af5cb5b..22a52bb621 100644 --- a/apps/mcp/src/tools/index.ts +++ b/apps/mcp/src/tools/index.ts @@ -2,6 +2,7 @@ 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'; import { registerCoreTools } from './core.js'; export async function registerAllTools( @@ -15,4 +16,5 @@ export async function registerAllTools( return; } registerIntentTools(server, sessions); + registerCollabTools(server, sessions); } 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..95ec09d81e --- /dev/null +++ b/demos/mcp-collaboration/IMPLEMENTATION_PLAN.md @@ -0,0 +1,207 @@ +# 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. +- **2026-07-16 — Review-oriented room prompt:** the generated room prompt now + asks the connected agent to identify and make document improvements using + tracked changes, replacing the original request-driven edit wording. The + pure snippet test verifies the new exact prompt. Affects AC-6. +- **2026-07-16 — Neutral external-agent identity:** the shared room prompt uses + an `External Agent` identity so edits made through Claude Code are not + attributed to Codex. The one neutral prompt remains valid for either client. + Affects AC-6. diff --git a/demos/mcp-collaboration/Makefile b/demos/mcp-collaboration/Makefile new file mode 100644 index 0000000000..d24a55fe8e --- /dev/null +++ b/demos/mcp-collaboration/Makefile @@ -0,0 +1,62 @@ +.PHONY: install prepare 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 + +UNAME_S := $(shell uname -s) +UNAME_M := $(shell uname -m) +ifeq ($(UNAME_S),Darwin) + ifeq ($(UNAME_M),arm64) + CLI_TARGET := darwin-arm64 + else + CLI_TARGET := darwin-x64 + endif +else ifeq ($(UNAME_S),Linux) + ifeq ($(UNAME_M),aarch64) + CLI_TARGET := linux-arm64 + else + CLI_TARGET := linux-x64 + endif +endif +CLI_BINARY := superdoc +CLI_BIN := $(ROOT)/apps/cli/artifacts/$(CLI_TARGET)/$(CLI_BINARY) + +install: ## Install all monorepo and demo dependencies + cd $(ROOT) && $(PNPM) install + $(MAKE) prepare + +prepare: ## Build the workspace packages and native CLI used by the demo + cd $(ROOT) && PATH="$(DEMO_DIR)/mcp-server/node_modules/.bin:$$PATH" \ + $(PNPM) --filter @superdoc-dev/cli run build:native:host + cd $(ROOT) && $(PNPM) --filter superdoc run build + cd $(ROOT) && $(PNPM) --filter @superdoc-dev/react run build + cd $(ROOT) && $(PNPM) --filter @superdoc-dev/sdk run build + +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 && SUPERDOC_CLI_BIN=$(CLI_BIN) $(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: prepare ## Run deterministic checks; no model key or external service required + 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..508106221c --- /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` installs the demo's local Bun test runner and builds the local +SuperDoc, React, SDK, and native CLI artifacts used by the four processes. + +```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..236d894dfd --- /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": "workspace:*", + "@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 0000000000..7deeb3f0c0 Binary files /dev/null and b/demos/mcp-collaboration/client/public/blank.docx differ diff --git a/demos/mcp-collaboration/client/public/sample.docx b/demos/mcp-collaboration/client/public/sample.docx new file mode 100644 index 0000000000..3dfdba3cb2 Binary files /dev/null and b/demos/mcp-collaboration/client/public/sample.docx differ 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..cba1f15c70 --- /dev/null +++ b/demos/mcp-collaboration/client/src/components/editor/editor-workspace.tsx @@ -0,0 +1,114 @@ +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'; +import { + onCollaborationProviderSynced, + type CollaborationSyncProvider, +} from '../../lib/provider-sync'; + +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; +} + +interface EditorWorkspaceHotData { + cachedRoom?: CachedRoom | null; +} + +const hotData = import.meta.hot?.data as EditorWorkspaceHotData | undefined; +let cached: CachedRoom | null = hotData?.cachedRoom ?? null; + +function destroyCachedRoom() { + if (!cached) return; + cached.provider.disconnect(); + cached.provider.destroy(); + cached.ydoc.destroy(); + cached = null; +} + +function getOrCreateRoom(roomId: string): CachedRoom { + if (cached && cached.roomId === roomId) return cached; + + // Different room — tear down the old one + destroyCachedRoom(); + + const ydoc = new YDoc(); + const provider = new WebsocketProvider(COLLAB_URL, roomId, ydoc); + cached = { roomId, ydoc, provider }; + return cached; +} + +// Clean up on full page unload, while preserving the room across HMR. +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', destroyCachedRoom); + import.meta.hot?.dispose((data: EditorWorkspaceHotData) => { + data.cachedRoom = cached; + window.removeEventListener('beforeunload', destroyCachedRoom); + }); +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function EditorWorkspace({ roomId, displayName }: EditorWorkspaceProps) { + const [synced, setSynced] = useState(false); + + const room = useMemo(() => getOrCreateRoom(roomId), [roomId]); + + useEffect(() => { + setSynced(false); + return onCollaborationProviderSynced( + room.provider as unknown as CollaborationSyncProvider, + () => setSynced(true), + ); + }, [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 + +
+ {roomId} + +
+ + Document ready + + +
+
+ ); +} 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 ( +