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
1 change: 1 addition & 0 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"devDependencies": {
"@superdoc/document-api": "workspace:*",
"@superdoc/llm-tools": "workspace:*",
"@superdoc/super-editor": "workspace:*",
"superdoc": "workspace:*",
"@types/bun": "catalog:",
Expand Down
97 changes: 97 additions & 0 deletions apps/mcp/src/__tests__/intent-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'bun:test';
import { dispatch, ALL_TOOLS } from '@superdoc/llm-tools';

/** Mock executor that records calls and returns a success object. */
function mockExecutor() {
const calls: Array<{ operationId: string; input: Record<string, unknown>; options?: Record<string, unknown> }> = [];

const execute = async (operationId: string, input: Record<string, unknown>, options?: Record<string, unknown>) => {
calls.push({ operationId, input, options });
return { success: true };
};

return { execute, calls };
}

describe('MCP dispatch integration', () => {
it('dispatches superdoc_read with format "text" to getText', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_read', { format: 'text' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('getText');
});

it('dispatches superdoc_read with format "info" to info', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_read', { format: 'info' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('info');
});

it('dispatches superdoc_find with pattern to find', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_find', { pattern: 'hello' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('find');
expect(calls[0].input).toEqual({ select: { type: 'text', pattern: 'hello', mode: 'contains' } });
});

it('dispatches superdoc_edit insert action to insert', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_edit', { action: 'insert', target: '{"kind":"text"}', text: 'hi' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('insert');
});

it('dispatches superdoc_create paragraph to create.paragraph', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_create', { type: 'paragraph', text: 'Hello' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('create.paragraph');
expect(calls[0].input).toEqual({ text: 'Hello' });
});

it('dispatches superdoc_comment list action to comments.list', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_comment', { action: 'list' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('comments.list');
});

it('dispatches superdoc_review list action to trackChanges.list', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_review', { action: 'list' }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('trackChanges.list');
});

it('dispatches superdoc_format with bold to format.apply', async () => {
const { execute, calls } = mockExecutor();
await dispatch('superdoc_format', { target: '{"kind":"text"}', bold: true }, execute);
expect(calls).toHaveLength(1);
expect(calls[0].operationId).toBe('format.apply');
});

it('throws for unknown tool name', async () => {
const { execute } = mockExecutor();
expect(dispatch('superdoc_nonexistent', {}, execute)).rejects.toThrow('Unknown tool');
});

it('all 13 tool names are dispatchable', () => {
const toolNames = ALL_TOOLS.map((t) => t.name);
expect(toolNames).toHaveLength(13);
expect(toolNames).toContain('superdoc_read');
expect(toolNames).toContain('superdoc_find');
expect(toolNames).toContain('superdoc_edit');
expect(toolNames).toContain('superdoc_create');
expect(toolNames).toContain('superdoc_format');
expect(toolNames).toContain('superdoc_table');
expect(toolNames).toContain('superdoc_list');
expect(toolNames).toContain('superdoc_image');
expect(toolNames).toContain('superdoc_comment');
expect(toolNames).toContain('superdoc_review');
expect(toolNames).toContain('superdoc_section');
expect(toolNames).toContain('superdoc_reference');
expect(toolNames).toContain('superdoc_control');
});
});
140 changes: 140 additions & 0 deletions apps/mcp/src/__tests__/json-schema-to-zod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'bun:test';
import { z } from 'zod';
import { jsonSchemaToZodShape } from '../tools/json-schema-to-zod.js';
import { ALL_TOOLS } from '@superdoc/llm-tools';

describe('jsonSchemaToZodShape', () => {
it('converts string properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
name: { type: 'string', description: 'A name' },
},
required: ['name'],
});

expect(shape.name).toBeDefined();
const result = z.object(shape).safeParse({ name: 'hello' });
expect(result.success).toBe(true);
});

it('converts string enum properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
action: { type: 'string', enum: ['insert', 'replace', 'delete'], description: 'Action' },
},
required: ['action'],
});

const schema = z.object(shape);
expect(schema.safeParse({ action: 'insert' }).success).toBe(true);
expect(schema.safeParse({ action: 'invalid' }).success).toBe(false);
});

it('converts number properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
count: { type: 'number', description: 'A count' },
},
required: ['count'],
});

const schema = z.object(shape);
expect(schema.safeParse({ count: 42 }).success).toBe(true);
expect(schema.safeParse({ count: 'nope' }).success).toBe(false);
});

it('converts boolean properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
suggest: { type: 'boolean', description: 'Suggest mode' },
},
required: [],
});

const schema = z.object(shape);
expect(schema.safeParse({ suggest: true }).success).toBe(true);
expect(schema.safeParse({}).success).toBe(true); // optional
});

it('marks required vs optional correctly', () => {
const shape = jsonSchemaToZodShape({
properties: {
session_id: { type: 'string' },
target: { type: 'string' },
text: { type: 'string' },
},
required: ['session_id', 'target'],
});

const schema = z.object(shape);
expect(schema.safeParse({ session_id: 's1', target: 't1' }).success).toBe(true);
expect(schema.safeParse({ session_id: 's1' }).success).toBe(false); // missing target
});

it('converts nested object properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
start: {
type: 'object',
properties: { rowIndex: { type: 'number' }, columnIndex: { type: 'number' } },
description: 'Start cell',
},
},
required: [],
});

const schema = z.object(shape);
expect(schema.safeParse({ start: { rowIndex: 0, columnIndex: 1 } }).success).toBe(true);
expect(schema.safeParse({}).success).toBe(true); // optional
});

it('converts array properties', () => {
const shape = jsonSchemaToZodShape({
properties: {
keys: {
type: 'array',
items: {
type: 'object',
properties: {
columnIndex: { type: 'number' },
direction: { type: 'string', enum: ['ascending', 'descending'] },
},
},
description: 'Sort keys',
},
},
required: [],
});

const schema = z.object(shape);
expect(schema.safeParse({ keys: [{ columnIndex: 0, direction: 'ascending' }] }).success).toBe(true);
});

it('handles properties with no type (z.any)', () => {
const shape = jsonSchemaToZodShape({
properties: {
value: { description: 'Any value' },
},
required: [],
});

const schema = z.object(shape);
expect(schema.safeParse({ value: 'text' }).success).toBe(true);
expect(schema.safeParse({ value: 42 }).success).toBe(true);
expect(schema.safeParse({ value: true }).success).toBe(true);
});

it('converts all 13 llm-tools definitions without error', () => {
expect(ALL_TOOLS).toHaveLength(13);

for (const tool of ALL_TOOLS) {
const shape = jsonSchemaToZodShape(tool.inputSchema);
expect(shape).toBeDefined();
expect(shape.session_id).toBeDefined();

// Verify the shape can be used in z.object
const schema = z.object(shape);
expect(schema).toBeDefined();
}
});
});
47 changes: 24 additions & 23 deletions apps/mcp/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,24 @@ const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blan
const SERVER_ENTRY = resolve(import.meta.dir, '../index.ts');

const EXPECTED_TOOLS = [
// Lifecycle tools (transport-specific)
'superdoc_open',
'superdoc_save',
'superdoc_close',
// Intent-based tools (from @superdoc/llm-tools)
'superdoc_read',
'superdoc_find',
'superdoc_get_node',
'superdoc_info',
'superdoc_get_text',
'superdoc_insert',
'superdoc_replace',
'superdoc_delete',
'superdoc_format',
'superdoc_edit',
'superdoc_create',
'superdoc_list_changes',
'superdoc_accept_change',
'superdoc_reject_change',
'superdoc_accept_all_changes',
'superdoc_reject_all_changes',
'superdoc_add_comment',
'superdoc_list_comments',
'superdoc_reply_comment',
'superdoc_resolve_comment',
'superdoc_insert_list',
'superdoc_list_create',
'superdoc_format',
'superdoc_table',
'superdoc_list',
'superdoc_image',
'superdoc_comment',
'superdoc_review',
'superdoc_section',
'superdoc_reference',
'superdoc_control',
];

function textContent(result: Awaited<ReturnType<Client['callTool']>>): string {
Expand Down Expand Up @@ -79,7 +74,7 @@ describe('MCP protocol integration', () => {
}
});

it('open → info → get_text → close workflow', async () => {
it('open → read info → read text → close workflow', async () => {
await ready;

// Open
Expand All @@ -90,12 +85,18 @@ describe('MCP protocol integration', () => {

const sid = opened.session_id;

// Info
const infoResult = await client.callTool({ name: 'superdoc_info', arguments: { session_id: sid } });
// Read info
const infoResult = await client.callTool({
name: 'superdoc_read',
arguments: { session_id: sid, format: 'info' },
});
expect(textContent(infoResult)).toBeTruthy();

// Get text
const textResult = await client.callTool({ name: 'superdoc_get_text', arguments: { session_id: sid } });
// Read text
const textResult = await client.callTool({
name: 'superdoc_read',
arguments: { session_id: sid, format: 'text' },
});
expect(textContent(textResult)).toBeDefined();

// Close
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('MCP tools integration', () => {
const result = api.invoke({
operationId: 'find',
input: {
query: { select: { type: 'node', nodeType: 'paragraph' } },
select: { type: 'node', nodeKind: 'paragraph' },
},
});

Expand Down
Loading
Loading