Skip to content

Commit 4c75ffe

Browse files
Jacob Joveclaude
authored andcommitted
feat(mcp): expose blocks.delete/deleteRange via superdoc_edit
Wire the existing document-api blocks.delete / blocks.deleteRange ops into the MCP tool surface as superdoc_edit actions delete_block and delete_block_range, so clients can remove an entire block node, not just its text content. Deleting a heading/paragraph's text previously left an empty container behind, still rendering as block spacing. Regenerates the MCP catalog and intent-dispatch (Node, browser, Python) and adds end-to-end protocol tests proving physical block removal (block-count decrement + nodeId absence). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4c51e5c commit 4c75ffe

6 files changed

Lines changed: 416 additions & 212 deletions

File tree

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,153 @@ describe('MCP protocol integration', () => {
203203
const body = JSON.parse(textContent(result));
204204
expect(body).toHaveProperty('session_id');
205205
});
206+
207+
// ---------------------------------------------------------------------------
208+
// Block-node deletion via superdoc_edit (delete_block / delete_block_range)
209+
//
210+
// These tests prove the *reported bug* is fixed end-to-end through the MCP
211+
// layer: a text `delete` (by ref) empties a block but leaves the container
212+
// behind, whereas `delete_block` (by {kind:'block', nodeType, nodeId} target)
213+
// physically removes the whole block node.
214+
//
215+
// Addressing model (must be respected or the test fails):
216+
// - get_content action:"blocks" returns { total, blocks: [...], revision };
217+
// each entry exposes BOTH a `nodeId` and a `ref`.
218+
// - text `delete` takes a `ref`.
219+
// - `delete_block` takes `target: {kind:'block', nodeType, nodeId}`.
220+
// - refs and block handles EXPIRE after ANY mutation; we re-fetch blocks
221+
// before every call that consumes a handle.
222+
// ---------------------------------------------------------------------------
223+
224+
interface BlockEntry {
225+
nodeId: string;
226+
nodeType: string;
227+
isEmpty: boolean;
228+
ref?: string;
229+
textPreview: string | null;
230+
}
231+
232+
interface BlocksPayload {
233+
total: number;
234+
blocks: BlockEntry[];
235+
revision: string;
236+
}
237+
238+
// Always re-fetch — never cache a handle across a mutation.
239+
async function fetchBlocks(sid: string): Promise<BlocksPayload> {
240+
const result = await client.callTool({
241+
name: 'superdoc_get_content',
242+
arguments: { session_id: sid, action: 'blocks' },
243+
});
244+
return parseContent(result) as BlocksPayload;
245+
}
246+
247+
it('exposes delete_block and delete_block_range in the superdoc_edit action enum', async () => {
248+
await ready;
249+
const { tools } = await client.listTools();
250+
251+
const editTool = tools.find((t) => t.name === 'superdoc_edit');
252+
expect(editTool).toBeDefined();
253+
254+
const schema = editTool!.inputSchema as { properties?: Record<string, { enum?: string[] }> };
255+
const actionEnum = schema.properties?.action?.enum;
256+
expect(actionEnum).toBeArray();
257+
expect(actionEnum).toContain('delete_block');
258+
expect(actionEnum).toContain('delete_block_range');
259+
});
260+
261+
it('delete_block physically removes a block node (count decrements, nodeId absent)', async () => {
262+
await ready;
263+
264+
// Open a blank document.
265+
const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } });
266+
const { session_id: sid } = parseContent(openResult) as { session_id: string };
267+
268+
// Create a heading and a paragraph (proven create workflow).
269+
const headingResult = await client.callTool({
270+
name: 'superdoc_create',
271+
arguments: { session_id: sid, action: 'heading', text: 'Block To Delete', level: 1 },
272+
});
273+
expect(textContent(headingResult)).toBeTruthy();
274+
275+
const paraResult = await client.callTool({
276+
name: 'superdoc_create',
277+
arguments: { session_id: sid, action: 'paragraph', text: 'Surviving paragraph' },
278+
});
279+
expect(textContent(paraResult)).toBeTruthy();
280+
281+
// Record block count N and the heading's nodeId from the authoritative listing.
282+
const before = await fetchBlocks(sid);
283+
const heading = before.blocks.find((b) => b.nodeType === 'heading');
284+
expect(heading).toBeDefined();
285+
const headingNodeId = heading!.nodeId;
286+
const countBefore = before.blocks.length;
287+
expect(countBefore).toBeGreaterThan(1);
288+
289+
// Delete the entire heading block by its target address.
290+
const deleteResult = await client.callTool({
291+
name: 'superdoc_edit',
292+
arguments: {
293+
session_id: sid,
294+
action: 'delete_block',
295+
target: { kind: 'block', nodeType: 'heading', nodeId: headingNodeId },
296+
},
297+
});
298+
expect(deleteResult).not.toHaveProperty('isError');
299+
300+
// Re-fetch (handles expired) and assert PHYSICAL removal.
301+
const after = await fetchBlocks(sid);
302+
expect(after.blocks.length).toBe(countBefore - 1);
303+
expect(after.blocks.some((b) => b.nodeId === headingNodeId)).toBe(false);
304+
305+
await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } });
306+
});
307+
308+
it('text delete leaves an empty container; delete_block removes it (the reported bug)', async () => {
309+
await ready;
310+
311+
// Open a blank document and create a heading to operate on.
312+
const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } });
313+
const { session_id: sid } = parseContent(openResult) as { session_id: string };
314+
315+
await client.callTool({
316+
name: 'superdoc_create',
317+
arguments: { session_id: sid, action: 'heading', text: 'Heading body text', level: 1 },
318+
});
319+
320+
// Locate the heading; capture its nodeId AND its fresh ref.
321+
const before = await fetchBlocks(sid);
322+
const heading = before.blocks.find((b) => b.nodeType === 'heading');
323+
expect(heading).toBeDefined();
324+
const headingNodeId = heading!.nodeId;
325+
expect(heading!.ref).toBeString();
326+
327+
// 1) Text `delete` by ref: empties the block but leaves the container.
328+
const textDelete = await client.callTool({
329+
name: 'superdoc_edit',
330+
arguments: { session_id: sid, action: 'delete', ref: heading!.ref },
331+
});
332+
expect(textDelete).not.toHaveProperty('isError');
333+
334+
const afterTextDelete = await fetchBlocks(sid);
335+
const survivor = afterTextDelete.blocks.find((b) => b.nodeId === headingNodeId);
336+
expect(survivor).toBeDefined(); // container survives — this is the bug
337+
expect(survivor!.isEmpty).toBe(true);
338+
339+
// 2) delete_block by target: physically removes the container.
340+
const blockDelete = await client.callTool({
341+
name: 'superdoc_edit',
342+
arguments: {
343+
session_id: sid,
344+
action: 'delete_block',
345+
target: { kind: 'block', nodeType: 'heading', nodeId: headingNodeId },
346+
},
347+
});
348+
expect(blockDelete).not.toHaveProperty('isError');
349+
350+
const afterBlockDelete = await fetchBlocks(sid);
351+
expect(afterBlockDelete.blocks.some((b) => b.nodeId === headingNodeId)).toBe(false);
352+
353+
await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } });
354+
});
206355
});

0 commit comments

Comments
 (0)