Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 @@ -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": {
Expand All @@ -28,6 +31,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();
});
});
2 changes: 1 addition & 1 deletion apps/mcp/src/__tests__/intent-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>> } };
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();
Expand Down
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
40 changes: 40 additions & 0 deletions apps/mcp/src/create-server.ts
Original file line number Diff line number Diff line change
@@ -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<SuperDocMcpServer> {
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 };
}
Loading