diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 03ccaaea1c..56a825c672 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