From 79f320d37977125d5e0dc1a6ae1af3c85ea742f1 Mon Sep 17 00:00:00 2001 From: James Kent Date: Tue, 4 Nov 2025 12:54:54 -0500 Subject: [PATCH] feat: add support for nested block insertion --- LOGSEQ_API_ARCHITECTURE.md | 24 ++++++++++- README.md | 5 ++- ROADMAP.md | 5 +++ src/mcp_logseq/logseq.py | 39 +++++++++++++++++ src/mcp_logseq/server.py | 1 + src/mcp_logseq/tools.py | 85 ++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 12 +++++- 7 files changed, 166 insertions(+), 5 deletions(-) diff --git a/LOGSEQ_API_ARCHITECTURE.md b/LOGSEQ_API_ARCHITECTURE.md index b5881d7..7310f25 100644 --- a/LOGSEQ_API_ARCHITECTURE.md +++ b/LOGSEQ_API_ARCHITECTURE.md @@ -55,6 +55,13 @@ Standard JSON-RPC response with result or error. - Adds content block to existing page - Example: `["My Page", "This is content"]` +- **`insertBlock(targetBlockUUID, content, options)`** + - Inserts a new block relative to an existing block + - Options: `{sibling: false, properties: {...}}` + - `sibling: false` inserts as child (default) + - `sibling: true` inserts as sibling after target + - Example: `["parent-block-uuid-123", "Child content", {"sibling": false, "properties": {}}]` + - **`getAllPages()`** - Returns array of all page objects with metadata - Each page includes: name, properties, journal status, etc. @@ -70,7 +77,6 @@ Standard JSON-RPC response with result or error. #### šŸ” Likely Available (Unverified) - **`deletePage(pageName)`** - Delete page entirely - **`updatePage(pageName, properties)`** - Update page properties -- **`insertBlock(targetBlock, content, options)`** - Insert block at position - **`updateBlock(blockUUID, content)`** - Update specific block content - **`removeBlock(blockUUID)`** - Delete specific block @@ -156,6 +162,22 @@ To create page with content: 1. `createPage(pageName, {}, {"createFirstBlock": true})` 2. `appendBlockInPage(pageName, content)` (if content needed) +### Nested Block Creation Pattern +To create hierarchical block structures: +1. Create parent block: `appendBlockInPage(pageName, "Parent content")` +2. Get parent block UUID from the returned block data +3. Insert child: `insertBlock(parentBlockUUID, "Child content", {"sibling": false})` +4. Insert another child: `insertBlock(parentBlockUUID, "Second child", {"sibling": false})` +5. Insert sibling: `insertBlock(parentBlockUUID, "Sibling content", {"sibling": true})` + +Block hierarchy example: +``` +- Parent block + - Child block 1 + - Child block 2 +- Sibling block +``` + ## Future Research Areas - Block-level CRUD operations diff --git a/README.md b/README.md index 89607e0..f995521 100644 --- a/README.md +++ b/README.md @@ -89,16 +89,17 @@ Add to your config file (`Settings → Developer → Edit Config`): ## šŸ› ļø Available Tools -The server provides 6 comprehensive tools: +The server provides 7 comprehensive tools: | Tool | Purpose | Example Use | |------|---------|-------------| | **`list_pages`** | Browse your graph | "Show me all my pages" | | **`get_page_content`** | Read page content | "Get my project notes" | -| **`create_page`** | Add new pages | "Create a meeting notes page" | +| **`create_page`** | Add new pages | "Create a meeting notes page" | | **`update_page`** | Modify existing pages | "Update my task list" | | **`delete_page`** | Remove pages | "Delete the old draft page" | | **`search`** | Find content across graph | "Search for 'productivity tips'" | +| **`insert_nested_block`** | Create hierarchical block structures | "Add a child block under this task" | --- diff --git a/ROADMAP.md b/ROADMAP.md index bdc93a2..d45336d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,6 +36,11 @@ - Full-text search across blocks, pages, and files - Configurable result filtering and limits - Rich result formatting with snippets and pagination +- āœ… Insert Nested Block (`insert_nested_block`) + - Create hierarchical block structures + - Insert blocks as children or siblings + - Support for block properties (markers, tags, etc.) + - Enable complex nested note-taking workflows ## Planned Features diff --git a/src/mcp_logseq/logseq.py b/src/mcp_logseq/logseq.py index 014d184..f89f204 100644 --- a/src/mcp_logseq/logseq.py +++ b/src/mcp_logseq/logseq.py @@ -295,3 +295,42 @@ def update_page(self, page_name: str, content: str = None, properties: dict = No except Exception as e: logger.error(f"Error updating page '{page_name}': {str(e)}") raise + + def insert_block_as_child( + self, + parent_block_uuid: str, + content: str, + properties: dict = None, + sibling: bool = False + ) -> Any: + """Insert a new block as a child of an existing block, enabling nested block structures.""" + url = self.get_base_url() + logger.info(f"Inserting block as {'sibling' if sibling else 'child'} of {parent_block_uuid}") + + try: + options = { + "sibling": sibling + } + + if properties: + options["properties"] = properties + + response = requests.post( + url, + headers=self._get_headers(), + json={ + "method": "logseq.Editor.insertBlock", + "args": [parent_block_uuid, content, options] + }, + verify=self.verify_ssl, + timeout=self.timeout + ) + response.raise_for_status() + result = response.json() + + logger.info(f"Successfully inserted block under {parent_block_uuid}") + return result + + except Exception as e: + logger.error(f"Error inserting nested block: {str(e)}") + raise diff --git a/src/mcp_logseq/server.py b/src/mcp_logseq/server.py index e78c525..d43cd20 100644 --- a/src/mcp_logseq/server.py +++ b/src/mcp_logseq/server.py @@ -78,6 +78,7 @@ def get_tool_handler(name: str) -> tools.ToolHandler | None: add_tool_handler(tools.DeletePageToolHandler()) add_tool_handler(tools.UpdatePageToolHandler()) add_tool_handler(tools.SearchToolHandler()) +add_tool_handler(tools.InsertNestedBlockToolHandler()) logger.info("Tool handlers registration complete") @app.list_tools() diff --git a/src/mcp_logseq/tools.py b/src/mcp_logseq/tools.py index 13e61e0..a5c2ab1 100644 --- a/src/mcp_logseq/tools.py +++ b/src/mcp_logseq/tools.py @@ -486,3 +486,88 @@ def run_tool(self, args: dict) -> list[TextContent]: type="text", text=f"āŒ Search failed: {str(e)}" )] + +class InsertNestedBlockToolHandler(ToolHandler): + def __init__(self): + super().__init__("insert_nested_block") + + def get_tool_description(self): + return Tool( + name=self.name, + description="""Insert a new block as a child or sibling of an existing block, enabling nested hierarchical structures""", + inputSchema={ + "type": "object", + "properties": { + "parent_block_uuid": { + "type": "string", + "description": "UUID of the reference block. If sibling=false, new block becomes a CHILD of this UUID. If sibling=true, new block becomes a SIBLING of this UUID (at the same level)." + }, + "content": { + "type": "string", + "description": "Content text for the new block" + }, + "properties": { + "type": "object", + "description": "Optional block properties (e.g., {'marker': 'TODO', 'priority': 'A'})", + "additionalProperties": True + }, + "sibling": { + "type": "boolean", + "description": "false (default) = insert as CHILD under parent_block_uuid. true = insert as SIBLING after parent_block_uuid at the same level. For multiple children under same parent, ALWAYS use false with the parent's UUID.", + "default": False + } + }, + "required": ["parent_block_uuid", "content"] + } + ) + + def run_tool(self, args: dict) -> list[TextContent]: + """Insert a nested block under an existing block.""" + if "parent_block_uuid" not in args or "content" not in args: + raise RuntimeError("parent_block_uuid and content arguments required") + + parent_uuid = args["parent_block_uuid"] + content = args["content"] + properties = args.get("properties") + sibling = args.get("sibling", False) + + try: + api = logseq.LogSeq(api_key=api_key) + result = api.insert_block_as_child( + parent_block_uuid=parent_uuid, + content=content, + properties=properties, + sibling=sibling + ) + + relationship = "sibling" if sibling else "child" + success_msg = f"āœ… Successfully inserted block as {relationship}" + + # Add block details if available + if result and isinstance(result, dict): + if result.get("uuid"): + success_msg += f"\nšŸ†” New block UUID: {result.get('uuid')}" + if result.get("content"): + content_preview = result.get('content') + if len(content_preview) > 100: + content_preview = content_preview[:100] + "..." + success_msg += f"\nšŸ“ Content: {content_preview}" + + success_msg += f"\nšŸ”— Inserted under parent: {parent_uuid}" + + return [TextContent( + type="text", + text=success_msg + )] + + except ValueError as e: + return [TextContent( + type="text", + text=f"āŒ Error: {str(e)}" + )] + except Exception as e: + logger.error(f"Failed to insert nested block: {str(e)}") + return [TextContent( + type="text", + text=f"āŒ Failed to insert nested block: {str(e)}" + )] diff --git a/tests/conftest.py b/tests/conftest.py index c18915e..0929617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,8 @@ GetPageContentToolHandler, DeletePageToolHandler, UpdatePageToolHandler, - SearchToolHandler + SearchToolHandler, + InsertNestedBlockToolHandler ) @pytest.fixture @@ -76,6 +77,12 @@ def mock_logseq_responses(): ], "files": [], "has-more?": False + }, + "insert_block_success": { + "uuid": "block-child-uuid-123", + "content": "Child block content", + "parent": "parent-block-uuid-456", + "properties": {} } } @@ -88,7 +95,8 @@ def tool_handlers(): "get_page_content": GetPageContentToolHandler(), "delete_page": DeletePageToolHandler(), "update_page": UpdatePageToolHandler(), - "search": SearchToolHandler() + "search": SearchToolHandler(), + "insert_nested_block": InsertNestedBlockToolHandler() } @pytest.fixture