From b36f627ac834f9c83325d535a3eb51612744eb6c Mon Sep 17 00:00:00 2001 From: Jacob Jove Date: Fri, 29 May 2026 23:16:59 -0400 Subject: [PATCH] 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 --- apps/mcp/src/__tests__/protocol.test.ts | 227 +++++++++ apps/mcp/src/generated/catalog.ts | 461 ++++++++++-------- .../generated/intent-dispatch.generated.ts | 4 + .../src/contract/operation-definitions.ts | 6 + .../sdk/langs/browser/src/intent-dispatch.ts | 4 + .../sdk/tools/intent_dispatch_generated.py | 4 + 6 files changed, 494 insertions(+), 212 deletions(-) diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts index 4d7718b6d3..4f4b12789c 100644 --- a/apps/mcp/src/__tests__/protocol.test.ts +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -203,4 +203,231 @@ describe('MCP protocol integration', () => { const body = JSON.parse(textContent(result)); expect(body).toHaveProperty('session_id'); }); + + // --------------------------------------------------------------------------- + // Block-node deletion via superdoc_edit (delete_block / delete_block_range) + // + // These tests prove the *reported bug* is fixed end-to-end through the MCP + // layer: a text `delete` (by ref) empties a block but leaves the container + // behind, whereas `delete_block` (by {kind:'block', nodeType, nodeId} target) + // physically removes the whole block node. + // + // Addressing model (must be respected or the test fails): + // - get_content action:"blocks" returns { total, blocks: [...], revision }; + // each entry exposes BOTH a `nodeId` and a `ref`. + // - text `delete` takes a `ref`. + // - `delete_block` takes `target: {kind:'block', nodeType, nodeId}`. + // - refs and block handles EXPIRE after ANY mutation; we re-fetch blocks + // before every call that consumes a handle. + // --------------------------------------------------------------------------- + + interface BlockEntry { + nodeId: string; + nodeType: string; + isEmpty: boolean; + ref?: string; + textPreview: string | null; + } + + interface BlocksPayload { + total: number; + blocks: BlockEntry[]; + revision: string; + } + + // Always re-fetch — never cache a handle across a mutation. + async function fetchBlocks(sid: string): Promise { + const result = await client.callTool({ + name: 'superdoc_get_content', + arguments: { session_id: sid, action: 'blocks' }, + }); + return parseContent(result) as BlocksPayload; + } + + it('exposes delete_block and delete_block_range in the superdoc_edit action enum', async () => { + await ready; + const { tools } = await client.listTools(); + + const editTool = tools.find((t) => t.name === 'superdoc_edit'); + expect(editTool).toBeDefined(); + + const schema = editTool!.inputSchema as { properties?: Record }; + const actionEnum = schema.properties?.action?.enum; + expect(actionEnum).toBeArray(); + expect(actionEnum).toContain('delete_block'); + expect(actionEnum).toContain('delete_block_range'); + }); + + it('delete_block physically removes a block node (count decrements, nodeId absent)', async () => { + await ready; + + // Open a blank document. + const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } }); + const { session_id: sid } = parseContent(openResult) as { session_id: string }; + + // Create a heading and a paragraph (proven create workflow). + const headingResult = await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, action: 'heading', text: 'Block To Delete', level: 1 }, + }); + expect(textContent(headingResult)).toBeTruthy(); + + const paraResult = await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, action: 'paragraph', text: 'Surviving paragraph' }, + }); + expect(textContent(paraResult)).toBeTruthy(); + + // Record block count N and the heading's nodeId from the authoritative listing. + const before = await fetchBlocks(sid); + const heading = before.blocks.find((b) => b.nodeType === 'heading'); + expect(heading).toBeDefined(); + const headingNodeId = heading!.nodeId; + const countBefore = before.blocks.length; + expect(countBefore).toBeGreaterThan(1); + + // Delete the entire heading block by its target address. + const deleteResult = await client.callTool({ + name: 'superdoc_edit', + arguments: { + session_id: sid, + action: 'delete_block', + target: { kind: 'block', nodeType: 'heading', nodeId: headingNodeId }, + }, + }); + expect(deleteResult).not.toHaveProperty('isError'); + + // Re-fetch (handles expired) and assert PHYSICAL removal. + const after = await fetchBlocks(sid); + expect(after.blocks.length).toBe(countBefore - 1); + expect(after.blocks.some((b) => b.nodeId === headingNodeId)).toBe(false); + + await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } }); + }); + + it('text delete leaves an empty container; delete_block removes it (the reported bug)', async () => { + await ready; + + // Open a blank document and create a heading to operate on. + const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } }); + const { session_id: sid } = parseContent(openResult) as { session_id: string }; + + await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, action: 'heading', text: 'Heading body text', level: 1 }, + }); + + // Locate the heading; capture its nodeId AND its fresh ref. + const before = await fetchBlocks(sid); + const heading = before.blocks.find((b) => b.nodeType === 'heading'); + expect(heading).toBeDefined(); + const headingNodeId = heading!.nodeId; + expect(heading!.ref).toBeString(); + + // 1) Text `delete` by ref: empties the block but leaves the container. + const textDelete = await client.callTool({ + name: 'superdoc_edit', + arguments: { session_id: sid, action: 'delete', ref: heading!.ref }, + }); + expect(textDelete).not.toHaveProperty('isError'); + + const afterTextDelete = await fetchBlocks(sid); + const survivor = afterTextDelete.blocks.find((b) => b.nodeId === headingNodeId); + expect(survivor).toBeDefined(); // container survives — this is the bug + expect(survivor!.isEmpty).toBe(true); + + // 2) delete_block by target: physically removes the container. + const blockDelete = await client.callTool({ + name: 'superdoc_edit', + arguments: { + session_id: sid, + action: 'delete_block', + target: { kind: 'block', nodeType: 'heading', nodeId: headingNodeId }, + }, + }); + expect(blockDelete).not.toHaveProperty('isError'); + + const afterBlockDelete = await fetchBlocks(sid); + expect(afterBlockDelete.blocks.some((b) => b.nodeId === headingNodeId)).toBe(false); + + await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } }); + }); + + it('delete_block_range removes a contiguous span of top-level blocks (count drop + spanned nodeIds absent)', async () => { + await ready; + + // Open a blank document. + const openResult = await client.callTool({ name: 'superdoc_open', arguments: { path: BLANK_DOCX } }); + const { session_id: sid } = parseContent(openResult) as { session_id: string }; + + // Create four contiguous top-level blocks with distinctive markers. Created + // blocks append at document end, so RangeAlpha..RangeDelta land in order. + await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, action: 'heading', text: 'RangeAlpha', level: 1 }, + }); + for (const marker of ['RangeBravo', 'RangeCharlie', 'RangeDelta']) { + await client.callTool({ + name: 'superdoc_create', + arguments: { session_id: sid, action: 'paragraph', text: marker }, + }); + } + + // Locate the four created blocks from the authoritative listing. + const before = await fetchBlocks(sid); + const findByMarker = (marker: string): BlockEntry => { + const block = before.blocks.find((b) => (b.textPreview ?? '').includes(marker)); + expect(block).toBeDefined(); // fail loud if create/preview wiring changed + return block!; + }; + const alpha = findByMarker('RangeAlpha'); + const bravo = findByMarker('RangeBravo'); + const charlie = findByMarker('RangeCharlie'); + const delta = findByMarker('RangeDelta'); + + // Confirm Bravo→Charlie→Delta are contiguous and ordered (start must precede + // end, and the span is inclusive of both endpoints). + const idxOf = (b: BlockEntry) => before.blocks.findIndex((x) => x.nodeId === b.nodeId); + expect(idxOf(charlie)).toBe(idxOf(bravo) + 1); + expect(idxOf(delta)).toBe(idxOf(bravo) + 2); + + const countBefore = before.blocks.length; + + // Delete the inclusive range Bravo..Delta (3 blocks) by top-level addresses. + const rangeResult = await client.callTool({ + name: 'superdoc_edit', + arguments: { + session_id: sid, + action: 'delete_block_range', + start: { kind: 'block', nodeType: bravo.nodeType, nodeId: bravo.nodeId }, + end: { kind: 'block', nodeType: delta.nodeType, nodeId: delta.nodeId }, + }, + }); + expect(rangeResult).not.toHaveProperty('isError'); + + // The MCP layer serializes the raw BlocksDeleteRangeResult as JSON. Assert + // the contiguous-span contract directly: count + the exact spanned nodeIds. + const payload = parseContent(rangeResult) as { + success: boolean; + deletedCount: number; + deletedBlocks: Array<{ nodeId: string; nodeType: string }>; + }; + expect(payload.success).toBe(true); + expect(payload.deletedCount).toBe(3); + expect(payload.deletedBlocks.map((b) => b.nodeId).sort()).toEqual( + [bravo.nodeId, charlie.nodeId, delta.nodeId].sort(), + ); + + // Re-fetch (handles expired) and assert PHYSICAL removal of the whole span, + // while the block just outside the range (Alpha) survives — proves the + // delete is bounded, not a delete-all. + const after = await fetchBlocks(sid); + expect(after.blocks.length).toBe(countBefore - 3); + for (const removed of [bravo, charlie, delta]) { + expect(after.blocks.some((b) => b.nodeId === removed.nodeId)).toBe(false); + } + expect(after.blocks.some((b) => b.nodeId === alpha.nodeId)).toBe(true); + + await client.callTool({ name: 'superdoc_close', arguments: { session_id: sid } }); + }); }); diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 7b994255c4..de511882a3 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -91,14 +91,15 @@ export const MCP_TOOL_CATALOG = { { toolName: 'superdoc_edit', description: - 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"undo"}', + 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. `delete` removes a text range and leaves the block container; `delete_block` removes the entire block node by its `{kind:\'block\',nodeType,nodeId}` address; `delete_block_range` removes a contiguous span of top-level blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"delete_block","target":{"kind":"block","nodeType":"heading","nodeId":""}}\n 6. {"action":"undo"}', inputSchema: { type: 'object', properties: { action: { type: 'string', - enum: ['delete', 'insert', 'redo', 'replace', 'undo'], - description: 'The action to perform. One of: delete, insert, redo, replace, undo.', + enum: ['delete', 'delete_block', 'delete_block_range', 'insert', 'redo', 'replace', 'undo'], + description: + 'The action to perform. One of: delete, delete_block, delete_block_range, insert, redo, replace, undo.', }, force: { type: 'boolean', @@ -117,275 +118,285 @@ export const MCP_TOOL_CATALOG = { oneOf: [ { oneOf: [ - { - $ref: '#/$defs/BlockNodeAddress', - description: - "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, { oneOf: [ { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', - }, - start: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', - }, - }, - required: ['kind', 'blockId', 'offset'], + $ref: '#/$defs/BlockNodeAddress', + description: + "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + }, + { + oneOf: [ + { + type: 'object', + properties: { + kind: { + const: 'selection', + type: 'string', }, - { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', - }, - node: { + start: { + oneOf: [ + { type: 'object', properties: { kind: { - const: 'block', + const: 'text', type: 'string', }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { + blockId: { type: 'string', }, + offset: { + type: 'number', + }, }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], - }, - }, - required: ['kind', 'node', 'edge'], - }, - ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, - end: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', + required: ['kind', 'blockId', 'offset'], }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', + { + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', + }, + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', + }, + }, + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], + }, + }, + required: ['kind', 'node', 'edge'], }, - }, - required: ['kind', 'blockId', 'offset'], + ], + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', - }, - node: { + end: { + oneOf: [ + { type: 'object', properties: { kind: { - const: 'block', + const: 'text', type: 'string', }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { + blockId: { type: 'string', }, + offset: { + type: 'number', + }, }, - required: ['kind', 'nodeType', 'nodeId'], + required: ['kind', 'blockId', 'offset'], }, - edge: { - enum: ['before', 'after'], + { + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', + }, + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', + }, + }, + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], + }, + }, + required: ['kind', 'node', 'edge'], }, - }, - required: ['kind', 'node', 'edge'], + ], + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, - }, - required: ['kind', 'start', 'end'], - }, - { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + }, + required: ['kind', 'start', 'end'], }, - }, - required: ['kind', 'nodeType', 'nodeId'], - }, - { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], + }, + nodeId: { + type: 'string', + }, + }, + required: ['kind', 'nodeType', 'nodeId'], }, - start: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', - }, - }, - required: ['kind', 'blockId', 'offset'], + { + type: 'object', + properties: { + kind: { + const: 'selection', + type: 'string', }, - { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', - }, - node: { + start: { + oneOf: [ + { type: 'object', properties: { kind: { - const: 'block', + const: 'text', type: 'string', }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { + blockId: { type: 'string', }, + offset: { + type: 'number', + }, }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], - }, - }, - required: ['kind', 'node', 'edge'], - }, - ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, - end: { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + required: ['kind', 'blockId', 'offset'], }, - offset: { - type: 'number', + { + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', + }, + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', + }, + }, + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], + }, + }, + required: ['kind', 'node', 'edge'], }, - }, - required: ['kind', 'blockId', 'offset'], + ], + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', - }, - node: { + end: { + oneOf: [ + { type: 'object', properties: { kind: { - const: 'block', + const: 'text', type: 'string', }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { + blockId: { type: 'string', }, + offset: { + type: 'number', + }, }, - required: ['kind', 'nodeType', 'nodeId'], + required: ['kind', 'blockId', 'offset'], }, - edge: { - enum: ['before', 'after'], + { + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', + }, + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', + }, + }, + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], + }, + }, + required: ['kind', 'node', 'edge'], }, - }, - required: ['kind', 'node', 'edge'], + ], + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + }, + required: ['kind', 'start', 'end'], }, - }, - required: ['kind', 'start', 'end'], + ], }, ], + description: + "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + }, + { + $ref: '#/$defs/SelectionTarget', + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, ], description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", }, { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + $ref: '#/$defs/DeletableBlockNodeAddress', }, ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + description: + "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}. Required for action 'delete_block'.", }, value: { type: 'string', @@ -422,7 +433,7 @@ export const MCP_TOOL_CATALOG = { }, ], description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object. Only for actions 'insert', 'replace', 'delete'. Omit for other actions.", }, content: { oneOf: [ @@ -499,6 +510,22 @@ export const MCP_TOOL_CATALOG = { description: "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions.", }, + nodeType: { + type: 'string', + description: "Block type of the node to delete. Only for action 'delete_block'. Omit for other actions.", + }, + nodeId: { + type: 'string', + description: "Node ID of the block to delete. Only for action 'delete_block'. Omit for other actions.", + }, + start: { + $ref: '#/$defs/BlockNodeAddress', + description: "Required for action 'delete_block_range'.", + }, + end: { + $ref: '#/$defs/BlockNodeAddress', + description: "Required for action 'delete_block_range'.", + }, }, required: ['action'], additionalProperties: false, @@ -525,6 +552,16 @@ export const MCP_TOOL_CATALOG = { intentAction: 'delete', requiredOneOf: [['target'], ['ref']], }, + { + operationId: 'doc.blocks.delete', + intentAction: 'delete_block', + required: ['target'], + }, + { + operationId: 'doc.blocks.deleteRange', + intentAction: 'delete_block_range', + required: ['start', 'end'], + }, { operationId: 'doc.history.undo', intentAction: 'undo', diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index 82bbd96b37..a0d98e13d6 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -35,6 +35,10 @@ export function dispatchIntentTool( return execute('doc.replace', rest); case 'delete': return execute('doc.delete', rest); + case 'delete_block': + return execute('doc.blocks.delete', rest); + case 'delete_block_range': + return execute('doc.blocks.deleteRange', rest); case 'undo': return execute('doc.history.undo', rest); case 'redo': diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index df14717b87..ab7c50ee84 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -158,6 +158,7 @@ export const INTENT_GROUP_META: Record = { 'Use scope: "block" so formatting covers the entire paragraph. ' + 'Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. ' + 'Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. ' + + "`delete` removes a text range and leaves the block container; `delete_block` removes the entire block node by its `{kind:'block',nodeType,nodeId}` address; `delete_block_range` removes a contiguous span of top-level blocks. " + 'A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. ' + 'For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. ' + 'Refs expire after any mutation; always re-search before the next edit. ' + @@ -180,6 +181,7 @@ export const INTENT_GROUP_META: Record = { }, { action: 'replace', ref: '', text: 'new text here' }, { action: 'delete', ref: '' }, + { action: 'delete_block', target: { kind: 'block', nodeType: 'heading', nodeId: '' } }, { action: 'undo' }, ], }, @@ -1018,6 +1020,8 @@ export const OPERATION_DEFINITIONS = { }), referenceDocPath: 'blocks/delete.mdx', referenceGroup: 'blocks', + intentGroup: 'edit', + intentAction: 'delete_block', }, 'blocks.deleteRange': { @@ -1043,6 +1047,8 @@ export const OPERATION_DEFINITIONS = { }), referenceDocPath: 'blocks/delete-range.mdx', referenceGroup: 'blocks', + intentGroup: 'edit', + intentAction: 'delete_block_range', }, 'format.apply': { diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 5a5a9d24e7..9c64565bca 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -35,6 +35,10 @@ export function dispatchIntentTool( return execute('doc.replace', rest); case 'delete': return execute('doc.delete', rest); + case 'delete_block': + return execute('doc.blocks.delete', rest); + case 'delete_block_range': + return execute('doc.blocks.deleteRange', rest); case 'undo': return execute('doc.history.undo', rest); case 'redo': diff --git a/packages/sdk/tools/intent_dispatch_generated.py b/packages/sdk/tools/intent_dispatch_generated.py index 5fc0785197..3832edcf0d 100644 --- a/packages/sdk/tools/intent_dispatch_generated.py +++ b/packages/sdk/tools/intent_dispatch_generated.py @@ -36,6 +36,10 @@ def dispatch_intent_tool( return execute('doc.replace', rest) elif action == 'delete': return execute('doc.delete', rest) + elif action == 'delete_block': + return execute('doc.blocks.delete', rest) + elif action == 'delete_block_range': + return execute('doc.blocks.deleteRange', rest) elif action == 'undo': return execute('doc.history.undo', rest) elif action == 'redo':