diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd186e..458a628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0e8f704..a9a4a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/mcp_logseq/logseq.py b/src/mcp_logseq/logseq.py index d66772c..45d1b4b 100644 --- a/src/mcp_logseq/logseq.py +++ b/src/mcp_logseq/logseq.py @@ -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__( @@ -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:: `` 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', @@ -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") @@ -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: diff --git a/src/mcp_logseq/server.py b/src/mcp_logseq/server.py index 25f2904..fdf6e78 100644 --- a/src/mcp_logseq/server.py +++ b/src/mcp_logseq/server.py @@ -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 @@ -125,11 +125,19 @@ 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 @@ -137,43 +145,49 @@ def build_app(read_only: bool = False) -> tuple[Server, dict]: 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 diff --git a/src/mcp_logseq/tools/blocks.py b/src/mcp_logseq/tools/blocks.py index d0366d2..2840f4a 100644 --- a/src/mcp_logseq/tools/blocks.py +++ b/src/mcp_logseq/tools/blocks.py @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { diff --git a/src/mcp_logseq/tools/namespace.py b/src/mcp_logseq/tools/namespace.py index 82343fd..c06a644 100644 --- a/src/mcp_logseq/tools/namespace.py +++ b/src/mcp_logseq/tools/namespace.py @@ -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": { @@ -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": { diff --git a/src/mcp_logseq/tools/pages.py b/src/mcp_logseq/tools/pages.py index 99ef393..e6effa9 100644 --- a/src/mcp_logseq/tools/pages.py +++ b/src/mcp_logseq/tools/pages.py @@ -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"}, @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { diff --git a/src/mcp_logseq/tools/search.py b/src/mcp_logseq/tools/search.py index aefca72..edf60ae 100644 --- a/src/mcp_logseq/tools/search.py +++ b/src/mcp_logseq/tools/search.py @@ -26,7 +26,7 @@ def get_tool_description(self): return Tool( name=self.name, description="Search for content across LogSeq pages, blocks, and files", - inputSchema={ + input_schema={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query text"}, @@ -415,7 +415,7 @@ def get_tool_description(self): return Tool( name=self.name, description="Execute a Logseq DSL query to search pages and blocks. Supports property queries, tag queries, task queries, and logical combinations. See https://docs.logseq.com/#/page/queries for query syntax.", - inputSchema={ + input_schema={ "type": "object", "properties": { "query": { diff --git a/src/mcp_logseq/transport/http.py b/src/mcp_logseq/transport/http.py index a7e031c..85f058e 100644 --- a/src/mcp_logseq/transport/http.py +++ b/src/mcp_logseq/transport/http.py @@ -3,7 +3,7 @@ Wraps the MCP ``Server`` from :mod:`mcp_logseq.server` in a Starlette app and serves it over the Streamable HTTP transport. -StreamableHTTPSessionManager (confirmed against installed mcp 1.27.2): +StreamableHTTPSessionManager (confirmed against installed mcp 2.0.0): from mcp.server.streamable_http_manager import StreamableHTTPSessionManager StreamableHTTPSessionManager(app, event_store=None, json_response=False, stateless=False, ...) diff --git a/src/mcp_logseq/vector/index.py b/src/mcp_logseq/vector/index.py index e46ecca..0618376 100644 --- a/src/mcp_logseq/vector/index.py +++ b/src/mcp_logseq/vector/index.py @@ -136,7 +136,7 @@ def get_tool_description(self) -> Tool: "Weak match results (score > 0.80) may be tangential; use judgment when " "presenting them to the user." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "query": { @@ -285,7 +285,7 @@ def get_tool_description(self) -> Tool: "writer — the logseq-sync CLI, run externally on the host that owns the DB. " "Calling this tool just returns instructions; it does not start a sync." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "rebuild": { @@ -316,7 +316,7 @@ def get_tool_description(self) -> Tool: return Tool( name=self.name, description="Show current state of the vector database without syncing.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) def run_tool(self, args: dict) -> list[TextContent]: diff --git a/tests/integration/test_mcp_protocol.py b/tests/integration/test_mcp_protocol.py new file mode 100644 index 0000000..ac39311 --- /dev/null +++ b/tests/integration/test_mcp_protocol.py @@ -0,0 +1,126 @@ +"""End-to-end coverage of the served MCP protocol surface (issue #92). + +Every other test in this suite drives the tool handlers directly, so the wiring +between them and the SDK — the part mcp 2.0 changed — went untested until a user +started the server. Here a real ``ClientSession`` talks to the real ``Server`` +over in-memory streams: ``initialize``, ``tools/list`` and ``tools/call`` all go +through the genuine SDK request pipeline. +""" + +from contextlib import asynccontextmanager +from functools import partial + +import anyio +import pytest +from mcp.client.session import ClientSession +from mcp.shared.memory import create_client_server_memory_streams +from mcp.types import TextContent, Tool + +from mcp_logseq.server import build_app, _WRITE_TOOL_NAMES +from mcp_logseq.tools import ToolHandler + + +class StubToolHandler(ToolHandler): + """A tool with a typed schema that needs no Logseq API behind it.""" + + def __init__(self): + super().__init__("stub_tool") + + def get_tool_description(self): + return Tool( + name=self.name, + description="Echo the given text, or fail on demand.", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + ) + + def run_tool(self, args: dict): + if args["text"] == "boom": + raise RuntimeError("stub exploded") + return [TextContent(type="text", text=f"echo: {args['text']}")] + + +@asynccontextmanager +async def _connected(read_only: bool = False): + """Yield ``(session, handlers)`` for an initialized in-memory connection.""" + app, handlers = build_app(read_only=read_only) + async with create_client_server_memory_streams() as ( + (client_read, client_write), + (server_read, server_write), + ): + async with anyio.create_task_group() as tg: + tg.start_soon( + partial( + app.run, + server_read, + server_write, + app.create_initialization_options(), + raise_exceptions=True, + ) + ) + async with ClientSession(client_read, client_write) as session: + await session.initialize() + yield session, handlers + tg.cancel_scope.cancel() + + +@pytest.mark.asyncio +async def test_initialize_and_list_tools(): + async with _connected() as (session, handlers): + result = await session.list_tools() + + assert [t.name for t in result.tools] == list(handlers) + assert all(t.input_schema["type"] == "object" for t in result.tools) + + +@pytest.mark.asyncio +async def test_read_only_app_serves_no_write_tools(): + async with _connected(read_only=True) as (session, _): + result = await session.list_tools() + + assert _WRITE_TOOL_NAMES.isdisjoint({t.name for t in result.tools}) + + +@pytest.mark.asyncio +async def test_call_tool_returns_handler_content(): + async with _connected() as (session, handlers): + handlers["stub_tool"] = StubToolHandler() + result = await session.call_tool("stub_tool", {"text": "hello"}) + + assert result.is_error is False + assert result.content[0].text == "echo: hello" + + +@pytest.mark.asyncio +async def test_unknown_tool_is_reported_as_an_error_result(): + async with _connected() as (session, _): + result = await session.call_tool("no_such_tool", {}) + + assert result.is_error is True + assert result.content[0].text == "Unknown tool: no_such_tool" + + +@pytest.mark.asyncio +async def test_handler_failure_reaches_the_client_verbatim(): + async with _connected() as (session, handlers): + handlers["stub_tool"] = StubToolHandler() + result = await session.call_tool("stub_tool", {"text": "boom"}) + + assert result.is_error is True + assert result.content[0].text == "Error: stub exploded" + + +@pytest.mark.asyncio +async def test_arguments_are_validated_against_the_tool_schema(): + async with _connected() as (session, handlers): + handlers["stub_tool"] = StubToolHandler() + missing = await session.call_tool("stub_tool", {}) + mistyped = await session.call_tool("stub_tool", {"text": 42}) + + assert missing.is_error is True + assert missing.content[0].text.startswith("Input validation error:") + assert mistyped.is_error is True + assert "42 is not of type 'string'" in mistyped.content[0].text diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index be02c7b..0d0df30 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -70,7 +70,7 @@ def test_list_tools_handler_count(self): assert isinstance(tool_desc, Tool) assert hasattr(tool_desc, "name") assert hasattr(tool_desc, "description") - assert hasattr(tool_desc, "inputSchema") + assert hasattr(tool_desc, "input_schema") @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -241,7 +241,7 @@ def get_tool_description(self): return Tool( name=self.name, description="Custom test tool", - inputSchema={"type": "object", "properties": {}, "required": []}, + input_schema={"type": "object", "properties": {}, "required": []}, ) def run_tool(self, args: dict): diff --git a/tests/unit/test_block_id_preservation.py b/tests/unit/test_block_id_preservation.py new file mode 100644 index 0000000..3ff50cf --- /dev/null +++ b/tests/unit/test_block_id_preservation.py @@ -0,0 +1,232 @@ +""" +Tests that block uuids survive a page rewrite. + +A block carrying an explicit ``id:: `` is the target of ``((uuid))`` +references elsewhere in the graph. If a rewrite mints new uuids, those +references dangle and Logseq rewrites them as plain text in the referring +files — content loss well outside the page being written. +""" + +import json + +import responses + +from mcp_logseq.parser import parse_content + +URL = "http://127.0.0.1:12315/api" + +BLOCK_UUID = "6a7d0147-dc0e-4ae1-b701-be4289116f48" + + +def _calls_for(method): + return [c for c in responses.calls if method in str(c.request.body)] + + +def _body(call): + return json.loads(call.request.body) + + +def _add_replace_mocks(*, first_block_props=None): + """Register the HTTP mocks for a replace-mode update on a one-block page.""" + responses.add(responses.POST, URL, json=[{"name": "Test Page", "originalName": "Test Page"}], status=200) # list_pages + responses.add(responses.POST, URL, json=[{"uuid": "old-1", "content": "Old"}], status=200) # clear: get blocks + responses.add(responses.POST, URL, json=True, status=200) # removeBlock + responses.add(responses.POST, URL, json={"uuid": "anchor-1", "content": ""}, status=200) # appendBlockInPage anchor + responses.add(responses.POST, URL, json=[{"uuid": "new-1"}], status=200) # insertBatchBlock + responses.add(responses.POST, URL, json=[{"uuid": "anchor-1", "content": "", "properties": first_block_props or {}}], status=200) # _resolve_first_block + responses.add(responses.POST, URL, json=True, status=200) # removeBlock / upsertBlockProperty (repeats) + + +class TestKeepUUID: + """insertBatchBlock must be told to honour the id:: it was handed.""" + + @responses.activate + def test_batch_insert_requests_keep_uuid(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", [{"content": f"## Meeting\nid:: {BLOCK_UUID}"}], mode="replace" + ) + + batch_calls = _calls_for("insertBatchBlock") + assert len(batch_calls) == 1 + assert _body(batch_calls[0])["args"][2]["keepUUID"] is True + + @responses.activate + def test_append_mode_also_keeps_uuids(self, logseq_client): + responses.add(responses.POST, URL, json=[{"name": "Test Page", "originalName": "Test Page"}], status=200) # list_pages + responses.add(responses.POST, URL, json=[{"uuid": "block-1", "content": "Existing"}], status=200) # get last block + responses.add(responses.POST, URL, json=[{"uuid": "block-2"}], status=200) # insertBatchBlock + + logseq_client.update_page_with_blocks( + "Test Page", [{"content": f"Appended\nid:: {BLOCK_UUID}"}], mode="append" + ) + + assert _body(_calls_for("insertBatchBlock")[0])["args"][2]["keepUUID"] is True + + +class TestMalformedIdsFallBack: + """A uuid Logseq won't parse must not take the page's content down with it. + + With keepUUID set, Logseq discards the entire batch when any id is malformed + — and still answers as if the write succeeded, so the page silently ends up + empty. Generated uuids are the lesser loss. + """ + + @responses.activate + def test_malformed_id_drops_keep_uuid(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", + # Well-formed length and grouping, but not an RFC 4122 variant + [{"content": "## Meeting\nid:: 11111111-2222-3333-4444-555555555555"}], + mode="replace", + ) + + assert _body(_calls_for("insertBatchBlock")[0])["args"][2]["keepUUID"] is False + + @responses.activate + def test_one_bad_id_disables_keep_uuid_for_the_whole_batch(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", + [ + {"content": f"## Good\nid:: {BLOCK_UUID}"}, + {"content": "## Bad", "children": [{"content": "id:: not-a-uuid"}]}, + ], + mode="replace", + ) + + assert _body(_calls_for("insertBatchBlock")[0])["args"][2]["keepUUID"] is False + + @responses.activate + def test_id_passed_as_a_property_is_validated_too(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", + [{"content": "## Meeting", "properties": {"id": "nope"}}], + mode="replace", + ) + + assert _body(_calls_for("insertBatchBlock")[0])["args"][2]["keepUUID"] is False + + @responses.activate + def test_blocks_without_ids_still_keep_uuid(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", [{"content": "plain block"}], mode="replace" + ) + + assert _body(_calls_for("insertBatchBlock")[0])["args"][2]["keepUUID"] is True + + +class TestReplaceRoutesEveryBlockThroughBatch: + """The first block must not take a different write path from the rest. + + appendBlockInPage cannot carry a uuid, so a first block written through it + loses its id:: even when the remaining blocks keep theirs. + """ + + @responses.activate + def test_first_block_goes_through_batch_insert(self, logseq_client): + _add_replace_mocks() + + blocks = [ + {"content": f"## First\nid:: {BLOCK_UUID}", "children": [{"content": "child"}]}, + {"content": "## Second"}, + ] + logseq_client.update_page_with_blocks("Test Page", blocks, mode="replace") + + # The anchor is empty — no real content is written through appendBlockInPage + append_calls = _calls_for("appendBlockInPage") + assert len(append_calls) == 1 + assert _body(append_calls[0])["args"][1] == "" + + batch_calls = _calls_for("insertBatchBlock") + assert len(batch_calls) == 1 + payload = _body(batch_calls[0])["args"][1] + assert [b["content"] for b in payload] == [ + f"## First\nid:: {BLOCK_UUID}", + "## Second", + ] + assert payload[0]["children"] == [{"content": "child"}] + + @responses.activate + def test_anchor_is_deleted_when_page_has_no_properties(self, logseq_client): + _add_replace_mocks() + + logseq_client.update_page_with_blocks( + "Test Page", [{"content": "Content"}], mode="replace" + ) + + removed = [_body(c)["args"][0] for c in _calls_for("removeBlock")] + assert "anchor-1" in removed + + +class TestPagePropertiesStayOffContentBlocks: + """Page properties belong in their own first block on file graphs. + + Logseq stores them as ``key:: value`` lines in the page's first block. If + the rewrite deletes the empty anchor, the first block of real content + becomes the property carrier and the properties are silently glued onto it. + """ + + @responses.activate + def test_file_graph_keeps_anchor_as_property_block(self, logseq_client): + _add_replace_mocks(first_block_props={"tags": "#Old"}) + + logseq_client.update_page_with_blocks( + "Test Page", + [{"content": "Dev meeting series for [[Company/ProfitPath]]"}], + properties={"tags": "#Company/ProfitPath"}, + mode="replace", + ) + + # The anchor survives the rewrite ... + removed = [_body(c)["args"][0] for c in _calls_for("removeBlock")] + assert "anchor-1" not in removed + + # ... and carries the page properties instead of the content block + upserts = _calls_for("upsertBlockProperty") + assert len(upserts) == 1 + assert _body(upserts[0])["args"][0] == "anchor-1" + assert _body(upserts[0])["args"][1] == "tags" + + @responses.activate + def test_db_graph_drops_the_anchor(self, logseq_client_db): + """DB graphs keep properties on the page entity, so no anchor is needed.""" + _add_replace_mocks() + + logseq_client_db.update_page_with_blocks( + "Test Page", + [{"content": "Content"}], + properties={"tags": "#Company/ProfitPath"}, + mode="replace", + ) + + removed = [_body(c)["args"][0] for c in _calls_for("removeBlock")] + assert "anchor-1" in removed + assert len(_calls_for("setPageProperties")) == 1 + + +class TestParserToBatchRoundTrip: + """The id:: has to survive parsing to reach insertBatchBlock at all.""" + + def test_id_property_is_carried_into_batch_content(self): + markdown = ( + f"- ## [[Aug 13th, 2026]] — Getting up and running locally\n" + f" id:: {BLOCK_UUID}\n" + f"\t- local setup\n" + ) + + batch = parse_content(markdown).to_batch_format() + + assert len(batch) == 1 + assert batch[0]["content"] == ( + f"## [[Aug 13th, 2026]] — Getting up and running locally\nid:: {BLOCK_UUID}" + ) + assert batch[0]["children"] == [{"content": "local setup"}] diff --git a/tests/unit/test_property_persistence.py b/tests/unit/test_property_persistence.py index 5639a25..129341b 100644 --- a/tests/unit/test_property_persistence.py +++ b/tests/unit/test_property_persistence.py @@ -199,8 +199,9 @@ def test_file_mode_replace_removes_stale_keys(self, logseq_client): responses.add(responses.POST, url, json=[{"name": "Test Page", "originalName": "Test Page"}], status=200) # list_pages responses.add(responses.POST, url, json=[{"uuid": "block-1", "content": "Old", "properties": {"priority": "low", "status": "old"}}], status=200) # clear: get blocks responses.add(responses.POST, url, json=True, status=200) # removeBlock (clear content) - responses.add(responses.POST, url, json={"uuid": "block-2", "content": "New"}, status=200) # appendBlockInPage anchor - responses.add(responses.POST, url, json=[{"uuid": "block-2", "content": "New", "properties": {"priority": "low", "status": "old"}}], status=200) # _replace_page_properties: get first block + responses.add(responses.POST, url, json={"uuid": "anchor-1", "content": ""}, status=200) # appendBlockInPage anchor + responses.add(responses.POST, url, json=[{"uuid": "block-2"}], status=200) # insertBatchBlock + responses.add(responses.POST, url, json=[{"uuid": "anchor-1", "content": "", "properties": {"priority": "low", "status": "old"}}], status=200) # _replace_page_properties: get first block responses.add(responses.POST, url, json=True, status=200) # removeBlockProperty / upsertBlockProperty (repeats) result = logseq_client.update_page_with_blocks( diff --git a/tests/unit/test_tool_handlers.py b/tests/unit/test_tool_handlers.py index 422ff81..4dde4cf 100644 --- a/tests/unit/test_tool_handlers.py +++ b/tests/unit/test_tool_handlers.py @@ -59,10 +59,10 @@ def test_get_tool_description(self): assert tool.description is not None assert "Create a new page in Logseq" in tool.description # New handler only requires title - assert tool.inputSchema["required"] == ["title"] + assert tool.input_schema["required"] == ["title"] # Should have content, properties as optional - assert "content" in tool.inputSchema["properties"] - assert "properties" in tool.inputSchema["properties"] + assert "content" in tool.input_schema["properties"] + assert "properties" in tool.input_schema["properties"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -211,7 +211,7 @@ def test_get_tool_description(self): assert tool.name == "list_pages" assert tool.description is not None assert "Lists all pages in a LogSeq graph" in tool.description - assert tool.inputSchema["required"] == [] + assert tool.input_schema["required"] == [] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -274,7 +274,7 @@ def test_get_tool_description(self): assert tool.name == "get_page_content" assert tool.description is not None assert "Get the content of a specific page" in tool.description - assert tool.inputSchema["required"] == ["page_name"] + assert tool.input_schema["required"] == ["page_name"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -532,7 +532,7 @@ def test_get_tool_description(self): assert tool.name == "delete_page" assert tool.description is not None assert "Delete a page from LogSeq" in tool.description - assert tool.inputSchema["required"] == ["page_name"] + assert tool.input_schema["required"] == ["page_name"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -581,7 +581,7 @@ def test_get_tool_description(self): assert tool.name == "delete_block" assert "Delete a block from LogSeq" in tool.description - assert tool.inputSchema["required"] == ["block_uuid"] + assert tool.input_schema["required"] == ["block_uuid"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -656,7 +656,7 @@ def test_get_tool_description(self): assert tool.name == "update_block" assert "Update the content of an existing LogSeq block" in tool.description - assert tool.inputSchema["required"] == ["block_uuid", "content"] + assert tool.input_schema["required"] == ["block_uuid", "content"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -732,10 +732,10 @@ def test_get_tool_description(self): assert tool.name == "update_page" assert tool.description is not None assert "Update a page in Logseq" in tool.description - assert tool.inputSchema["required"] == ["page_name"] + assert tool.input_schema["required"] == ["page_name"] # Should have mode parameter - assert "mode" in tool.inputSchema["properties"] - assert tool.inputSchema["properties"]["mode"]["enum"] == ["append", "replace"] + assert "mode" in tool.input_schema["properties"] + assert tool.input_schema["properties"]["mode"]["enum"] == ["append", "replace"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -848,7 +848,7 @@ def test_get_tool_description(self): assert tool.name == "search" assert tool.description is not None assert "Search for content across LogSeq pages" in tool.description - assert tool.inputSchema["required"] == ["query"] + assert tool.input_schema["required"] == ["query"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") @@ -1036,10 +1036,10 @@ def test_get_tool_description(self): assert tool.name == "query" assert "Execute a Logseq DSL query" in tool.description - assert "query" in tool.inputSchema["properties"] - assert "limit" in tool.inputSchema["properties"] - assert "result_type" in tool.inputSchema["properties"] - assert tool.inputSchema["required"] == ["query"] + assert "query" in tool.input_schema["properties"] + assert "limit" in tool.input_schema["properties"] + assert "result_type" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["query"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1212,10 +1212,10 @@ def test_get_tool_description(self): assert tool.name == "find_pages_by_property" assert "Find all pages that have a specific property" in tool.description - assert "property_name" in tool.inputSchema["properties"] - assert "property_value" in tool.inputSchema["properties"] - assert "limit" in tool.inputSchema["properties"] - assert tool.inputSchema["required"] == ["property_name"] + assert "property_name" in tool.input_schema["properties"] + assert "property_value" in tool.input_schema["properties"] + assert "limit" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["property_name"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1323,8 +1323,8 @@ def test_get_tool_description(self): assert tool.name == "get_pages_from_namespace" assert "namespace" in tool.description.lower() - assert "namespace" in tool.inputSchema["properties"] - assert "namespace" in tool.inputSchema["required"] + assert "namespace" in tool.input_schema["properties"] + assert "namespace" in tool.input_schema["required"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1387,8 +1387,8 @@ def test_get_tool_description(self): assert tool.name == "get_pages_tree_from_namespace" assert "tree" in tool.description.lower() - assert "namespace" in tool.inputSchema["properties"] - assert "namespace" in tool.inputSchema["required"] + assert "namespace" in tool.input_schema["properties"] + assert "namespace" in tool.input_schema["required"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1461,10 +1461,10 @@ def test_get_tool_description(self): assert tool.name == "rename_page" assert "rename" in tool.description.lower() - assert "old_name" in tool.inputSchema["properties"] - assert "new_name" in tool.inputSchema["properties"] - assert "old_name" in tool.inputSchema["required"] - assert "new_name" in tool.inputSchema["required"] + assert "old_name" in tool.input_schema["properties"] + assert "new_name" in tool.input_schema["properties"] + assert "old_name" in tool.input_schema["required"] + assert "new_name" in tool.input_schema["required"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1540,9 +1540,9 @@ def test_get_tool_description(self): assert tool.name == "get_page_backlinks" assert "backlink" in tool.description.lower() - assert "page_name" in tool.inputSchema["properties"] - assert "include_content" in tool.inputSchema["properties"] - assert "page_name" in tool.inputSchema["required"] + assert "page_name" in tool.input_schema["properties"] + assert "include_content" in tool.input_schema["properties"] + assert "page_name" in tool.input_schema["required"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1672,10 +1672,10 @@ def test_get_tool_description(self): assert tool.name == "insert_nested_block" assert "child" in tool.description.lower() or "nested" in tool.description.lower() - assert "parent_block_uuid" in tool.inputSchema["properties"] - assert "content" in tool.inputSchema["properties"] - assert "sibling" in tool.inputSchema["properties"] - assert tool.inputSchema["required"] == ["parent_block_uuid", "content"] + assert "parent_block_uuid" in tool.input_schema["properties"] + assert "content" in tool.input_schema["properties"] + assert "sibling" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["parent_block_uuid", "content"] @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) @patch('mcp_logseq.tools.logseq.LogSeq') @@ -1796,9 +1796,9 @@ def test_get_tool_description(self): assert tool.name == "get_block" assert "Get a single block" in tool.description - assert tool.inputSchema["required"] == ["block_uuid"] - assert "include_children" in tool.inputSchema["properties"] - assert "format" in tool.inputSchema["properties"] + assert tool.input_schema["required"] == ["block_uuid"] + assert "include_children" in tool.input_schema["properties"] + assert "format" in tool.input_schema["properties"] @patch.dict("os.environ", {"LOGSEQ_API_TOKEN": "test_token"}) @patch("mcp_logseq.tools.logseq.LogSeq") diff --git a/uv.lock b/uv.lock index c1f0808..f612c5c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] [[package]] name = "annotated-types" @@ -13,16 +18,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.7.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076, upload-time = "2024-12-05T15:42:09.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052, upload-time = "2024-12-05T15:42:06.492Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -260,55 +264,73 @@ wheels = [ name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", +] sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, ] [[package]] -name = "httpcore" -version = "1.0.7" +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" } }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -397,15 +419,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.2" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -415,9 +437,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, ] [[package]] @@ -425,6 +447,7 @@ name = "mcp-logseq" version = "1.8.0" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "mcp" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -452,8 +475,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "jsonschema", specifier = ">=4.20" }, { name = "lancedb", marker = "extra == 'vector'", specifier = ">=0.6" }, - { name = "mcp", specifier = ">=1.27" }, + { name = "mcp", specifier = ">=2.0" }, { name = "portalocker", marker = "extra == 'vector'", specifier = ">=2.0" }, { name = "pyarrow", marker = "extra == 'vector'", specifier = ">=14.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, @@ -474,6 +498,19 @@ dev = [ { name = "responses", specifier = ">=0.23.0" }, ] +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -562,6 +599,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -777,20 +826,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1140,39 +1175,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, ] [[package]] name = "starlette" -version = "0.42.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/ae/0c98794b248370ce30f71018d0f39889f1d90c73a631e68e2f47e5efda2f/starlette-0.42.0.tar.gz", hash = "sha256:91f1fbd612f3e3d821a8a5f46bf381afe2a9722a7b8bbde1c07fb83384c2882a", size = 2575136, upload-time = "2024-12-14T09:01:48.773Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/38/f790c69b2cbfe9cd4a8a89db1ef50d0a10e5121c07ff8b1d7c16d7807f41/starlette-0.42.0-py3-none-any.whl", hash = "sha256:02f877201a3d6d301714b5c72f15cac305ea5cc9e213c4b46a5af7eecad0d625", size = 73356, upload-time = "2024-12-14T09:01:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -1187,6 +1213,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1223,7 +1258,8 @@ version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "h11" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } wheels = [