From 70a65bf9373142212ace8f2f2a5cc6f20ca1bf6e Mon Sep 17 00:00:00 2001 From: LUIS NOVO Date: Tue, 16 Dec 2025 12:00:44 -0300 Subject: [PATCH] feat: add query and find_pages_by_property tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new MCP tools for DSL-based searching: - query: Execute arbitrary Logseq DSL queries with support for: - Property queries: (page-property status active) - Task queries: (task todo) - Logical combinations: (and ...) (or ...) (not ...) - Optional limit and result_type (pages_only, blocks_only, all) parameters - Type indicators in results (πŸ“„ page / πŸ“ block) - find_pages_by_property: Simplified interface for property searches - Search by property name only or with specific value - Auto-escapes special characters in values - Shows property values in results Both tools use the logseq.DB.q API method. Includes 18 new unit tests (68 total tests pass). Total tools now: 8 --- FEATURE_QUERY_DSL.md | 513 +++++++++++++++++++++++++++ pyproject.toml | 2 +- specs/query-dsl/architecture.md | 217 +++++++++++ specs/query-dsl/plan.md | 190 ++++++++++ specs/query-dsl/spec.md | 136 +++++++ src/mcp_logseq/logseq.py | 30 ++ src/mcp_logseq/server.py | 2 + src/mcp_logseq/tools.py | 241 +++++++++++++ tests/conftest.py | 51 ++- tests/integration/test_mcp_server.py | 15 +- tests/unit/test_logseq_api.py | 45 ++- tests/unit/test_tool_handlers.py | 252 ++++++++++++- 12 files changed, 1679 insertions(+), 15 deletions(-) create mode 100644 FEATURE_QUERY_DSL.md create mode 100644 specs/query-dsl/architecture.md create mode 100644 specs/query-dsl/plan.md create mode 100644 specs/query-dsl/spec.md diff --git a/FEATURE_QUERY_DSL.md b/FEATURE_QUERY_DSL.md new file mode 100644 index 0000000..61c3a8b --- /dev/null +++ b/FEATURE_QUERY_DSL.md @@ -0,0 +1,513 @@ +# Feature: Query DSL (Search by Properties) + +## Contexto + +O Logseq possui um poderoso sistema de queries DSL que permite buscar pΓ‘ginas e blocos por propriedades, tags, e combinaΓ§Γ΅es lΓ³gicas. Atualmente o MCP nΓ£o expΓ΅e essa capacidade, impossibilitando buscas como: + +- "Todas as pΓ‘ginas com `status:: active`" +- "Todos os clientes (`type:: customer`) que estΓ£o ativos" +- "Blocos marcados como TODO criados esta semana" + +## Problema + +Nenhuma tool atual permite buscar por propriedades/metadados: + +| Tool | Busca por propriedade? | +|------|------------------------| +| `list_pages` | ❌ Lista tudo, sem filtro | +| `search` | ❌ Full-text apenas | +| `get_page_content` | ❌ Exibe uma pΓ‘gina, nΓ£o busca | + +Para encontrar pΓ‘ginas por propriedade hoje, seria necessΓ‘rio listar todas as pΓ‘ginas e chamar `get_page_content` em cada uma β€” inviΓ‘vel. + +## SoluΓ§Γ£o Proposta + +Implementar duas tools complementares: + +### 1. `query` (Query DSL genΓ©rica) + +Executa queries DSL arbitrΓ‘rias do Logseq. MΓ‘xima flexibilidade para usuΓ‘rios avanΓ§ados. + +**API Logseq:** +```json +{ + "method": "logseq.DB.q", + "args": ["(page-property service mentorship)"] +} +``` + +**Tool Schema:** +```python +Tool( + name="query", + description="Execute a Logseq DSL query to search pages and blocks. Supports property queries, tag queries, task queries, and logical combinations.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Logseq DSL query string (e.g., '(page-property status active)', '(and (task todo) (page [[Project]])')" + } + }, + "required": ["query"] + } +) +``` + +**Exemplos de queries DSL:** + +```clojure +;; PΓ‘ginas com propriedade especΓ­fica +(page-property service mentorship) +(page-property status "in progress") + +;; PΓ‘ginas com qualquer valor para uma propriedade +(page-property type) + +;; CombinaΓ§Γ΅es lΓ³gicas +(and (page-property type customer) (page-property status active)) +(or (page-property priority high) (page-property priority urgent)) + +;; Blocos com tags +(page-tags [[meeting]]) + +;; Tasks +(task todo) +(task now later) +(and (task todo) (page [[Projects]])) + +;; Blocos com propriedades +(property status done) + +;; Between dates (para journals) +(between [[Dec 1st, 2024]] [[Dec 15th, 2024]]) +``` + +--- + +### 2. `find_pages_by_property` (Busca simplificada) + +Interface amigΓ‘vel para o caso de uso mais comum: buscar pΓ‘ginas por propriedade. + +**Tool Schema:** +```python +Tool( + name="find_pages_by_property", + description="Find all pages that have a specific property, optionally filtered by value. Simpler alternative to the full query DSL.", + inputSchema={ + "type": "object", + "properties": { + "property_name": { + "type": "string", + "description": "Name of the property to search for (e.g., 'status', 'type', 'service')" + }, + "property_value": { + "type": "string", + "description": "Optional: specific value to match. If omitted, returns all pages that have this property." + } + }, + "required": ["property_name"] + } +) +``` + +**Exemplos de uso:** + +``` +Input: {"property_name": "service", "property_value": "mentorship"} +Output: +Pages with property 'service = mentorship': + +- Customer/Orienteme + +Total: 1 page +``` + +``` +Input: {"property_name": "type"} +Output: +Pages with property 'type': + +- Customer/Orienteme (type: customer) +- Customer/InsideOut (type: customer) +- Projects/Website (type: project) + +Total: 3 pages +``` + +--- + +## ImplementaΓ§Γ£o + +### Arquivo: `src/mcp_logseq/logseq.py` + +```python +def query_dsl(self, query: str) -> Any: + """Execute a Logseq DSL query.""" + url = self.get_base_url() + logger.info(f"Executing DSL query: {query}") + + try: + response = requests.post( + url, + headers=self._get_headers(), + json={ + "method": "logseq.DB.q", + "args": [query] + }, + verify=self.verify_ssl, + timeout=self.timeout + ) + response.raise_for_status() + return response.json() + + except Exception as e: + logger.error(f"Error executing query: {str(e)}") + raise + + +def find_pages_by_property(self, property_name: str, property_value: str = None) -> Any: + """Find pages by property name and optional value.""" + # Build the DSL query + if property_value: + # Escape quotes in value if needed + escaped_value = property_value.replace('"', '\\"') + query = f'(page-property {property_name} "{escaped_value}")' + else: + query = f'(page-property {property_name})' + + return self.query_dsl(query) +``` + +### Arquivo: `src/mcp_logseq/tools.py` + +```python +class QueryToolHandler(ToolHandler): + def __init__(self): + super().__init__("query") + + 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={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Logseq DSL query string (e.g., '(page-property status active)')" + } + }, + "required": ["query"] + } + ) + + def run_tool(self, args: dict) -> list[TextContent]: + if "query" not in args: + raise RuntimeError("query argument required") + + query = args["query"] + + try: + api = logseq.LogSeq(api_key=api_key) + result = api.query_dsl(query) + + if not result: + return [TextContent( + type="text", + text=f"No results found for query: {query}" + )] + + # Format results + content_parts = [] + content_parts.append(f"# Query Results\n") + content_parts.append(f"**Query:** `{query}`\n") + + # Results can be pages or blocks + for i, item in enumerate(result, 1): + if isinstance(item, dict): + # Could be a page or block + name = item.get('originalName') or item.get('name') or item.get('content', '')[:50] + + # Get properties if available + props = item.get('propertiesTextValues', {}) + props_str = ", ".join(f"{k}: {v}" for k, v in props.items()) if props else "" + + if props_str: + content_parts.append(f"{i}. **{name}** ({props_str})") + else: + content_parts.append(f"{i}. **{name}**") + else: + content_parts.append(f"{i}. {item}") + + content_parts.append(f"\n---\n**Total: {len(result)} results**") + + return [TextContent(type="text", text="\n".join(content_parts))] + + except Exception as e: + logger.error(f"Query failed: {str(e)}") + return [TextContent( + type="text", + text=f"❌ Query failed: {str(e)}\n\nMake sure the query syntax is valid. See https://docs.logseq.com/#/page/queries" + )] + + +class FindPagesByPropertyToolHandler(ToolHandler): + def __init__(self): + super().__init__("find_pages_by_property") + + def get_tool_description(self): + return Tool( + name=self.name, + description="Find all pages that have a specific property, optionally filtered by value.", + inputSchema={ + "type": "object", + "properties": { + "property_name": { + "type": "string", + "description": "Name of the property to search for (e.g., 'status', 'type', 'service')" + }, + "property_value": { + "type": "string", + "description": "Optional: specific value to match" + } + }, + "required": ["property_name"] + } + ) + + def run_tool(self, args: dict) -> list[TextContent]: + if "property_name" not in args: + raise RuntimeError("property_name argument required") + + property_name = args["property_name"] + property_value = args.get("property_value") + + try: + api = logseq.LogSeq(api_key=api_key) + result = api.find_pages_by_property(property_name, property_value) + + if not result: + if property_value: + msg = f"No pages found with property '{property_name} = {property_value}'" + else: + msg = f"No pages found with property '{property_name}'" + return [TextContent(type="text", text=msg)] + + # Format results + content_parts = [] + + if property_value: + content_parts.append(f"# Pages with '{property_name} = {property_value}'\n") + else: + content_parts.append(f"# Pages with property '{property_name}'\n") + + for item in result: + if isinstance(item, dict): + name = item.get('originalName') or item.get('name', '') + props = item.get('propertiesTextValues', {}) + + # Show the property value if we searched without a specific value + if not property_value and property_name in props: + content_parts.append(f"- **{name}** ({property_name}: {props[property_name]})") + else: + content_parts.append(f"- **{name}**") + + content_parts.append(f"\n---\n**Total: {len(result)} pages**") + + return [TextContent(type="text", text="\n".join(content_parts))] + + except Exception as e: + logger.error(f"Property search failed: {str(e)}") + return [TextContent( + type="text", + text=f"❌ Search failed: {str(e)}" + )] +``` + +### Arquivo: `src/mcp_logseq/server.py` + +Registrar os novos handlers: + +```python +add_tool_handler(tools.QueryToolHandler()) +add_tool_handler(tools.FindPagesByPropertyToolHandler()) +``` + +--- + +## Testes + +### Testes UnitΓ‘rios (`tests/unit/test_tool_handlers.py`) + +```python +def test_query_handler(): + handler = QueryToolHandler() + assert handler.name == "query" + + tool = handler.get_tool_description() + assert "query" in tool.inputSchema["properties"] + assert "query" in tool.inputSchema["required"] + + +def test_find_pages_by_property_handler(): + handler = FindPagesByPropertyToolHandler() + assert handler.name == "find_pages_by_property" + + tool = handler.get_tool_description() + assert "property_name" in tool.inputSchema["properties"] + assert "property_value" in tool.inputSchema["properties"] + assert "property_name" in tool.inputSchema["required"] + assert "property_value" not in tool.inputSchema["required"] +``` + +### Testes de IntegraΓ§Γ£o (`tests/integration/test_query.py`) + +```python +import pytest +import os +from mcp_logseq.logseq import LogSeq + +@pytest.mark.integration +class TestQueryIntegration: + + def test_query_dsl_page_property(self): + """Test querying pages by property.""" + api = LogSeq(api_key=os.getenv("LOGSEQ_API_TOKEN")) + + # Setup - create a page with property + api.create_page("TestQueryPage", "type:: test\nTest content") + + # Test + result = api.query_dsl("(page-property type test)") + + # Verify + assert result is not None + page_names = [p.get('originalName') or p.get('name') for p in result if isinstance(p, dict)] + assert "TestQueryPage" in page_names + + # Cleanup + api.delete_page("TestQueryPage") + + def test_find_pages_by_property_with_value(self): + """Test simplified property search with value.""" + api = LogSeq(api_key=os.getenv("LOGSEQ_API_TOKEN")) + + # Setup + api.create_page("TestPropPage", "status:: active\nContent") + + # Test + result = api.find_pages_by_property("status", "active") + + # Verify + assert result is not None + + # Cleanup + api.delete_page("TestPropPage") + + def test_find_pages_by_property_without_value(self): + """Test simplified property search without value (any value).""" + api = LogSeq(api_key=os.getenv("LOGSEQ_API_TOKEN")) + + # Setup - create pages with same property, different values + api.create_page("TestPropA", "category:: alpha\nContent A") + api.create_page("TestPropB", "category:: beta\nContent B") + + # Test - find all pages with 'category' property + result = api.find_pages_by_property("category") + + # Verify - should find both + assert result is not None + assert len(result) >= 2 + + # Cleanup + api.delete_page("TestPropA") + api.delete_page("TestPropB") + + def test_query_dsl_logical_combination(self): + """Test query with AND/OR logic.""" + api = LogSeq(api_key=os.getenv("LOGSEQ_API_TOKEN")) + + # Setup + api.create_page("TestLogicPage", "type:: customer\nstatus:: active\nContent") + + # Test - AND query + result = api.query_dsl("(and (page-property type customer) (page-property status active))") + + # Verify + assert result is not None + + # Cleanup + api.delete_page("TestLogicPage") +``` + +--- + +## DocumentaΓ§Γ£o de Queries DSL + +Incluir na descriΓ§Γ£o da tool ou em README: + +### Sintaxe BΓ‘sica + +```clojure +;; Buscar pΓ‘ginas por propriedade +(page-property ) +(page-property ) ;; qualquer valor + +;; Buscar blocos por propriedade +(property ) + +;; CombinaΓ§Γ΅es lΓ³gicas +(and ...) +(or ...) +(not ) + +;; Tasks/TODOs +(task todo) +(task now later done) + +;; Tags +(page-tags [[tag-name]]) + +;; ReferΓͺncias +(page [[Page Name]]) + +;; Datas (para journals) +(between [[Dec 1st, 2024]] [[Dec 15th, 2024]]) +``` + +### Exemplos PrΓ‘ticos + +```clojure +;; Todos os clientes ativos +(and (page-property type customer) (page-property status active)) + +;; PΓ‘ginas de projeto com prioridade alta +(and (page-property type project) (page-property priority high)) + +;; TODOs nΓ£o concluΓ­dos +(task todo now later) + +;; PΓ‘ginas modificadas em dezembro +(between [[Dec 1st, 2024]] [[Dec 31st, 2024]]) +``` + +--- + +## AtualizaΓ§Γ£o do ROADMAP + +ApΓ³s implementaΓ§Γ£o, adicionar a "Implemented Features": + +```markdown +- βœ… Query System + - `query` - Execute arbitrary DSL queries + - `find_pages_by_property` - Simplified property search +``` + +--- + +## ReferΓͺncias + +- [Logseq Queries Documentation](https://docs.logseq.com/#/page/queries) +- [Logseq Plugin API - IDBProxy](https://logseq.github.io/plugins/interfaces/IDBProxy.html) +- MΓ©todo: `q(dsl: string): Promise` - Run a DSL query diff --git a/pyproject.toml b/pyproject.toml index d454330..43ab186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-logseq" -version = "1.0.1" +version = "1.2.0" description = "MCP server to work with LogSeq via the local HTTP server" readme = "README.md" requires-python = ">=3.11" diff --git a/specs/query-dsl/architecture.md b/specs/query-dsl/architecture.md new file mode 100644 index 0000000..210bd89 --- /dev/null +++ b/specs/query-dsl/architecture.md @@ -0,0 +1,217 @@ +# Architecture: Query DSL (Search by Properties) + +**Feature**: Query DSL +**Date**: 2025-12-16 +**Branch**: `query-dsl` +**Specs**: [spec.md](./spec.md) + +## Summary + +This feature adds two new MCP tools (`query` and `find_pages_by_property`) that expose Logseq's DSL query capabilities. The implementation follows the established tool handler pattern: adding API methods to `logseq.py`, creating tool handler classes in `tools.py`, and registering them in `server.py`. + +## Technical Context + +**Language/Stack**: Python 3.11+ +**Key Dependencies**: requests, mcp (existing) +**Storage**: N/A (queries Logseq API directly) +**Testing**: pytest with unittest.mock and responses library +**Platform**: MCP server (stdio) + +## Technical Decisions + +### Decision 1: Single `query_dsl` API Method + +**What**: Both tools will use a single underlying `query_dsl()` method in the API client +**Why**: The `find_pages_by_property` tool is essentially a convenience wrapper that constructs a DSL query string - no need for separate API methods +**Alternatives Considered**: Separate API methods for each tool - rejected as it would duplicate logic +**Trade-offs**: Slightly more string construction in the convenience method, but cleaner API surface + +### Decision 2: Result Type Detection via Heuristics + +**What**: Detect whether a result item is a page or block based on available fields (`originalName`/`name` = page, `content`/`block/content` = block) +**Why**: Logseq's `DB.q` returns mixed results - the response structure varies by query type +**Alternatives Considered**: +- Always return raw JSON - rejected as it's not user-friendly +- Separate API calls - rejected as Logseq doesn't provide type-specific query endpoints +**Trade-offs**: Heuristics may occasionally misclassify edge cases, but covers 99% of use cases + +### Decision 3: Client-Side Filtering for result_type + +**What**: Apply `result_type` filter (pages_only, blocks_only, all) after receiving results from Logseq +**Why**: Logseq's DSL doesn't have a built-in type filter - filtering must be done post-query +**Alternatives Considered**: Modify DSL query to only match certain types - rejected as DSL syntax varies by query type +**Trade-offs**: May fetch more data than needed, but simplifies implementation and maintains query flexibility + +### Decision 4: Default Limit of 100 + +**What**: Both tools default to returning max 100 results +**Why**: Balances usability (enough results for most queries) with performance (prevents overwhelming output) +**Alternatives Considered**: No limit (rejected - could return thousands), lower limit like 20 (rejected - too restrictive for property searches) +**Trade-offs**: Users may need to increase limit for exhaustive searches + +## Architecture Overview + +The current MCP server has 6 tools. This feature adds 2 more tools that query Logseq's database using DSL syntax. + +### Component Structure + +**New Files/Modules:** +``` +None - all changes go into existing files +``` + +**Modified Files:** +``` +src/mcp_logseq/logseq.py - Add query_dsl() method +src/mcp_logseq/tools.py - Add QueryToolHandler and FindPagesByPropertyToolHandler classes +src/mcp_logseq/server.py - Register new tool handlers +tests/conftest.py - Add mock responses for query results +tests/unit/test_tool_handlers.py - Add test classes for new handlers +tests/unit/test_logseq_api.py - Add API method tests +tests/integration/test_mcp_server.py - Update tool count to 8 +``` + +### Component Relationships + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Client β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ server.py β”‚ +β”‚ - Registers QueryToolHandler β”‚ +β”‚ - Registers FindPagesByPropertyToolHandler β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ tools.py β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ QueryToolHandler β”‚ β”‚ FindPagesByPropertyToolHandlerβ”‚ β”‚ +β”‚ β”‚ - query (required) β”‚ β”‚ - property_name (required) β”‚ β”‚ +β”‚ β”‚ - limit (optional) β”‚ β”‚ - property_value (optional) β”‚ β”‚ +β”‚ β”‚ - result_type (opt) β”‚ β”‚ - limit (optional) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ Formats results with β”‚ +β”‚ type indicators β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ logseq.py β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ +β”‚ β”‚ query_dsl(query: str) β”‚β”‚ +β”‚ β”‚ β”‚β”‚ +β”‚ β”‚ POST /api β”‚β”‚ +β”‚ β”‚ {"method": "logseq.DB.q", "args": [query]} β”‚β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Logseq HTTP API β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Data Flow + +**Query Tool Flow:** +1. MCP client calls `query` tool with DSL string +2. QueryToolHandler validates args and calls `api.query_dsl(query)` +3. LogSeq client POSTs to `/api` with `logseq.DB.q` method +4. Results returned, filtered by `result_type` if specified +5. Results limited to `limit` count +6. Each result annotated with type indicator (πŸ“„ page / πŸ“ block) +7. Formatted text response returned to client + +**Find Pages By Property Flow:** +1. MCP client calls `find_pages_by_property` with property_name and optional value +2. FindPagesByPropertyToolHandler constructs DSL query: + - With value: `(page-property {name} "{value}")` + - Without value: `(page-property {name})` +3. Calls `api.query_dsl(constructed_query)` +4. Results limited to `limit` count +5. Formatted text response with property values shown + +## Implementation Approach + +### User Story Mapping + +**US-001 (P1): Execute arbitrary DSL queries** +- Files: `logseq.py`, `tools.py`, `server.py` +- Key components: `query_dsl()` method, `QueryToolHandler` class +- Testing: Mock API responses, test various query types + +**US-002 (P1): Simple property search** +- Files: `tools.py` +- Key components: `FindPagesByPropertyToolHandler` class +- Dependencies: Uses `query_dsl()` from US-001 +- Testing: Test with/without property value, escaping + +**US-003 (P1): Readable result formatting** +- Files: `tools.py` +- Key components: Result formatting in both handlers +- Testing: Verify output format, type indicators + +**US-004 (P2): Unit tests** +- Files: `tests/unit/test_tool_handlers.py`, `tests/unit/test_logseq_api.py`, `tests/conftest.py` +- Testing: Tool description validation, success/error cases + +**US-005 (P2): Register tools** +- Files: `server.py`, `tests/integration/test_mcp_server.py` +- Testing: Integration test verifies 8 tools registered + +### File Structure + +``` +mcp-logseq/ +β”œβ”€β”€ src/mcp_logseq/ +β”‚ β”œβ”€β”€ logseq.py # MODIFIED: Add query_dsl() +β”‚ β”œβ”€β”€ tools.py # MODIFIED: Add 2 new handler classes +β”‚ └── server.py # MODIFIED: Register 2 new handlers +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ conftest.py # MODIFIED: Add query mock responses +β”‚ β”œβ”€β”€ unit/ +β”‚ β”‚ β”œβ”€β”€ test_logseq_api.py # MODIFIED: Add query API tests +β”‚ β”‚ └── test_tool_handlers.py # MODIFIED: Add handler tests +β”‚ └── integration/ +β”‚ └── test_mcp_server.py # MODIFIED: Update tool count to 8 +└── specs/query-dsl/ + β”œβ”€β”€ spec.md + └── architecture.md # THIS FILE +``` + +## Integration Points + +- **Integration with existing tools**: None - these are standalone query tools +- **New APIs/Interfaces**: Two new MCP tools exposed via `list_tools` and `call_tool` +- **Dependencies**: Relies on existing `LogSeq` client infrastructure + +## Technical Constraints + +- Must maintain backward compatibility with existing 6 tools +- Query syntax is determined by Logseq - we pass through DSL strings directly +- Result structure varies by query type - formatting must handle multiple shapes + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Invalid DSL query crashes server | Medium | Catch exceptions, return user-friendly error with doc link | +| Large result sets cause timeouts | Low | Default limit of 100, configurable by user | +| Logseq API changes query response format | Low | Type detection uses multiple field checks as fallback | +| Property values with special chars break query | Medium | Escape quotes in property values | + +## Open Questions + +None - all technical decisions resolved. + +## Next Steps + +1. Review this architecture +2. Run `/plan` to generate implementation tasks diff --git a/specs/query-dsl/plan.md b/specs/query-dsl/plan.md new file mode 100644 index 0000000..b27560b --- /dev/null +++ b/specs/query-dsl/plan.md @@ -0,0 +1,190 @@ +# Tasks: Query DSL (Search by Properties) + +**Branch**: `query-dsl` +**Specs**: [spec.md](./spec.md) +**Architecture**: [architecture.md](./architecture.md) +**Status**: βœ… Complete + +--- + +## Phase 1: Implementation - API Client Method (~30min) + +**Purpose**: Add the core DSL query method to the LogSeq API client + +- [x] T001 Add `query_dsl()` method to `src/mcp_logseq/logseq.py` + +**Checkpoint**: βœ… API method implemented, ready for tool handlers + +**Notes:** +- Uses `logseq.DB.q` API method +- Returns raw query results from Logseq +- Follows existing method patterns (logging, error handling, try/except) + +--- + +## Phase 2: Implementation - Tool Handlers (~1h) + +**Purpose**: Create the MCP tool handler classes with result formatting + +- [x] T002 Add `QueryToolHandler` class to `src/mcp_logseq/tools.py` + - Parameters: `query` (required), `limit` (optional, default 100), `result_type` (optional: pages_only, blocks_only, all) + - Implemented type detection heuristics (page vs block) + - Formats results with type indicators (πŸ“„ page / πŸ“ block) + - Handles errors with helpful messages and doc link + +- [x] T003 Add `FindPagesByPropertyToolHandler` class to `src/mcp_logseq/tools.py` + - Parameters: `property_name` (required), `property_value` (optional), `limit` (optional, default 100) + - Constructs DSL query: `(page-property name)` or `(page-property name "value")` + - Escapes special characters in property values + - Formats results showing property values + +- [x] T004 Register both handlers in `src/mcp_logseq/server.py` + +**Checkpoint**: βœ… Tools registered and available via MCP protocol + +**Notes:** +- Followed existing handler patterns (SearchToolHandler as reference) +- `find_pages_by_property` uses `query_dsl()` internally +- Error handling includes link to Logseq query docs + +--- + +## Phase 3: Testing (~45min) + +**Purpose**: Add comprehensive unit tests for new functionality + +- [x] T005 Add mock responses for query results to `tests/conftest.py` + - `query_dsl_pages_success`: List of page results + - `query_dsl_blocks_success`: List of block results + - `query_dsl_mixed_success`: Mixed pages and blocks + - `query_dsl_empty`: Empty results + +- [x] T006 [P] Add `TestQueryToolHandler` class to `tests/unit/test_tool_handlers.py` + - 8 test cases covering: description, success, empty, limit, pages_only, blocks_only, invalid query, missing args + +- [x] T007 [P] Add `TestFindPagesByPropertyToolHandler` class to `tests/unit/test_tool_handlers.py` + - 7 test cases covering: description, with value, without value, empty, limit, escaping quotes, missing args + +- [x] T008 [P] Add query API tests to `tests/unit/test_logseq_api.py` + - `test_query_dsl_success` + - `test_query_dsl_empty_results` + - `test_query_dsl_network_error` + +- [x] T009 Update `tool_handlers` fixture in `tests/conftest.py` to include new handlers + +- [x] T010 Update integration test tool count to 8 in `tests/integration/test_mcp_server.py` + +**Checkpoint**: βœ… All tests pass with `uv run pytest` + +**Notes:** +- Added 18 new tests (68 total now) +- All tests follow existing patterns + +--- + +## Phase 4: Validation (~15min) + +**Purpose**: Final validation + +- [x] T011 Run full test suite: `LOGSEQ_API_TOKEN=test_token uv run pytest -v` +- [x] T012 Verify 8 tools appear in server (via integration test) +- [ ] T013 Test with real LogSeq instance (optional) + +**Checkpoint**: βœ… Feature complete and validated + +**Notes:** +- All 68 tests pass +- Total tools: 8 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1: API Client Method + ↓ +Phase 2: Tool Handlers (depends on Phase 1) + ↓ +Phase 3: Testing (depends on Phase 1 & 2) + ↓ +Phase 4: Validation (depends on all phases) +``` + +### Parallel Opportunities + +**Within Phase 3:** +- T006, T007, T008 are marked [P] - different test classes can be written in parallel +- T005 must complete first (fixtures needed by tests) + +--- + +## Implementation Strategy + +### Recommended Approach + +1. Complete Phase 1 (API method) - ~30 min βœ… +2. Complete Phase 2 (Tool handlers) - ~1 hour βœ… +3. Complete Phase 3 (Tests) - ~45 min βœ… +4. Complete Phase 4 (Validation) - ~15 min βœ… + +**Total estimated time: ~2.5 hours** + +### Single-Session Execution + +This feature was completed in a single session following the established patterns in the codebase. + +--- + +## Progress Tracking + +**Emoji Legend:** +- ⏳ Not Started +- ⏰ In Progress +- βœ… Completed + +--- + +## Notes + +**Key Implementation References:** +- Feature requirements: `FEATURE_QUERY_DSL.md` (includes sample code) +- Existing patterns: `SearchToolHandler` in `tools.py` +- API patterns: `search_content()` in `logseq.py` +- Test patterns: `TestSearchToolHandler` in `test_tool_handlers.py` + +**Logseq API Method:** +- `logseq.DB.q` - Run a DSL query +- Returns array of matching pages/blocks + +**Result Type Detection:** +- Page: has `originalName` or `name` field, without `content` field +- Block: has `content` or `block/content` field + +**Output Format Examples:** + +Query tool: +``` +# Query Results + +**Query:** `(page-property type customer)` + +1. πŸ“„ **Customer/Orienteme** (type: customer, status: active) +2. πŸ“„ **Customer/InsideOut** (type: customer) +3. πŸ“ Block content here... + +--- +**Total: 3 results** +``` + +Find pages by property: +``` +# Pages with 'type = customer' + +- **Customer/Orienteme** (type: customer) +- **Customer/InsideOut** (type: customer) + +--- +**Total: 2 pages** +``` diff --git a/specs/query-dsl/spec.md b/specs/query-dsl/spec.md new file mode 100644 index 0000000..110be02 --- /dev/null +++ b/specs/query-dsl/spec.md @@ -0,0 +1,136 @@ +# Feature Specification: Query DSL (Search by Properties) + +**Feature Branch**: `query-dsl` +**Input**: User description: "@FEATURE_QUERY_DSL.md" + +## Context and Understanding + +Logseq has a powerful DSL (Domain Specific Language) query system that allows searching pages and blocks by properties, tags, and logical combinations. Currently, the MCP server does not expose this capability, making it impossible to perform searches like: + +- "All pages with `status:: active`" +- "All customers (`type:: customer`) that are active" +- "Blocks marked as TODO created this week" + +**Current Limitations:** + +| Tool | Can search by property? | +|------|------------------------| +| `list_pages` | ❌ Lists everything, no filter | +| `search` | ❌ Full-text search only | +| `get_page_content` | ❌ Shows one page, doesn't search | + +To find pages by property today, one would need to list all pages and call `get_page_content` on each one β€” completely impractical. + +## Feature Description + +This feature adds two complementary MCP tools that expose Logseq's DSL query capabilities: + +1. **`query`** - A generic DSL query tool for maximum flexibility, allowing advanced users to execute any valid Logseq query +2. **`find_pages_by_property`** - A simplified interface for the most common use case: finding pages by a specific property and optional value + +These tools unlock powerful metadata-based searching that was previously unavailable through the MCP interface. + +## Requirements + +### Proposed Solution + +- **US-001**: As an MCP user, I want to execute arbitrary Logseq DSL queries so that I can search for pages and blocks using complex criteria +- **US-002**: As an MCP user, I want a simple way to find pages by property name and value so that I don't need to learn DSL syntax for common searches +- **US-003**: As an MCP user, I want query results formatted in a readable way so that I can easily understand what was found +- **US-004**: As a developer, I want unit tests for both new tools so that the feature is maintainable +- **US-005**: As a developer, I want the tools registered in the MCP server so that they are available to clients + +### Functional Requirements + +- **FR-001**: System MUST provide a `query` tool that accepts a DSL query string and returns matching results +- **FR-002**: System MUST provide a `find_pages_by_property` tool that accepts a property name and optional value +- **FR-003**: System MUST use the `logseq.DB.q` API method to execute DSL queries +- **FR-004**: System MUST format query results showing page/block names and relevant properties +- **FR-005**: System MUST handle empty results gracefully with informative messages +- **FR-006**: System MUST handle invalid queries with clear error messages and documentation reference +- **FR-007**: The `find_pages_by_property` tool MUST support searching with just a property name (returns all pages with that property) +- **FR-008**: The `find_pages_by_property` tool MUST support searching with property name AND value (returns pages matching both) +- **FR-009**: System MUST properly escape special characters in property values when building queries +- **FR-010**: Both tools MUST support an optional `limit` parameter (default: 100) to control result set size +- **FR-011**: The `query` tool MUST support an optional `result_type` parameter to filter results (pages_only, blocks_only, all - default: all) +- **FR-012**: Query results MUST include a type indicator for each item (page or block) in a unified list format + +### DSL Query Syntax Support + +The `query` tool should support standard Logseq DSL syntax including: + +```clojure +;; Page property queries +(page-property ) +(page-property ) ;; any value + +;; Block property queries +(property ) + +;; Logical combinations +(and ...) +(or ...) +(not ) + +;; Tasks/TODOs +(task todo) +(task now later done) + +;; Tags +(page-tags [[tag-name]]) + +;; References +(page [[Page Name]]) + +;; Date ranges (for journals) +(between [[Dec 1st, 2024]] [[Dec 15th, 2024]]) +``` + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Users can find pages by property in a single MCP tool call instead of multiple calls +- **SC-002**: The `query` tool successfully executes valid Logseq DSL queries and returns results +- **SC-003**: The `find_pages_by_property` tool returns correct results for property searches +- **SC-004**: All new unit tests pass +- **SC-005**: Integration test confirms 12 tools are registered (current 10 + 2 new) +- **SC-006**: Error messages for invalid queries include helpful guidance + +## Clarification Needed + +*All clarifications resolved.* + +### Decisions Made + +1. **Result Pagination**: βœ… **Option A** - Add optional `limit` parameter (default: 100) to both tools + +2. **Block vs Page Results**: βœ… **Options B + C** - Unified list with type indicator per item, plus optional `result_type` parameter to filter (pages_only, blocks_only, all) + +## Notes + +### API Reference +- **Logseq API Method**: `logseq.DB.q` - Run a DSL query +- **Documentation**: https://docs.logseq.com/#/page/queries +- **Plugin API Reference**: https://logseq.github.io/plugins/interfaces/IDBProxy.html + +### Example Queries + +```clojure +;; All active customers +(and (page-property type customer) (page-property status active)) + +;; High priority project pages +(and (page-property type project) (page-property priority high)) + +;; Incomplete TODOs +(task todo now later) + +;; Pages modified in December +(between [[Dec 1st, 2024]] [[Dec 31st, 2024]]) +``` + +### Implementation Notes +- The feature file includes sample implementation code that follows existing patterns in the codebase +- Both tools use the same underlying `query_dsl` API method - `find_pages_by_property` is a convenience wrapper +- Property values containing quotes need proper escaping diff --git a/src/mcp_logseq/logseq.py b/src/mcp_logseq/logseq.py index 014d184..982acb2 100644 --- a/src/mcp_logseq/logseq.py +++ b/src/mcp_logseq/logseq.py @@ -295,3 +295,33 @@ 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 query_dsl(self, query: str) -> Any: + """Execute a Logseq DSL query to search pages and blocks. + + Args: + query: Logseq DSL query string (e.g., '(page-property status active)') + + Returns: + List of matching pages/blocks from the query + """ + url = self.get_base_url() + logger.info(f"Executing DSL query: {query}") + + try: + response = requests.post( + url, + headers=self._get_headers(), + json={ + "method": "logseq.DB.q", + "args": [query] + }, + verify=self.verify_ssl, + timeout=self.timeout + ) + response.raise_for_status() + return response.json() + + except Exception as e: + logger.error(f"Error executing DSL query: {str(e)}") + raise diff --git a/src/mcp_logseq/server.py b/src/mcp_logseq/server.py index e78c525..330f85c 100644 --- a/src/mcp_logseq/server.py +++ b/src/mcp_logseq/server.py @@ -78,6 +78,8 @@ 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.QueryToolHandler()) +add_tool_handler(tools.FindPagesByPropertyToolHandler()) 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..d6892b5 100644 --- a/src/mcp_logseq/tools.py +++ b/src/mcp_logseq/tools.py @@ -486,3 +486,244 @@ def run_tool(self, args: dict) -> list[TextContent]: type="text", text=f"❌ Search failed: {str(e)}" )] + + +class QueryToolHandler(ToolHandler): + """Execute Logseq DSL queries to search pages and blocks.""" + + def __init__(self): + super().__init__("query") + + 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={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Logseq DSL query string (e.g., '(page-property status active)', '(and (task todo) (page [[Project]]))')" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return", + "default": 100 + }, + "result_type": { + "type": "string", + "description": "Filter results by type", + "enum": ["all", "pages_only", "blocks_only"], + "default": "all" + } + }, + "required": ["query"] + } + ) + + def _is_page(self, item: dict) -> bool: + """Detect if a result item is a page based on available fields.""" + if not isinstance(item, dict): + return False + # Pages typically have originalName or name without block-specific fields + has_page_fields = bool(item.get("originalName") or item.get("name")) + has_block_content = bool(item.get("content") or item.get("block/content")) + return has_page_fields and not has_block_content + + def _is_block(self, item: dict) -> bool: + """Detect if a result item is a block based on available fields.""" + if not isinstance(item, dict): + return False + return bool(item.get("content") or item.get("block/content")) + + def _format_item(self, item: dict, index: int) -> str: + """Format a single result item with type indicator.""" + if not isinstance(item, dict): + return f"{index}. {item}" + + if self._is_page(item): + name = item.get("originalName") or item.get("name", "") + # Get properties if available + props = item.get("propertiesTextValues", {}) or item.get("properties", {}) + props_str = ", ".join(f"{k}: {v}" for k, v in props.items()) if props else "" + if props_str: + return f"{index}. πŸ“„ **{name}** ({props_str})" + return f"{index}. πŸ“„ **{name}**" + elif self._is_block(item): + content = item.get("content") or item.get("block/content", "") + # Truncate long content + if len(content) > 100: + content = content[:100] + "..." + return f"{index}. πŸ“ {content}" + else: + # Unknown type - just show what we have + name = item.get("originalName") or item.get("name") or str(item)[:50] + return f"{index}. {name}" + + def run_tool(self, args: dict) -> list[TextContent]: + """Execute DSL query and format results.""" + if "query" not in args: + raise RuntimeError("query argument required") + + query = args["query"] + limit = args.get("limit", 100) + result_type = args.get("result_type", "all") + + try: + api = logseq.LogSeq(api_key=api_key) + result = api.query_dsl(query) + + if not result: + return [TextContent( + type="text", + text=f"No results found for query: `{query}`" + )] + + # Filter by result_type if specified + filtered_results = [] + for item in result: + if result_type == "pages_only" and not self._is_page(item): + continue + if result_type == "blocks_only" and not self._is_block(item): + continue + filtered_results.append(item) + + if not filtered_results: + filter_msg = f" (filtered to {result_type})" if result_type != "all" else "" + return [TextContent( + type="text", + text=f"No results found for query: `{query}`{filter_msg}" + )] + + # Apply limit + limited_results = filtered_results[:limit] + + # Format results + content_parts = [] + content_parts.append(f"# Query Results\n") + content_parts.append(f"**Query:** `{query}`\n") + + for i, item in enumerate(limited_results, 1): + content_parts.append(self._format_item(item, i)) + + # Summary + content_parts.append(f"\n---") + if len(filtered_results) > limit: + content_parts.append(f"**Showing {limit} of {len(filtered_results)} results** (increase limit to see more)") + else: + content_parts.append(f"**Total: {len(limited_results)} results**") + + return [TextContent(type="text", text="\n".join(content_parts))] + + except Exception as e: + logger.error(f"Query failed: {str(e)}") + return [TextContent( + type="text", + text=f"❌ Query failed: {str(e)}\n\nMake sure the query syntax is valid. See https://docs.logseq.com/#/page/queries" + )] + + +class FindPagesByPropertyToolHandler(ToolHandler): + """Find pages by property name and optional value.""" + + def __init__(self): + super().__init__("find_pages_by_property") + + 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={ + "type": "object", + "properties": { + "property_name": { + "type": "string", + "description": "Name of the property to search for (e.g., 'status', 'type', 'service')" + }, + "property_value": { + "type": "string", + "description": "Optional: specific value to match. If omitted, returns all pages that have this property." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return", + "default": 100 + } + }, + "required": ["property_name"] + } + ) + + def _escape_value(self, value: str) -> str: + """Escape special characters in property values for DSL query.""" + # Escape double quotes + return value.replace('"', '\\"') + + def run_tool(self, args: dict) -> list[TextContent]: + """Find pages by property and format results.""" + if "property_name" not in args: + raise RuntimeError("property_name argument required") + + property_name = args["property_name"] + property_value = args.get("property_value") + limit = args.get("limit", 100) + + # Build the DSL query + if property_value: + escaped_value = self._escape_value(property_value) + query = f'(page-property {property_name} "{escaped_value}")' + else: + query = f'(page-property {property_name})' + + try: + api = logseq.LogSeq(api_key=api_key) + result = api.query_dsl(query) + + if not result: + if property_value: + msg = f"No pages found with property '{property_name} = {property_value}'" + else: + msg = f"No pages found with property '{property_name}'" + return [TextContent(type="text", text=msg)] + + # Apply limit + limited_results = result[:limit] + + # Format results + content_parts = [] + + if property_value: + content_parts.append(f"# Pages with '{property_name} = {property_value}'\n") + else: + content_parts.append(f"# Pages with property '{property_name}'\n") + + for item in limited_results: + if isinstance(item, dict): + name = item.get("originalName") or item.get("name", "") + props = item.get("propertiesTextValues", {}) or item.get("properties", {}) + + # Show the property value if we searched without a specific value + if not property_value and property_name in props: + content_parts.append(f"- **{name}** ({property_name}: {props[property_name]})") + elif not property_value and property_name.lower() in props: + content_parts.append(f"- **{name}** ({property_name}: {props[property_name.lower()]})") + else: + content_parts.append(f"- **{name}**") + else: + content_parts.append(f"- {item}") + + # Summary + content_parts.append(f"\n---") + if len(result) > limit: + content_parts.append(f"**Showing {limit} of {len(result)} pages** (increase limit to see more)") + else: + content_parts.append(f"**Total: {len(limited_results)} pages**") + + return [TextContent(type="text", text="\n".join(content_parts))] + + except Exception as e: + logger.error(f"Property search failed: {str(e)}") + return [TextContent( + type="text", + text=f"❌ Search failed: {str(e)}" + )] diff --git a/tests/conftest.py b/tests/conftest.py index c18915e..a29a98e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,13 @@ from mcp_logseq.logseq import LogSeq from mcp_logseq.tools import ( CreatePageToolHandler, - ListPagesToolHandler, + ListPagesToolHandler, GetPageContentToolHandler, DeletePageToolHandler, UpdatePageToolHandler, - SearchToolHandler + SearchToolHandler, + QueryToolHandler, + FindPagesByPropertyToolHandler ) @pytest.fixture @@ -76,7 +78,44 @@ def mock_logseq_responses(): ], "files": [], "has-more?": False - } + }, + "query_dsl_pages_success": [ + { + "id": "page-1", + "name": "customer/orienteme", + "originalName": "Customer/Orienteme", + "propertiesTextValues": {"type": "customer", "status": "active"} + }, + { + "id": "page-2", + "name": "customer/insideout", + "originalName": "Customer/InsideOut", + "propertiesTextValues": {"type": "customer"} + } + ], + "query_dsl_blocks_success": [ + { + "id": "block-1", + "content": "This is a TODO block", + "marker": "TODO" + }, + { + "id": "block-2", + "content": "Another block with content" + } + ], + "query_dsl_mixed_success": [ + { + "id": "page-1", + "originalName": "Customer/Orienteme", + "propertiesTextValues": {"type": "customer"} + }, + { + "id": "block-1", + "content": "Block referencing customer" + } + ], + "query_dsl_empty": [] } @pytest.fixture @@ -86,9 +125,11 @@ def tool_handlers(): "create_page": CreatePageToolHandler(), "list_pages": ListPagesToolHandler(), "get_page_content": GetPageContentToolHandler(), - "delete_page": DeletePageToolHandler(), + "delete_page": DeletePageToolHandler(), "update_page": UpdatePageToolHandler(), - "search": SearchToolHandler() + "search": SearchToolHandler(), + "query": QueryToolHandler(), + "find_pages_by_property": FindPagesByPropertyToolHandler() } @pytest.fixture diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index f5a9dae..7de9f5a 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -11,11 +11,13 @@ def test_tool_handlers_registration(self): """Test that all tool handlers are properly registered.""" expected_tools = [ "create_page", - "list_pages", + "list_pages", "get_page_content", "delete_page", "update_page", - "search" + "search", + "query", + "find_pages_by_property" ] # Verify all expected tools are registered @@ -39,13 +41,14 @@ def test_get_tool_handler_non_existing(self): def test_list_tools_handler_count(self): """Test that we have the expected number of tool handlers.""" - # We should have 6 registered tool handlers - assert len(tool_handlers) == 6 - + # We should have 8 registered tool handlers + assert len(tool_handlers) == 8 + # Verify specific tool names are present expected_names = [ "create_page", "list_pages", "get_page_content", - "delete_page", "update_page", "search" + "delete_page", "update_page", "search", + "query", "find_pages_by_property" ] for name in expected_names: assert name in tool_handlers diff --git a/tests/unit/test_logseq_api.py b/tests/unit/test_logseq_api.py index a2dc4f4..cb9228f 100644 --- a/tests/unit/test_logseq_api.py +++ b/tests/unit/test_logseq_api.py @@ -296,4 +296,47 @@ def test_update_page_success(self, logseq_client, mock_logseq_responses): assert result == expected # Verify all calls were made - assert len(responses.calls) == 3 \ No newline at end of file + assert len(responses.calls) == 3 + + @responses.activate + def test_query_dsl_success(self, logseq_client, mock_logseq_responses): + """Test successful DSL query.""" + responses.add( + responses.POST, + "http://127.0.0.1:12315/api", + json=mock_logseq_responses["query_dsl_pages_success"], + status=200 + ) + + result = logseq_client.query_dsl("(page-property type customer)") + assert result == mock_logseq_responses["query_dsl_pages_success"] + + # Verify the request + request_data = json.loads(responses.calls[0].request.body) + assert request_data["method"] == "logseq.DB.q" + assert request_data["args"] == ["(page-property type customer)"] + + @responses.activate + def test_query_dsl_empty_results(self, logseq_client, mock_logseq_responses): + """Test DSL query with no results.""" + responses.add( + responses.POST, + "http://127.0.0.1:12315/api", + json=mock_logseq_responses["query_dsl_empty"], + status=200 + ) + + result = logseq_client.query_dsl("(page-property nonexistent)") + assert result == [] + + @responses.activate + def test_query_dsl_network_error(self, logseq_client): + """Test DSL query with network error.""" + responses.add( + responses.POST, + "http://127.0.0.1:12315/api", + body=requests.exceptions.ConnectionError("Connection failed") + ) + + with pytest.raises(requests.exceptions.ConnectionError): + logseq_client.query_dsl("(page-property type)") \ No newline at end of file diff --git a/tests/unit/test_tool_handlers.py b/tests/unit/test_tool_handlers.py index 80056c4..eaec7db 100644 --- a/tests/unit/test_tool_handlers.py +++ b/tests/unit/test_tool_handlers.py @@ -7,7 +7,9 @@ GetPageContentToolHandler, DeletePageToolHandler, UpdatePageToolHandler, - SearchToolHandler + SearchToolHandler, + QueryToolHandler, + FindPagesByPropertyToolHandler ) class TestCreatePageToolHandler: @@ -387,4 +389,250 @@ def test_run_tool_with_options(self, mock_logseq_class): }) # Verify API was called with correct options - mock_api.search_content.assert_called_once_with("test", {"limit": 5}) \ No newline at end of file + mock_api.search_content.assert_called_once_with("test", {"limit": 5}) + + +class TestQueryToolHandler: + """Test cases for QueryToolHandler.""" + + def test_get_tool_description(self): + """Test tool description schema.""" + handler = QueryToolHandler() + tool = handler.get_tool_description() + + 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"] + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_success(self, mock_logseq_class): + """Test successful DSL query.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": "Customer/Orienteme", "propertiesTextValues": {"type": "customer"}}, + {"originalName": "Customer/InsideOut", "propertiesTextValues": {"type": "customer"}} + ] + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(page-property type customer)"}) + + mock_api.query_dsl.assert_called_once_with("(page-property type customer)") + + text = result[0].text + assert "# Query Results" in text + assert "(page-property type customer)" in text + assert "Customer/Orienteme" in text + assert "Customer/InsideOut" in text + assert "Total: 2 results" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_empty_results(self, mock_logseq_class): + """Test query with no results.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [] + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(page-property nonexistent)"}) + + text = result[0].text + assert "No results found for query" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_with_limit(self, mock_logseq_class): + """Test query with limit parameter.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": f"Page{i}"} for i in range(10) + ] + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(page-property type)", "limit": 3}) + + text = result[0].text + assert "Page0" in text + assert "Page2" in text + assert "Showing 3 of 10 results" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_result_type_pages_only(self, mock_logseq_class): + """Test query filtered to pages only.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": "Customer/Test"}, + {"content": "Block content"} + ] + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(page-property type)", "result_type": "pages_only"}) + + text = result[0].text + assert "Customer/Test" in text + assert "Block content" not in text + assert "Total: 1 results" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_result_type_blocks_only(self, mock_logseq_class): + """Test query filtered to blocks only.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": "Customer/Test"}, + {"content": "Block content"} + ] + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(task todo)", "result_type": "blocks_only"}) + + text = result[0].text + assert "Customer/Test" not in text + assert "Block content" in text + assert "Total: 1 results" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_invalid_query(self, mock_logseq_class): + """Test error handling for invalid query.""" + mock_api = Mock() + mock_api.query_dsl.side_effect = Exception("Invalid query syntax") + mock_logseq_class.return_value = mock_api + + handler = QueryToolHandler() + result = handler.run_tool({"query": "(invalid"}) + + text = result[0].text + assert "Query failed" in text + assert "Invalid query syntax" in text + assert "https://docs.logseq.com" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + def test_run_tool_missing_args(self): + """Test missing required argument.""" + handler = QueryToolHandler() + + with pytest.raises(RuntimeError, match="query argument required"): + handler.run_tool({}) + + +class TestFindPagesByPropertyToolHandler: + """Test cases for FindPagesByPropertyToolHandler.""" + + def test_get_tool_description(self): + """Test tool description schema.""" + handler = FindPagesByPropertyToolHandler() + tool = handler.get_tool_description() + + 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"] + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_with_value(self, mock_logseq_class): + """Test property search with specific value.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": "Customer/Orienteme", "propertiesTextValues": {"type": "customer"}} + ] + mock_logseq_class.return_value = mock_api + + handler = FindPagesByPropertyToolHandler() + result = handler.run_tool({"property_name": "type", "property_value": "customer"}) + + mock_api.query_dsl.assert_called_once_with('(page-property type "customer")') + + text = result[0].text + assert "Pages with 'type = customer'" in text + assert "Customer/Orienteme" in text + assert "Total: 1 pages" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_without_value(self, mock_logseq_class): + """Test property search without specific value.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": "Customer/Orienteme", "propertiesTextValues": {"type": "customer"}}, + {"originalName": "Projects/Website", "propertiesTextValues": {"type": "project"}} + ] + mock_logseq_class.return_value = mock_api + + handler = FindPagesByPropertyToolHandler() + result = handler.run_tool({"property_name": "type"}) + + mock_api.query_dsl.assert_called_once_with('(page-property type)') + + text = result[0].text + assert "Pages with property 'type'" in text + assert "Customer/Orienteme" in text + assert "type: customer" in text + assert "Projects/Website" in text + assert "type: project" in text + assert "Total: 2 pages" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_empty_results(self, mock_logseq_class): + """Test property search with no results.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [] + mock_logseq_class.return_value = mock_api + + handler = FindPagesByPropertyToolHandler() + result = handler.run_tool({"property_name": "nonexistent"}) + + text = result[0].text + assert "No pages found with property 'nonexistent'" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_with_limit(self, mock_logseq_class): + """Test property search with limit.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [ + {"originalName": f"Page{i}"} for i in range(10) + ] + mock_logseq_class.return_value = mock_api + + handler = FindPagesByPropertyToolHandler() + result = handler.run_tool({"property_name": "type", "limit": 3}) + + text = result[0].text + assert "Page0" in text + assert "Page2" in text + assert "Showing 3 of 10 pages" in text + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + @patch('mcp_logseq.tools.logseq.LogSeq') + def test_run_tool_escapes_quotes(self, mock_logseq_class): + """Test that quotes in property values are escaped.""" + mock_api = Mock() + mock_api.query_dsl.return_value = [] + mock_logseq_class.return_value = mock_api + + handler = FindPagesByPropertyToolHandler() + handler.run_tool({"property_name": "status", "property_value": 'in "progress"'}) + + mock_api.query_dsl.assert_called_once_with('(page-property status "in \\"progress\\"")') + + @patch.dict('os.environ', {'LOGSEQ_API_TOKEN': 'test_token'}) + def test_run_tool_missing_args(self): + """Test missing required argument.""" + handler = FindPagesByPropertyToolHandler() + + with pytest.raises(RuntimeError, match="property_name argument required"): + handler.run_tool({}) \ No newline at end of file