Skip to content

Commit c0dad77

Browse files
Jacob Joveclaude
authored andcommitted
feat(mcp): attach to live Yjs collaboration rooms with attributed tracked changes
Add `superdoc_attach` so an MCP client can join a live SuperDoc Yjs collaboration room over WebSocket (openRoom + WebsocketProvider, awaiting initial sync) instead of only round-tripping a local .docx. The returned session_id works with every existing tool. The motivating use case is agent-assisted review: suggesting tracked redlines into a document a human has open, attributed to a named reviewer, for accept/reject. Tracked-change attribution: `superdoc_attach` accepts an optional user ({ id, name, email }) threaded through openRoom into buildAttachEditor's headless Editor config. Without a configured user, forceTrackChanges rejects tracked edits, so suggested edits over an attach could not be attributed. The file-open path already sets a default user; this brings the attach path to parity, scoped to a caller-supplied identity. Collab-aware save export: a joiner editor is built with no docx source, so converter.convertedXml carried none of the base OOXML parts that Editor.exportDocx dereferences. The deref threw and (via exportDocx's catch) surfaced as "not binary (got undefined)". buildAttachEditor now seeds the blank-docx template via Editor.loadXmlData so export has valid scaffolding; Yjs still drives the body (the initial ProseMirror doc is seeded from `content` only when no ydoc is present). save() rejects room saves without an explicit output path. 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. Adds yjs, y-websocket, and ws dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 544a8e1 commit c0dad77

8 files changed

Lines changed: 285 additions & 11 deletions

File tree

apps/mcp/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
},
2020
"dependencies": {
2121
"@modelcontextprotocol/sdk": "^1.26.0",
22+
"ws": "^8.18.3",
23+
"y-websocket": "catalog:",
24+
"yjs": "catalog:",
2225
"zod": "^4.3.6"
2326
},
2427
"devDependencies": {
@@ -27,6 +30,7 @@
2730
"superdoc": "workspace:*",
2831
"@types/bun": "catalog:",
2932
"@types/node": "catalog:",
33+
"@types/ws": "catalog:",
3034
"typescript": "catalog:"
3135
},
3236
"publishConfig": {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import { Doc as YDoc } from 'yjs';
3+
import { buildAttachEditor } from '../session-manager.js';
4+
5+
/**
6+
* Tracked-change authoring over a collab attach requires a configured user.
7+
* Without one, `forceTrackChanges` rejects the edit ("forceTrackChanges requires
8+
* a user to be configured on the editor instance") because the gate reads
9+
* `editor.options.user`, which is null on a bare attach.
10+
*
11+
* `buildAttachEditor` accepts an optional user and wires it into the headless
12+
* Editor config so suggested edits can be attributed to a reviewer.
13+
*/
14+
describe('collab attach user identity (tracked-change user wiring)', () => {
15+
// Scope: this asserts buildAttachEditor wires `user` into the Editor config —
16+
// the input the forceTrackChanges gate reads. The gate's own behavior (rejecting
17+
// tracked edits when no user is set) belongs to super-editor and is tested there.
18+
it('configures the tracked-change user on the attach editor when supplied', async () => {
19+
const ydoc = new YDoc({ gc: false });
20+
const user = { id: 'reviewer-1', name: 'Reviewer', email: 'reviewer@example.com' };
21+
22+
const editor = await buildAttachEditor(ydoc, 'test-room', user);
23+
24+
// This is the exact value the forceTrackChanges gate reads.
25+
expect(editor.options.user).toEqual(user);
26+
27+
editor.destroy();
28+
});
29+
30+
it('leaves the user unset when none is supplied (default preserved)', async () => {
31+
const ydoc = new YDoc({ gc: false });
32+
33+
const editor = await buildAttachEditor(ydoc, 'test-room');
34+
35+
// Editor default for `user` is null; the no-arg attach path must not invent one.
36+
expect(editor.options.user ?? null).toBeNull();
37+
38+
editor.destroy();
39+
});
40+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import { Doc as YDoc } from 'yjs';
3+
import { Editor } from 'superdoc/super-editor';
4+
import { buildAttachEditor } from '../session-manager.js';
5+
6+
/**
7+
* Regression: `superdoc_save` over a collab attach reported
8+
* "Exported document data is not binary (got undefined)".
9+
*
10+
* Root cause: the attach editor was built with no docx source, so
11+
* `converter.convertedXml` carried none of the base OOXML parts that
12+
* `Editor.exportDocx` unguarded-derefs (docProps/custom.xml, word/styles.xml,
13+
* word/_rels/document.xml.rels). The deref threw; the swallowing catch in
14+
* exportDocx returned `undefined`.
15+
*
16+
* A collab-joiner editor must export a valid .docx even with an empty Yjs doc.
17+
*/
18+
describe('collab attach export (superdoc_save over a room)', () => {
19+
it('exports binary .docx bytes from a collab-joiner editor with no docx source', async () => {
20+
const ydoc = new YDoc({ gc: false });
21+
const editor = await buildAttachEditor(ydoc, 'test-room');
22+
23+
const exported = await editor.exportDocument();
24+
25+
// The pre-fix failure mode: exportDocx throws on the missing parts and the
26+
// catch swallows to undefined.
27+
expect(exported).toBeDefined();
28+
29+
const bytes = exported instanceof Uint8Array ? exported : new Uint8Array(await (exported as Blob).arrayBuffer());
30+
31+
expect(bytes.byteLength).toBeGreaterThan(0);
32+
// Valid .docx is a ZIP — "PK" local-file-header magic.
33+
expect(bytes[0]).toBe(0x50); // 'P'
34+
expect(bytes[1]).toBe(0x4b); // 'K'
35+
36+
// Round-trip: the exported bytes must re-open as a structurally valid docx
37+
// carrying the base OOXML parts that were previously missing.
38+
const [parts] = (await Editor.loadXmlData(Buffer.from(bytes), true))!;
39+
const names = new Set(parts.map((p: { name: string }) => p.name));
40+
expect(names.has('word/document.xml')).toBe(true);
41+
expect(names.has('word/styles.xml')).toBe(true);
42+
expect(names.has('word/_rels/document.xml.rels')).toBe(true);
43+
44+
editor.destroy();
45+
});
46+
});

apps/mcp/src/__tests__/protocol.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
66
const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blank.docx');
77
const SERVER_ENTRY = resolve(import.meta.dir, '../index.ts');
88

9-
// 3 lifecycle + 10 intent tools from the generated catalog
9+
// 4 lifecycle + 10 intent tools from the generated catalog
1010
const EXPECTED_TOOLS = [
1111
// Lifecycle
1212
'superdoc_open',
13+
'superdoc_attach',
1314
'superdoc_save',
1415
'superdoc_close',
1516
// Intent tools (from catalog.json)
@@ -77,8 +78,10 @@ describe('MCP protocol integration', () => {
7778
const { tools } = await client.listTools();
7879

7980
// Multi-action intent tools should have an "action" property with an enum
81+
// superdoc_attach, like superdoc_open, is a session-creating lifecycle tool — no action enum.
8082
const multiActionTools = tools.filter(
81-
(t) => !['superdoc_open', 'superdoc_save', 'superdoc_close', 'superdoc_search'].includes(t.name),
83+
(t) =>
84+
!['superdoc_open', 'superdoc_attach', 'superdoc_save', 'superdoc_close', 'superdoc_search'].includes(t.name),
8285
);
8386

8487
for (const tool of multiActionTools) {
@@ -93,8 +96,11 @@ describe('MCP protocol integration', () => {
9396
await ready;
9497
const { tools } = await client.listTools();
9598

96-
// All intent tools (not lifecycle open) should require session_id
97-
const intentTools = tools.filter((t) => !['superdoc_open', 'superdoc_save', 'superdoc_close'].includes(t.name));
99+
// All intent tools (not session-creating lifecycle tools) should require session_id.
100+
// superdoc_open and superdoc_attach both produce a session_id rather than consuming one.
101+
const intentTools = tools.filter(
102+
(t) => !['superdoc_open', 'superdoc_attach', 'superdoc_save', 'superdoc_close'].includes(t.name),
103+
);
98104

99105
for (const tool of intentTools) {
100106
const schema = tool.inputSchema as { properties?: Record<string, unknown>; required?: string[] };

apps/mcp/src/session-manager.ts

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { access, readFile, writeFile } from 'node:fs/promises';
22
import { randomBytes } from 'node:crypto';
33
import { resolve, basename } from 'node:path';
4-
import { Editor } from 'superdoc/super-editor';
4+
import { Editor, getStarterExtensions } from 'superdoc/super-editor';
55
import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters';
66
import { createDocumentApi, type DocumentApi } from '@superdoc/document-api';
77
import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx';
8+
import { Doc as YDoc } from 'yjs';
9+
import { WebsocketProvider } from 'y-websocket';
810

911
export interface Session {
1012
id: string;
11-
filePath: string;
13+
filePath: string | null;
1214
editor: Editor;
1315
api: DocumentApi;
1416
openedAt: number;
17+
provider?: WebsocketProvider;
1518
}
1619

1720
export class SessionManager {
@@ -65,12 +68,63 @@ export class SessionManager {
6568
return session;
6669
}
6770

71+
async openRoom(wsUrl: string, documentId: string, token?: string, user?: AttachUser): Promise<Session> {
72+
const ydoc = new YDoc({ gc: false });
73+
74+
// y-websocket needs a WebSocket constructor; Node has no global one, so supply `ws`.
75+
const { default: WebSocket } = await import('ws');
76+
// Auth token is passed as a `params` query entry — the same mechanism SuperDoc's
77+
// own y-websocket provider uses (createSuperDocProvider in
78+
// packages/superdoc/src/core/collaboration/collaboration.js). The y-websocket
79+
// protocol has no header channel, so the token rides the connect URL.
80+
const provider = new WebsocketProvider(wsUrl, documentId, ydoc, {
81+
WebSocketPolyfill: WebSocket as unknown as typeof globalThis.WebSocket,
82+
params: token ? { token } : {},
83+
});
84+
85+
// Await initial sync before editor construction so the fragment is populated
86+
await new Promise<void>((resolve, reject) => {
87+
const timeout = setTimeout(() => {
88+
provider.destroy();
89+
reject(new Error('sync timeout (10s)'));
90+
}, 10_000);
91+
provider.once('sync', (synced: boolean) => {
92+
if (synced) {
93+
clearTimeout(timeout);
94+
resolve();
95+
}
96+
});
97+
});
98+
99+
const editor = await buildAttachEditor(ydoc, documentId, user);
100+
101+
const adapters = getDocumentApiAdapters(editor);
102+
const api = createDocumentApi(adapters);
103+
104+
const id = generateRoomSessionId(documentId);
105+
106+
const session: Session = {
107+
id,
108+
filePath: null,
109+
editor,
110+
api,
111+
openedAt: Date.now(),
112+
provider,
113+
};
114+
115+
this.sessions.set(id, session);
116+
return session;
117+
}
118+
68119
async save(sessionId: string, outputPath?: string): Promise<{ path: string; byteLength: number }> {
69120
const session = this.get(sessionId);
70-
const targetPath = outputPath ? resolve(outputPath) : session.filePath;
121+
if (!outputPath && !session.filePath) {
122+
throw new Error('Cannot save a room session without specifying an output path.');
123+
}
124+
const targetPath = outputPath ? resolve(outputPath) : resolve(session.filePath!);
71125

72126
const exported = await session.editor.exportDocument();
73-
const bytes = toUint8Array(exported);
127+
const bytes = await toBytes(exported);
74128

75129
await writeFile(targetPath, bytes);
76130

@@ -81,18 +135,20 @@ export class SessionManager {
81135
const session = this.sessions.get(sessionId);
82136
if (!session) return;
83137

138+
session.provider?.destroy();
84139
session.editor.destroy();
85140
this.sessions.delete(sessionId);
86141
}
87142

88143
async closeAll(): Promise<void> {
89144
for (const session of this.sessions.values()) {
145+
session.provider?.destroy();
90146
session.editor.destroy();
91147
}
92148
this.sessions.clear();
93149
}
94150

95-
list(): Array<{ id: string; filePath: string; openedAt: number }> {
151+
list(): Array<{ id: string; filePath: string | null; openedAt: number }> {
96152
return Array.from(this.sessions.values()).map((s) => ({
97153
id: s.id,
98154
filePath: s.filePath,
@@ -101,6 +157,60 @@ export class SessionManager {
101157
}
102158
}
103159

160+
/** Identity for attributing tracked changes authored over a collab attach. */
161+
export interface AttachUser {
162+
id?: string;
163+
name?: string;
164+
email?: string;
165+
}
166+
167+
/**
168+
* Build the headless Editor for a collaborative attach session.
169+
*
170+
* The document body arrives via the Yjs fragment (`ydoc`); `content` only seeds
171+
* the base OOXML parts (`converter.convertedXml`) that `Editor.exportDocx` derefs
172+
* on save. A bare `content: []` leaves those parts empty, so export throws and the
173+
* swallowing catch returns `undefined` ("not binary (got undefined)"). Seeding the
174+
* blank-docx template gives export valid scaffolding while Yjs still drives the body
175+
* (Editor only seeds the initial PM doc from `content` when no `ydoc` is present).
176+
*
177+
* An optional `user` configures the tracked-change author so an MCP client can
178+
* author attributable tracked (suggested) edits over the attach; without it,
179+
* `forceTrackChanges` rejects tracked edits. The file-open path (`open`) already
180+
* sets a default user; this brings the attach path to parity.
181+
*/
182+
export async function buildAttachEditor(ydoc: YDoc, documentId: string, user?: AttachUser): Promise<Editor> {
183+
const blankBytes = Buffer.from(BLANK_DOCX_BASE64, 'base64');
184+
const [content, , mediaFiles, fonts] = (await Editor.loadXmlData(blankBytes, true))!;
185+
186+
return new Editor({
187+
isHeadless: true,
188+
mode: 'docx',
189+
documentId,
190+
extensions: getStarterExtensions(),
191+
ydoc,
192+
content,
193+
mediaFiles,
194+
fonts,
195+
fileSource: blankBytes,
196+
// Without a user, `forceTrackChanges` rejects tracked edits. Supplying one
197+
// lets the caller author attributable tracked changes over the attach.
198+
...(user ? { user } : {}),
199+
});
200+
}
201+
202+
function generateRoomSessionId(documentId: string): string {
203+
const stem = documentId.replace(/\//g, '-').replace(/[^a-zA-Z0-9._-]+/g, '-') || 'room';
204+
const normalized =
205+
stem
206+
.toLowerCase()
207+
.replace(/[^a-z0-9._-]+/g, '-')
208+
.replace(/-{2,}/g, '-')
209+
.replace(/^[._-]+|[._-]+$/g, '') || 'room';
210+
const suffix = randomBytes(4).toString('hex').slice(0, 6);
211+
return `room-${normalized.slice(0, 50)}-${suffix}`;
212+
}
213+
104214
function generateSessionId(filePath: string): string {
105215
const stem = basename(filePath).replace(/\.[^.]+$/, '');
106216
const normalized =
@@ -113,11 +223,17 @@ function generateSessionId(filePath: string): string {
113223
return `${normalized.slice(0, 57)}-${suffix}`;
114224
}
115225

116-
function toUint8Array(data: unknown): Uint8Array {
226+
async function toBytes(data: unknown): Promise<Uint8Array> {
117227
if (data instanceof Uint8Array) return data;
118228
if (data instanceof ArrayBuffer) return new Uint8Array(data);
119229
if (ArrayBuffer.isView(data)) {
120230
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
121231
}
122-
throw new Error('Exported document data is not binary.');
232+
// Blob (incl. cross-realm/polyfilled instances where `instanceof Blob` is false): duck-type
233+
// on arrayBuffer(). Headless/collab exportDocument() returns a Blob.
234+
if (data && typeof (data as { arrayBuffer?: unknown }).arrayBuffer === 'function') {
235+
return new Uint8Array(await (data as Blob).arrayBuffer());
236+
}
237+
const desc = data == null ? String(data) : `${typeof data}/${(data as object).constructor?.name ?? 'unknown'}`;
238+
throw new Error(`Exported document data is not binary (got ${desc}).`);
123239
}

apps/mcp/src/tools/collab.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from 'zod';
2+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
import type { SessionManager } from '../session-manager.js';
4+
5+
export function registerCollabTools(server: McpServer, sessions: SessionManager): void {
6+
server.registerTool(
7+
'superdoc_attach',
8+
{
9+
title: 'Attach to Collaboration Room',
10+
description:
11+
'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.',
12+
inputSchema: {
13+
ws_url: z.string().describe('WebSocket URL base, e.g. ws://localhost:4444/doc'),
14+
document_id: z.string().describe('Document/room identifier'),
15+
token: z.string().optional().describe('Optional auth token passed as query param'),
16+
user: z
17+
.object({
18+
id: z.string().optional(),
19+
name: z.string().optional(),
20+
email: z.string().optional(),
21+
})
22+
.optional()
23+
.describe(
24+
'Optional identity for attributing tracked changes. Required to author tracked (suggested) edits over the attach; direct edits work without it.',
25+
),
26+
},
27+
annotations: { readOnlyHint: false },
28+
},
29+
async ({ ws_url, document_id, token, user }) => {
30+
try {
31+
const session = await sessions.openRoom(ws_url, document_id, token, user);
32+
return {
33+
content: [
34+
{
35+
type: 'text' as const,
36+
text: JSON.stringify({ session_id: session.id }),
37+
},
38+
],
39+
};
40+
} catch (err) {
41+
return {
42+
content: [{ type: 'text' as const, text: `Failed to attach to room: ${(err as Error).message}` }],
43+
isError: true,
44+
};
45+
}
46+
},
47+
);
48+
}

apps/mcp/src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import type { SessionManager } from '../session-manager.js';
33
import { registerLifecycleTools } from './lifecycle.js';
44
import { registerIntentTools } from './intent.js';
5+
import { registerCollabTools } from './collab.js';
56

67
export function registerAllTools(server: McpServer, sessions: SessionManager): void {
78
registerLifecycleTools(server, sessions);
89
registerIntentTools(server, sessions);
10+
registerCollabTools(server, sessions);
911
}

0 commit comments

Comments
 (0)