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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking (dependency):** the server now requires `mcp>=2.0` and is ported to the 2.x low-level API, which replaced the `@server.list_tools()` / `@server.call_tool()` decorators with constructor-based handler registration. Environments pinned to `mcp<2` must upgrade (#92)

- **Potentially breaking:** `LogSeq(...)` now defaults `verify_ssl=True` (was
`False`), so the safe path is the default. The bundled server is unaffected
(it always sets `verify_ssl` explicitly from the protocol), but external code
constructing the client directly against a self-signed HTTPS Logseq endpoint
must now pass `verify_ssl=False` explicitly (#89)

### Fixed

- The server no longer crashes on import with `'Server' object has no attribute 'list_tools'` (surfacing client-side as `MCP error -32000: Connection closed`) when mcp 2.x is installed (#92)
- Tool-call failures still reach the model as in-band error results, and tool arguments are still validated against each tool's `inputSchema` — the 2.x low-level server does neither on a handler's behalf, so the server does both itself (#92)

### Internal

- Tech-debt cleanup: migrate off the deprecated LanceDB `table_names()`, commit
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ classifiers = [
"Topic :: Text Processing",
]
dependencies = [
"mcp>=1.27",
"mcp>=2.0",
"jsonschema>=4.20",
"python-dotenv>=1.0.1",
"requests>=2.32.3",
"pyyaml>=6.0",
Expand Down
79 changes: 61 additions & 18 deletions src/mcp_logseq/logseq.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
import requests
import logging
import re
from collections.abc import Iterator
from typing import Any

logger = logging.getLogger("mcp-logseq")

# Logseq accepts a uuid only in the RFC 4122 layout — version 1-5, variant 8-b.
UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
ID_PROPERTY_PATTERN = re.compile(r"^\s*id::\s*(\S+)\s*$", re.MULTILINE)


def _iter_batch_ids(blocks: list[dict]) -> Iterator[str]:
"""Yield every explicit block id in an IBatchBlock tree."""
for block in blocks:
yield from ID_PROPERTY_PATTERN.findall(block.get("content") or "")
block_id = (block.get("properties") or {}).get("id")
if block_id is not None:
yield str(block_id)
yield from _iter_batch_ids(block.get("children") or [])


def _batch_ids_are_valid(blocks: list[dict]) -> bool:
"""Whether every explicit id in a batch is a uuid Logseq will accept."""
for block_id in _iter_batch_ids(blocks):
if not UUID_PATTERN.match(block_id):
logger.warning(
f"Block id '{block_id}' is not a valid uuid; inserting the batch "
f"with generated ids so Logseq does not discard it"
)
return False
return True


class LogSeq:
def __init__(
Expand Down Expand Up @@ -226,6 +257,16 @@ def insert_batch_block(

Uses Logseq's insertBatchBlock API to insert a tree of blocks.

Blocks whose content carries an explicit ``id:: <uuid>`` line keep that
uuid. Without ``keepUUID`` Logseq mints a fresh one and drops the
property, which turns every ``((uuid))`` reference elsewhere in the graph
into a dangling ref that Logseq then rewrites as plain text.

``keepUUID`` is only requested when every id in the batch is well formed:
Logseq rejects a malformed uuid by discarding the whole batch and still
reporting success, so one bad id would wipe the page. Falling back to
generated uuids costs the ids but never the content.

Args:
src_block: UUID of anchor block (blocks will be inserted after this)
blocks: List of IBatchBlock dicts with 'content', optional 'children',
Expand All @@ -237,9 +278,10 @@ def insert_batch_block(
List of created block entities
"""
logger.info(f"Inserting batch of {len(blocks)} blocks")
keep_uuid = _batch_ids_are_valid(blocks)
result = self._call(
"logseq.Editor.insertBatchBlock",
[src_block, blocks, {"sibling": sibling}],
[src_block, blocks, {"sibling": sibling, "keepUUID": keep_uuid}],
error_context="inserting batch blocks",
)
logger.info(f"Successfully inserted batch blocks")
Expand Down Expand Up @@ -411,26 +453,27 @@ def update_page_with_blocks(
# Insert new blocks FIRST, then set properties
if blocks:
if mode == "replace":
# After clearing, we need to add a first block to use as anchor
first_block = blocks[0]
anchor = self.append_block_in_page(
page_name,
first_block.get("content", ""),
first_block.get("properties"),
)
# After clearing, we need an empty block to use as anchor.
# Every block is then written through insertBatchBlock, which is
# the only path that preserves an explicit id::; appendBlockInPage
# would strip the uuid off whichever block went through it.
anchor = self.append_block_in_page(page_name, "")
anchor_uuid = anchor.get("uuid") if anchor else None

# Insert children of first block if any
if anchor_uuid and first_block.get("children"):
self.insert_batch_block(
anchor_uuid,
first_block["children"],
sibling=False, # Insert as children
if anchor_uuid:
self.insert_batch_block(anchor_uuid, blocks, sibling=True)
# File graphs store page properties as `key:: value` lines in
# the page's first block, so the anchor stays behind to serve
# as that pre-block. Deleting it would push the properties
# into the first block of real content.
if self.db_mode or not properties:
self.delete_block(anchor_uuid)
else:
logger.warning(
"No anchor block found, using fallback append method"
)

# Insert remaining blocks as siblings
if len(blocks) > 1 and anchor_uuid:
self.insert_batch_block(anchor_uuid, blocks[1:], sibling=True)
for block in blocks:
self._append_block_recursive(page_name, block)

results.append(("blocks_replaced", len(blocks)))
else:
Expand Down
56 changes: 35 additions & 21 deletions src/mcp_logseq/server.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import asyncio
import logging
import sys
from collections.abc import Sequence
from typing import Any
import os
import jsonschema
from dotenv import load_dotenv
from mcp.server import Server
from mcp.server import Server, ServerRequestContext
from mcp.types import (
Tool,
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
ImageContent,
EmbeddedResource,
)

# Configure logging to stderr with more verbose output
Expand Down Expand Up @@ -125,55 +125,69 @@ def add(tool_class: tools.ToolHandler) -> None:
logger.warning(f"Could not load vector config, vector tools disabled: {e}")


def _error_result(message: str) -> CallToolResult:
"""Report a failed tool call in-band, as a result the model can read.

The low-level server scrubs a raised exception into a generic JSON-RPC "Internal server error", which would hide why the call failed — an access denial, a missing page, an unreachable Logseq.
"""
return CallToolResult(content=[TextContent(type="text", text=message)], is_error=True)


def build_app(read_only: bool = False) -> tuple[Server, dict]:
"""Build a fully wired MCP ``Server`` plus its tool-handler registry.

Returns ``(server, handlers)`` where ``handlers`` is the very same dict the
server's ``list_tools`` / ``call_tool`` closures read from. Mutating that
server's ``list_tools`` / ``call_tool`` handlers read from. Mutating that
dict after construction is therefore reflected by the served app.

When ``read_only`` is True the genuine write tools are not registered, so
the served app exposes only read/search tools (plus the vector tools,
including ``sync_vector_db``). Default ``read_only=False`` registers
everything, identical to prior behavior.
"""
server = Server("mcp-logseq")
handlers: dict = {}
_register_all_tool_handlers(handlers, read_only)

@server.list_tools()
async def list_tools() -> list[Tool]:
async def list_tools(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListToolsResult:
"""List available tools."""
logger.debug("Listing tools")
tools_list = [th.get_tool_description() for th in handlers.values()]
logger.debug(f"Found {len(tools_list)} tools")
return tools_list
return ListToolsResult(tools=tools_list)

@server.call_tool()
async def call_tool(
name: str, arguments: Any
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
ctx: ServerRequestContext, params: CallToolRequestParams
) -> CallToolResult:
"""Handle tool calls."""
name = params.name
arguments = params.arguments or {}
logger.info(f"Tool call: {name} with arguments {arguments}")

if not isinstance(arguments, dict):
logger.error("Arguments must be dictionary")
raise RuntimeError("arguments must be dictionary")

tool_handler = handlers.get(name)
if not tool_handler:
logger.error(f"Unknown tool: {name}")
raise ValueError(f"Unknown tool: {name}")
return _error_result(f"Unknown tool: {name}")

try:
jsonschema.validate(
instance=arguments, schema=tool_handler.get_tool_description().input_schema
)
except jsonschema.ValidationError as e:
logger.error(f"Input validation error for {name}: {e.message}")
return _error_result(f"Input validation error: {e.message}")

try:
logger.debug(f"Running tool {name}")
result = await asyncio.to_thread(tool_handler.run_tool, arguments)
logger.debug(f"Tool result: {result}")
return result
return CallToolResult(content=list(result))
except Exception as e:
logger.error(f"Error running tool: {str(e)}", exc_info=True)
raise RuntimeError(f"Error: {str(e)}")
return _error_result(f"Error: {str(e)}")

server = Server("mcp-logseq", on_list_tools=list_tools, on_call_tool=call_tool)
return server, handlers


Expand Down
10 changes: 5 additions & 5 deletions src/mcp_logseq/tools/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Delete a block from LogSeq by its UUID.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"block_uuid": {
Expand Down Expand Up @@ -75,7 +75,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Update the content of an existing LogSeq block by UUID.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"block_uuid": {
Expand Down Expand Up @@ -133,7 +133,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Get a single block by its UUID. Returns the block content, properties, and child blocks (recursively). Useful for inspecting a specific block after finding its UUID via search or query.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"block_uuid": {
Expand Down Expand Up @@ -218,7 +218,7 @@ 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={
input_schema={
"type": "object",
"properties": {
"parent_block_uuid": {
Expand Down Expand Up @@ -308,7 +308,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Set properties on a block in Logseq DB-mode. Properties must be defined on the block's tag/class. Use property display names (e.g. 'Content status', not the internal ident).",
inputSchema={
input_schema={
"type": "object",
"properties": {
"block_uuid": {
Expand Down
4 changes: 2 additions & 2 deletions src/mcp_logseq/tools/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Get all pages within a namespace hierarchy (flat list). Use this to discover subpages of a parent page.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"namespace": {
Expand Down Expand Up @@ -83,7 +83,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Get pages within a namespace as a hierarchical tree structure. Useful for understanding the full page hierarchy.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"namespace": {
Expand Down
16 changes: 8 additions & 8 deletions src/mcp_logseq/tools/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def get_tool_description(self):
- Subtask A
- Task 2
```""",
inputSchema={
input_schema={
"type": "object",
"properties": {
"title": {"type": "string", "description": "Title of the new page"},
Expand Down Expand Up @@ -147,7 +147,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Lists all pages in a LogSeq graph.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"include_journals": {
Expand Down Expand Up @@ -290,7 +290,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Get the content of a specific page from LogSeq.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"page_name": {
Expand Down Expand Up @@ -419,7 +419,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Delete a page from LogSeq.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"page_name": {
Expand Down Expand Up @@ -495,7 +495,7 @@ def get_tool_description(self):

Markdown is parsed into proper block hierarchy just like create_page.
YAML frontmatter in content will be merged with explicit properties.""",
inputSchema={
input_schema={
"type": "object",
"properties": {
"page_name": {
Expand Down Expand Up @@ -602,7 +602,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Find all pages that have a specific property, optionally filtered by value. Simpler alternative to the full query DSL.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"property_name": {
Expand Down Expand Up @@ -733,7 +733,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Rename an existing page. All references throughout the graph will be automatically updated.",
inputSchema={
input_schema={
"type": "object",
"properties": {
"old_name": {
Expand Down Expand Up @@ -788,7 +788,7 @@ def get_tool_description(self):
return Tool(
name=self.name,
description="Get all pages and blocks that link to a specific page (backlinks/linked references).",
inputSchema={
input_schema={
"type": "object",
"properties": {
"page_name": {
Expand Down
Loading