Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"ws": "^8.18.3",
"y-websocket": "catalog:",
"yjs": "catalog:",
"zod": "^4.3.6"
},
"devDependencies": {
Expand All @@ -27,6 +30,7 @@
"superdoc": "workspace:*",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"typescript": "catalog:"
},
"publishConfig": {
Expand Down
40 changes: 40 additions & 0 deletions apps/mcp/src/__tests__/collab-attach-user.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
188 changes: 188 additions & 0 deletions apps/mcp/src/__tests__/collab-attach.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderEvent, Set<SyncHandler>> = {
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<void>((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<typeof providerStub>;
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'
});
});
46 changes: 46 additions & 0 deletions apps/mcp/src/__tests__/collab-export.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 10 additions & 4 deletions apps/mcp/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, unknown>; required?: string[] };
Expand Down
Loading