From 99f14805bdf04bfcbe0d4dacec45f5f6f0630a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:05:04 +0300 Subject: [PATCH 1/8] docs: add A5 logging refactor design spec Co-Authored-By: Claude --- .../specs/2026-07-19-a5-logging-design.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-a5-logging-design.md diff --git a/docs/superpowers/specs/2026-07-19-a5-logging-design.md b/docs/superpowers/specs/2026-07-19-a5-logging-design.md new file mode 100644 index 0000000..9cc4be1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-a5-logging-design.md @@ -0,0 +1,81 @@ +# A5 — Logging Configuration and Payload Redaction (Design) + +**Date:** 2026-07-19 +**Source:** ARCHITECTURE_REVIEW.md item A5 (the last remaining item from the review). + +## Problem + +- Importing `server.py` calls `logging.basicConfig(level=logging.DEBUG)` on the + **root logger** and attaches a `FileHandler` writing to + `~/.cache/mcp-logseq/mcp_logseq.log` at DEBUG. A library/server module must + not hijack the host process's logging configuration. +- `call_tool` logs every call's full `arguments` at INFO and the full tool + `result` at DEBUG. Page/block content — including pages the ACL layer is + meant to gate — ends up on disk in plaintext. + +## Design + +### 1. Remove import-time logging configuration from `server.py` + +Delete the `basicConfig` call and the unconditional file-handler block +(`server.py` lines 16–36). The module keeps only +`logger = logging.getLogger("mcp-logseq")`. No other module needs changes: +`logseq.py`, `config.py`, `access.py`, and the tool handlers already use +`logging.getLogger(...)` without configuring anything. + +### 2. Configure logging in the CLI entrypoint (`__init__.main()`) + +`main()` is the single entrypoint for both transports (stdio and http), so one +`_setup_logging()` call at its start covers everything: + +- **Level** from `LOGSEQ_LOG_LEVEL` (case-insensitive: `DEBUG`, `INFO`, + `WARNING`, `ERROR`, `CRITICAL`). Default: **INFO**. An invalid value logs a + warning and falls back to INFO. +- **Stderr handler** via `logging.basicConfig(...)` with the existing format + string (`%(asctime)s - %(name)s - %(levelname)s - %(message)s`). Configuring + the root logger is correct at this layer — the CLI owns the process. +- **File logging is opt-in**: only when `LOGSEQ_LOG_FILE=` is set, a + `FileHandler` for that path is added (same level and format). If the file + cannot be opened, log a warning and continue with stderr only. The + unconditional `~/.cache/mcp-logseq/mcp_logseq.log` file is gone. + +### 3. Redact payloads at the dispatch choke point (`call_tool`) + +`call_tool` in `server.py` is the single dispatch point (the A4 refactor's +choke-point structure), so redaction happens once, there: + +- Arguments: log the tool name and the **argument keys only**, after the + `isinstance(arguments, dict)` check — + `Tool call: create_page (argument keys: content, title)`. +- Result: log the **count of content items** instead of the bodies — + `Tool create_page returned 1 content item(s)`. + +Identifier-level logs elsewhere (`logseq.py` logging page names, query +strings, block counts) match the review's "log sizes/identifiers instead" +guidance and stay as they are. + +### 4. Documentation and changelog + +- README "Environment Variables" section: add `LOGSEQ_LOG_LEVEL` and + `LOGSEQ_LOG_FILE`. +- `CHANGELOG.md` `[Unreleased]`: note the behavior change — no more + DEBUG-by-default, and no log file is written unless `LOGSEQ_LOG_FILE` is + set. Anyone relying on `~/.cache/mcp-logseq/mcp_logseq.log` must now opt in. + +## Testing + +- Unit tests for `_setup_logging()`: default level INFO, `LOGSEQ_LOG_LEVEL` + honored, invalid value falls back to INFO, `LOGSEQ_LOG_FILE` attaches a file + handler, unopenable path degrades gracefully. +- A test asserting that importing `mcp_logseq.server` does not touch the root + logger's level or handlers. +- A test asserting `call_tool` logs argument keys but not argument values + (e.g. a marker string in `content` must not appear in `caplog.text`). +- Existing `caplog` tests are unaffected: `caplog` captures via its own + handler, independent of `basicConfig`. + +## Error handling + +- Invalid `LOGSEQ_LOG_LEVEL` → warning + INFO fallback (never crash). +- Unwritable `LOGSEQ_LOG_FILE` → warning + stderr-only (matches the old + behavior of continuing without file logging). From bf83ca52f40977a7ab842a9c894801ec7e7a699b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:14:08 +0300 Subject: [PATCH 2/8] docs: add A5 logging refactor implementation plan Co-Authored-By: Claude --- .../plans/2026-07-19-a5-logging.md | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-a5-logging.md diff --git a/docs/superpowers/plans/2026-07-19-a5-logging.md b/docs/superpowers/plans/2026-07-19-a5-logging.md new file mode 100644 index 0000000..3fc91b3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-a5-logging.md @@ -0,0 +1,480 @@ +# A5 Logging Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move logging configuration from `server.py` import time to the CLI entrypoint, gate verbosity behind `LOGSEQ_LOG_LEVEL`, make file logging opt-in via `LOGSEQ_LOG_FILE`, and redact tool arguments/results from logs. + +**Architecture:** `__init__.main()` is the single CLI entrypoint for both transports (stdio and http), so a `_setup_logging()` call there covers everything. `server.py`'s `call_tool` closure is the single dispatch point; its body moves into a module-level `_dispatch_tool_call()` coroutine so the redacted logging is unit-testable, and the closure becomes a one-line delegate. + +**Tech Stack:** Python ≥3.11 stdlib `logging`, pytest (tests run with `uv run pytest`). + +**Spec:** `docs/superpowers/specs/2026-07-19-a5-logging-design.md` + +## Global Constraints + +- Repo language is English for all committed content (code, comments, docs, commit messages). +- Commit trailer: `Co-Authored-By: Claude `. +- Work on branch `refactor/a5-logging` (already created; spec is committed there). +- Env var names: `LOGSEQ_LOG_LEVEL` (default `INFO`), `LOGSEQ_LOG_FILE` (unset = no file logging). +- Log format string (must match the current one exactly): `%(asctime)s - %(name)s - %(levelname)s - %(message)s` +- Never crash on bad logging config: invalid level → warn + INFO; unopenable file → warn + stderr only. +- All tests are run with `uv run pytest ...` from the repo root. + +--- + +### Task 1: `_setup_logging()` in the CLI entrypoint + +**Files:** +- Modify: `src/mcp_logseq/__init__.py` (add `_setup_logging()`, call it first in `main()`) +- Test: `tests/unit/test_logging.py` (new file) +- Test (one addition): `tests/unit/test_cli.py` + +**Interfaces:** +- Produces: `mcp_logseq._setup_logging() -> None` — module-level function, no arguments. Reads `LOGSEQ_LOG_LEVEL` and `LOGSEQ_LOG_FILE` from `os.environ`. Configures the **root** logger via `logging.basicConfig(..., force=True)`. `main()` calls it as its first statement. Task 2's subprocess test and Task 4's README docs rely on exactly these names. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/test_logging.py`: + +```python +"""Tests for logging configuration (A5): entrypoint setup, import purity, redaction.""" + +import logging + +import pytest + +import mcp_logseq + + +@pytest.fixture +def clean_root_logger(): + """Snapshot and restore root logger handlers/level around a test. + + _setup_logging() uses basicConfig(force=True), which would otherwise leak + handler changes into the rest of the test session. + """ + root = logging.getLogger() + saved_handlers = root.handlers[:] + saved_level = root.level + yield root + root.handlers[:] = saved_handlers + root.setLevel(saved_level) + + +class TestSetupLogging: + def test_default_level_is_info(self, monkeypatch, clean_root_logger): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.INFO + + def test_level_env_var_honored(self, monkeypatch, clean_root_logger): + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "debug") # case-insensitive + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.DEBUG + + def test_invalid_level_falls_back_to_info(self, monkeypatch, clean_root_logger, capsys): + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "VERBOSE") + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.INFO + # The warning about the bad value goes to the stderr handler just set up. + assert "VERBOSE" in capsys.readouterr().err + + def test_no_file_handler_by_default(self, monkeypatch, clean_root_logger): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert not any( + isinstance(h, logging.FileHandler) for h in clean_root_logger.handlers + ) + + def test_log_file_env_var_adds_file_handler(self, monkeypatch, clean_root_logger, tmp_path): + log_path = tmp_path / "mcp.log" + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.setenv("LOGSEQ_LOG_FILE", str(log_path)) + mcp_logseq._setup_logging() + file_handlers = [ + h for h in clean_root_logger.handlers if isinstance(h, logging.FileHandler) + ] + assert len(file_handlers) == 1 + assert file_handlers[0].baseFilename == str(log_path) + logging.getLogger("mcp-logseq").info("hello file") + for h in file_handlers: + h.close() + assert "hello file" in log_path.read_text() + + def test_unopenable_log_file_degrades_to_stderr(self, monkeypatch, clean_root_logger, tmp_path, capsys): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + # A path whose parent directory does not exist cannot be opened. + monkeypatch.setenv("LOGSEQ_LOG_FILE", str(tmp_path / "no-such-dir" / "mcp.log")) + mcp_logseq._setup_logging() # must not raise + assert not any( + isinstance(h, logging.FileHandler) for h in clean_root_logger.handlers + ) + assert "LOGSEQ_LOG_FILE" in capsys.readouterr().err +``` + +Add to `tests/unit/test_cli.py` (bottom of the file; it already imports `pytest`, `mcp_logseq`, and `parse_args`): + +```python +def test_main_configures_logging_first(monkeypatch): + """main() must call _setup_logging() before dispatching to a transport.""" + order = [] + monkeypatch.setattr(mcp_logseq, "_setup_logging", lambda: order.append("logging")) + monkeypatch.setenv("MCP_HTTP_AUTH_TOKEN", "secret-token") + monkeypatch.setattr( + mcp_logseq, "parse_args", lambda argv=None: parse_args(["--transport", "http"]) + ) + import mcp_logseq.transport.http as http_mod + monkeypatch.setattr(http_mod, "run_http", lambda *a, **k: order.append("run_http")) + + mcp_logseq.main() + + assert order == ["logging", "run_http"] +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `uv run pytest tests/unit/test_logging.py tests/unit/test_cli.py::test_main_configures_logging_first -v` +Expected: all FAIL with `AttributeError: module 'mcp_logseq' has no attribute '_setup_logging'`. + +- [ ] **Step 3: Implement `_setup_logging()` and wire it into `main()`** + +In `src/mcp_logseq/__init__.py`, add above `main()` (module level, after `_validate_http_options`): + +```python +_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + +def _setup_logging(): + """Configure process-wide logging for the CLI entrypoint. + + Level comes from LOGSEQ_LOG_LEVEL (default INFO). Logs go to stderr; + a file handler is added only when LOGSEQ_LOG_FILE is set. Bad config + never crashes the server: it degrades to INFO / stderr-only with a + warning. + """ + import logging + import os + import sys + + level_name = os.environ.get("LOGSEQ_LOG_LEVEL", "INFO").upper() + level = logging.getLevelName(level_name) + invalid_level = not isinstance(level, int) + if invalid_level: + level = logging.INFO + + handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] + log_file = os.environ.get("LOGSEQ_LOG_FILE") + file_error = None + if log_file: + try: + handlers.append(logging.FileHandler(log_file)) + except OSError as e: + file_error = e + + logging.basicConfig(level=level, format=_LOG_FORMAT, handlers=handlers, force=True) + + logger = logging.getLogger("mcp-logseq") + if invalid_level: + logger.warning( + f"Invalid LOGSEQ_LOG_LEVEL {level_name!r}; falling back to INFO" + ) + if file_error is not None: + logger.warning( + f"Could not open LOGSEQ_LOG_FILE {log_file!r}: {file_error}; " + f"logging to stderr only" + ) +``` + +In `main()`, add the call as the first statement: + +```python +def main(): + """Main entry point for the package.""" + import os + + _setup_logging() + args = parse_args() + ... +``` + +Note for the implementer: `logging.getLevelName(name)` returns the numeric level +for a valid name and the string `"Level "` for an invalid one — that is +what the `isinstance(level, int)` check keys off. Do not "simplify" it to +`getattr(logging, level_name)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/unit/test_logging.py tests/unit/test_cli.py -v` +Expected: all PASS (including the pre-existing CLI tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_logseq/__init__.py tests/unit/test_logging.py tests/unit/test_cli.py +git commit -m "feat: configure logging in CLI entrypoint, gated by LOGSEQ_LOG_LEVEL/LOGSEQ_LOG_FILE + +Co-Authored-By: Claude " +``` + +--- + +### Task 2: Remove import-time logging configuration from `server.py` + +**Files:** +- Modify: `src/mcp_logseq/server.py:1-38` (imports and the config block) +- Test: `tests/unit/test_logging.py` (add one test) + +**Interfaces:** +- Consumes: nothing from Task 1 at runtime (the entrypoint now owns configuration). +- Produces: importing `mcp_logseq.server` leaves the root logger and the `mcp-logseq` logger untouched (no handlers added, level unchanged, no file created under `~/.cache/mcp-logseq`). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/test_logging.py` (module level, after the imports; add `import subprocess` and `import sys` to the file's imports): + +```python +def test_importing_server_does_not_configure_logging(): + """Importing mcp_logseq.server must not touch root-logger config (A5). + + Runs in a subprocess because this test process has long since imported + the module and configured logging itself. + """ + code = ( + "import logging, sys\n" + "import mcp_logseq.server\n" + "root = logging.getLogger()\n" + "assert root.level == logging.WARNING, f'root level changed: {root.level}'\n" + "assert root.handlers == [], f'root handlers added: {root.handlers}'\n" + "pkg = logging.getLogger('mcp-logseq')\n" + "assert pkg.handlers == [], f'package handlers added: {pkg.handlers}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/unit/test_logging.py::test_importing_server_does_not_configure_logging -v` +Expected: FAIL — the subprocess assertion on `root.level` fires (import currently calls `basicConfig(level=DEBUG)`). + +- [ ] **Step 3: Delete the import-time configuration** + +In `src/mcp_logseq/server.py`, replace lines 1–38 (everything from `import asyncio` through `load_dotenv()`) with: + +```python +import asyncio +import logging +from collections.abc import Sequence +from typing import Any +from dotenv import load_dotenv +from mcp.server import Server +from mcp.types import ( + Tool, + TextContent, + ImageContent, + EmbeddedResource, +) + +logger = logging.getLogger("mcp-logseq") + +load_dotenv() +``` + +This removes: the `basicConfig(level=DEBUG)` call, the `~/.cache/mcp-logseq` file-handler block, and the now-unused `import sys` and `import os` (verified: nothing else in `server.py` uses `os` or `sys`). + +- [ ] **Step 4: Run the test file and the full unit suite to verify nothing broke** + +Run: `uv run pytest tests/unit/ -v --tb=short` +Expected: all PASS. The existing `caplog` tests (`test_tool_handlers.py`) are unaffected — `caplog` captures independently of handler configuration. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_logseq/server.py tests/unit/test_logging.py +git commit -m "refactor: stop configuring root logger at server.py import time (A5) + +Co-Authored-By: Claude " +``` + +--- + +### Task 3: Redact tool arguments/results in the dispatch choke point + +**Files:** +- Modify: `src/mcp_logseq/server.py` (extract `_dispatch_tool_call`, redact logs) +- Test: `tests/unit/test_logging.py` (add two tests) + +**Interfaces:** +- Consumes: `logger = logging.getLogger("mcp-logseq")` from `server.py`. +- Produces: `async def _dispatch_tool_call(handlers: dict, name: str, arguments: Any) -> Sequence[TextContent | ImageContent | EmbeddedResource]` — module-level coroutine in `mcp_logseq.server`. The `call_tool` closure in `build_app` delegates to it. Behavior (validation order, raised exception types, `asyncio.to_thread` offload) is identical to the current closure body; only the log lines change. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/test_logging.py` (add `import asyncio` to the file's imports, plus `from mcp.types import TextContent`): + +```python +class _FakeHandler: + def run_tool(self, arguments): + return [TextContent(type="text", text="SECRET-RESULT-BODY")] + + +class TestDispatchRedaction: + def test_argument_and_result_bodies_not_logged(self, caplog): + from mcp_logseq.server import _dispatch_tool_call + + with caplog.at_level(logging.DEBUG, logger="mcp-logseq"): + result = asyncio.run( + _dispatch_tool_call( + {"fake_tool": _FakeHandler()}, + "fake_tool", + {"title": "T", "content": "SECRET-ARG-VALUE"}, + ) + ) + + assert len(result) == 1 + # Identifiers are logged... + assert "fake_tool" in caplog.text + assert "content, title" in caplog.text # sorted argument keys + assert "1 content item(s)" in caplog.text + # ...bodies are not. + assert "SECRET-ARG-VALUE" not in caplog.text + assert "SECRET-RESULT-BODY" not in caplog.text + + def test_unknown_tool_still_raises_value_error(self): + from mcp_logseq.server import _dispatch_tool_call + + with pytest.raises(ValueError, match="Unknown tool"): + asyncio.run(_dispatch_tool_call({}, "nope", {})) + + def test_non_dict_arguments_still_raise_runtime_error(self): + from mcp_logseq.server import _dispatch_tool_call + + with pytest.raises(RuntimeError, match="arguments must be dictionary"): + asyncio.run(_dispatch_tool_call({}, "any", "not-a-dict")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/unit/test_logging.py -k Dispatch -v` +Expected: FAIL with `ImportError: cannot import name '_dispatch_tool_call'`. + +- [ ] **Step 3: Extract `_dispatch_tool_call` and redact the log lines** + +In `src/mcp_logseq/server.py`, add a module-level coroutine above `build_app` (after `_register_all_tool_handlers`): + +```python +async def _dispatch_tool_call( + handlers: dict, name: str, arguments: Any +) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Validate and dispatch one tool call. + + Single choke point for tool dispatch: argument/result bodies are + deliberately NOT logged here — only the tool name, the argument keys, + and the result size (A5: page/block content must not reach log files). + """ + 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}") + + logger.info( + f"Tool call: {name} (argument keys: {', '.join(sorted(arguments)) or 'none'})" + ) + try: + result = await asyncio.to_thread(tool_handler.run_tool, arguments) + logger.debug(f"Tool {name} returned {len(result)} content item(s)") + return result + except Exception as e: + logger.error(f"Error running tool: {str(e)}", exc_info=True) + raise RuntimeError(f"Error: {str(e)}") +``` + +Then replace the body of the `call_tool` closure inside `build_app` (currently `server.py:152-175`) with a delegate: + +```python + @server.call_tool() + async def call_tool( + name: str, arguments: Any + ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Handle tool calls.""" + return await _dispatch_tool_call(handlers, name, arguments) +``` + +Note the one deliberate behavior tweak, matching the spec: the old code logged +`Tool call: ... with arguments {arguments}` **before** the isinstance check; +the new code validates first, then logs keys (you cannot take `.keys()` of a +non-dict). Error paths and exception types are otherwise unchanged. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/unit/test_logging.py tests/integration/test_mcp_server.py -v --tb=short` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp_logseq/server.py tests/unit/test_logging.py +git commit -m "refactor: redact tool arguments/results from dispatch logging (A5) + +Co-Authored-By: Claude " +``` + +--- + +### Task 4: Documentation, changelog, and full verification + +**Files:** +- Modify: `README.md:205-214` (Environment Variables section) +- Modify: `CHANGELOG.md` (`[Unreleased]` section) + +**Interfaces:** +- Consumes: the env var names and defaults from Task 1 (`LOGSEQ_LOG_LEVEL` default `INFO`; `LOGSEQ_LOG_FILE` unset by default). + +- [ ] **Step 1: Add the new env vars to README** + +In `README.md`, in the `### Environment Variables` list (after the +`LOGSEQ_API_READ_TIMEOUT` bullet at line 209), add: + +```markdown +- **`LOGSEQ_LOG_LEVEL`** (optional): Log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (default: `INFO`). Logs go to stderr. +- **`LOGSEQ_LOG_FILE`** (optional): Path to a log file. When unset (the default), nothing is written to disk. Note that logs never include page/block content — tool calls are logged with argument names and result sizes only. +``` + +- [ ] **Step 2: Add the changelog entry** + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Changed`, add a bullet after +the existing `verify_ssl` bullet: + +```markdown +- **Potentially breaking:** logging is no longer configured at import time and + the server no longer writes `~/.cache/mcp-logseq/mcp_logseq.log` by default. + The CLI entrypoint now configures stderr logging at `INFO` (was `DEBUG`), + tunable via `LOGSEQ_LOG_LEVEL`; file logging is opt-in via `LOGSEQ_LOG_FILE`. + Tool arguments and results are redacted from logs — only tool names, argument + keys, and result sizes are recorded, so page/block content (including + ACL-gated pages) no longer lands in plaintext logs +``` + +- [ ] **Step 3: Run the full test suite** + +Run: `uv run pytest` +Expected: all tests PASS (was 630 + the ~11 new ones from Tasks 1–3). + +- [ ] **Step 4: Commit** + +```bash +git add README.md CHANGELOG.md +git commit -m "docs: document LOGSEQ_LOG_LEVEL/LOGSEQ_LOG_FILE and A5 logging changes + +Co-Authored-By: Claude " +``` From 5e1f1c35d2a5b3075468c26fd8f2c8885ab1c2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:16:36 +0300 Subject: [PATCH 3/8] feat: configure logging in CLI entrypoint, gated by LOGSEQ_LOG_LEVEL/LOGSEQ_LOG_FILE Co-Authored-By: Claude --- src/mcp_logseq/__init__.py | 45 ++++++++++++++++++++++ tests/unit/test_cli.py | 16 ++++++++ tests/unit/test_logging.py | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/unit/test_logging.py diff --git a/src/mcp_logseq/__init__.py b/src/mcp_logseq/__init__.py index cc56cd7..efdd503 100644 --- a/src/mcp_logseq/__init__.py +++ b/src/mcp_logseq/__init__.py @@ -53,10 +53,55 @@ def _validate_http_options(args) -> None: ) +_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + +def _setup_logging(): + """Configure process-wide logging for the CLI entrypoint. + + Level comes from LOGSEQ_LOG_LEVEL (default INFO). Logs go to stderr; + a file handler is added only when LOGSEQ_LOG_FILE is set. Bad config + never crashes the server: it degrades to INFO / stderr-only with a + warning. + """ + import logging + import os + import sys + + level_name = os.environ.get("LOGSEQ_LOG_LEVEL", "INFO").upper() + level = logging.getLevelName(level_name) + invalid_level = not isinstance(level, int) + if invalid_level: + level = logging.INFO + + handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] + log_file = os.environ.get("LOGSEQ_LOG_FILE") + file_error = None + if log_file: + try: + handlers.append(logging.FileHandler(log_file)) + except OSError as e: + file_error = e + + logging.basicConfig(level=level, format=_LOG_FORMAT, handlers=handlers, force=True) + + logger = logging.getLogger("mcp-logseq") + if invalid_level: + logger.warning( + f"Invalid LOGSEQ_LOG_LEVEL {level_name!r}; falling back to INFO" + ) + if file_error is not None: + logger.warning( + f"Could not open LOGSEQ_LOG_FILE {log_file!r}: {file_error}; " + f"logging to stderr only" + ) + + def main(): """Main entry point for the package.""" import os + _setup_logging() args = parse_args() if args.transport == "stdio": import asyncio diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 8e9a7dc..62b541f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -177,3 +177,19 @@ def test_run_http_no_tls_passes_none(monkeypatch): http_mod.run_http("127.0.0.1", 12320, "tok") assert calls.get("ssl_certfile") is None assert calls.get("ssl_keyfile") is None + + +def test_main_configures_logging_first(monkeypatch): + """main() must call _setup_logging() before dispatching to a transport.""" + order = [] + monkeypatch.setattr(mcp_logseq, "_setup_logging", lambda: order.append("logging")) + monkeypatch.setenv("MCP_HTTP_AUTH_TOKEN", "secret-token") + monkeypatch.setattr( + mcp_logseq, "parse_args", lambda argv=None: parse_args(["--transport", "http"]) + ) + import mcp_logseq.transport.http as http_mod + monkeypatch.setattr(http_mod, "run_http", lambda *a, **k: order.append("run_http")) + + mcp_logseq.main() + + assert order == ["logging", "run_http"] diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..87f225f --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,77 @@ +"""Tests for logging configuration (A5): entrypoint setup, import purity, redaction.""" + +import logging + +import pytest + +import mcp_logseq + + +@pytest.fixture +def clean_root_logger(): + """Snapshot and restore root logger handlers/level around a test. + + _setup_logging() uses basicConfig(force=True), which would otherwise leak + handler changes into the rest of the test session. + """ + root = logging.getLogger() + saved_handlers = root.handlers[:] + saved_level = root.level + yield root + root.handlers[:] = saved_handlers + root.setLevel(saved_level) + + +class TestSetupLogging: + def test_default_level_is_info(self, monkeypatch, clean_root_logger): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.INFO + + def test_level_env_var_honored(self, monkeypatch, clean_root_logger): + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "debug") # case-insensitive + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.DEBUG + + def test_invalid_level_falls_back_to_info(self, monkeypatch, clean_root_logger, capsys): + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "VERBOSE") + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert clean_root_logger.level == logging.INFO + # The warning about the bad value goes to the stderr handler just set up. + assert "VERBOSE" in capsys.readouterr().err + + def test_no_file_handler_by_default(self, monkeypatch, clean_root_logger): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert not any( + isinstance(h, logging.FileHandler) for h in clean_root_logger.handlers + ) + + def test_log_file_env_var_adds_file_handler(self, monkeypatch, clean_root_logger, tmp_path): + log_path = tmp_path / "mcp.log" + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.setenv("LOGSEQ_LOG_FILE", str(log_path)) + mcp_logseq._setup_logging() + file_handlers = [ + h for h in clean_root_logger.handlers if isinstance(h, logging.FileHandler) + ] + assert len(file_handlers) == 1 + assert file_handlers[0].baseFilename == str(log_path) + logging.getLogger("mcp-logseq").info("hello file") + for h in file_handlers: + h.close() + assert "hello file" in log_path.read_text() + + def test_unopenable_log_file_degrades_to_stderr(self, monkeypatch, clean_root_logger, tmp_path, capsys): + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + # A path whose parent directory does not exist cannot be opened. + monkeypatch.setenv("LOGSEQ_LOG_FILE", str(tmp_path / "no-such-dir" / "mcp.log")) + mcp_logseq._setup_logging() # must not raise + assert not any( + isinstance(h, logging.FileHandler) for h in clean_root_logger.handlers + ) + assert "LOGSEQ_LOG_FILE" in capsys.readouterr().err From f9292162b61534a648af37d7c25cebdadc6b51a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:20:36 +0300 Subject: [PATCH 4/8] refactor: stop configuring root logger at server.py import time (A5) Co-Authored-By: Claude --- src/mcp_logseq/server.py | 22 ---------------------- tests/unit/test_logging.py | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/mcp_logseq/server.py b/src/mcp_logseq/server.py index 25f2904..347e81e 100644 --- a/src/mcp_logseq/server.py +++ b/src/mcp_logseq/server.py @@ -1,9 +1,7 @@ import asyncio import logging -import sys from collections.abc import Sequence from typing import Any -import os from dotenv import load_dotenv from mcp.server import Server from mcp.types import ( @@ -13,28 +11,8 @@ EmbeddedResource, ) -# Configure logging to stderr with more verbose output -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, -) logger = logging.getLogger("mcp-logseq") -# Add a file handler to keep logs (in user's home directory to avoid permission issues) -log_dir = os.path.expanduser("~/.cache/mcp-logseq") -os.makedirs(log_dir, exist_ok=True) -log_file = os.path.join(log_dir, "mcp_logseq.log") -try: - file_handler = logging.FileHandler(log_file) - file_handler.setLevel(logging.DEBUG) - logger.addHandler(file_handler) - logger.debug(f"Logging to: {log_file}") -except Exception as e: - # If file logging fails, continue without it - logger.warning(f"Could not setup file logging: {e}") - pass - load_dotenv() from . import tools diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 87f225f..2871026 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -1,12 +1,35 @@ """Tests for logging configuration (A5): entrypoint setup, import purity, redaction.""" import logging +import subprocess +import sys import pytest import mcp_logseq +def test_importing_server_does_not_configure_logging(): + """Importing mcp_logseq.server must not touch root-logger config (A5). + + Runs in a subprocess because this test process has long since imported + the module and configured logging itself. + """ + code = ( + "import logging, sys\n" + "import mcp_logseq.server\n" + "root = logging.getLogger()\n" + "assert root.level == logging.WARNING, f'root level changed: {root.level}'\n" + "assert root.handlers == [], f'root handlers added: {root.handlers}'\n" + "pkg = logging.getLogger('mcp-logseq')\n" + "assert pkg.handlers == [], f'package handlers added: {pkg.handlers}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + + @pytest.fixture def clean_root_logger(): """Snapshot and restore root logger handlers/level around a test. From 729b4aefd2c45c7c6ef61ecdd46d9044577ceb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:24:16 +0300 Subject: [PATCH 5/8] refactor: redact tool arguments/results from dispatch logging (A5) Co-Authored-By: Claude --- src/mcp_logseq/server.py | 50 +++++++++++++++++++++++--------------- tests/unit/test_logging.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/mcp_logseq/server.py b/src/mcp_logseq/server.py index 347e81e..d300fd5 100644 --- a/src/mcp_logseq/server.py +++ b/src/mcp_logseq/server.py @@ -103,6 +103,36 @@ def add(tool_class: tools.ToolHandler) -> None: logger.warning(f"Could not load vector config, vector tools disabled: {e}") +async def _dispatch_tool_call( + handlers: dict, name: str, arguments: Any +) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Validate and dispatch one tool call. + + Single choke point for tool dispatch: argument/result bodies are + deliberately NOT logged here — only the tool name, the argument keys, + and the result size (A5: page/block content must not reach log files). + """ + 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}") + + logger.info( + f"Tool call: {name} (argument keys: {', '.join(sorted(arguments)) or 'none'})" + ) + try: + result = await asyncio.to_thread(tool_handler.run_tool, arguments) + logger.debug(f"Tool {name} returned {len(result)} content item(s)") + return result + except Exception as e: + logger.error(f"Error running tool: {str(e)}", exc_info=True) + raise RuntimeError(f"Error: {str(e)}") + + def build_app(read_only: bool = False) -> tuple[Server, dict]: """Build a fully wired MCP ``Server`` plus its tool-handler registry. @@ -132,25 +162,7 @@ async def call_tool( name: str, arguments: Any ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """Handle tool calls.""" - 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}") - - 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 - except Exception as e: - logger.error(f"Error running tool: {str(e)}", exc_info=True) - raise RuntimeError(f"Error: {str(e)}") + return await _dispatch_tool_call(handlers, name, arguments) return server, handlers diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 2871026..cfbe0e8 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -1,10 +1,12 @@ """Tests for logging configuration (A5): entrypoint setup, import purity, redaction.""" +import asyncio import logging import subprocess import sys import pytest +from mcp.types import TextContent import mcp_logseq @@ -98,3 +100,43 @@ def test_unopenable_log_file_degrades_to_stderr(self, monkeypatch, clean_root_lo isinstance(h, logging.FileHandler) for h in clean_root_logger.handlers ) assert "LOGSEQ_LOG_FILE" in capsys.readouterr().err + + +class _FakeHandler: + def run_tool(self, arguments): + return [TextContent(type="text", text="SECRET-RESULT-BODY")] + + +class TestDispatchRedaction: + def test_argument_and_result_bodies_not_logged(self, caplog): + from mcp_logseq.server import _dispatch_tool_call + + with caplog.at_level(logging.DEBUG, logger="mcp-logseq"): + result = asyncio.run( + _dispatch_tool_call( + {"fake_tool": _FakeHandler()}, + "fake_tool", + {"title": "T", "content": "SECRET-ARG-VALUE"}, + ) + ) + + assert len(result) == 1 + # Identifiers are logged... + assert "fake_tool" in caplog.text + assert "content, title" in caplog.text # sorted argument keys + assert "1 content item(s)" in caplog.text + # ...bodies are not. + assert "SECRET-ARG-VALUE" not in caplog.text + assert "SECRET-RESULT-BODY" not in caplog.text + + def test_unknown_tool_still_raises_value_error(self): + from mcp_logseq.server import _dispatch_tool_call + + with pytest.raises(ValueError, match="Unknown tool"): + asyncio.run(_dispatch_tool_call({}, "nope", {})) + + def test_non_dict_arguments_still_raise_runtime_error(self): + from mcp_logseq.server import _dispatch_tool_call + + with pytest.raises(RuntimeError, match="arguments must be dictionary"): + asyncio.run(_dispatch_tool_call({}, "any", "not-a-dict")) From 7e8d045072511a6e9a308f71553eacb173dd2309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:28:01 +0300 Subject: [PATCH 6/8] docs: document LOGSEQ_LOG_LEVEL/LOGSEQ_LOG_FILE and A5 logging changes Co-Authored-By: Claude --- CHANGELOG.md | 7 +++++++ README.md | 2 ++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd186e..198e1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (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) +- **Potentially breaking:** logging is no longer configured at import time and + the server no longer writes `~/.cache/mcp-logseq/mcp_logseq.log` by default. + The CLI entrypoint now configures stderr logging at `INFO` (was `DEBUG`), + tunable via `LOGSEQ_LOG_LEVEL`; file logging is opt-in via `LOGSEQ_LOG_FILE`. + Tool arguments and results are redacted from logs — only tool names, argument + keys, and result sizes are recorded, so page/block content (including + ACL-gated pages) no longer lands in plaintext logs ### Internal diff --git a/README.md b/README.md index 02ad074..0ee18f1 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ If you hit the "already exists" error mid-ingest, use `get_page_content` to see - **`LOGSEQ_API_URL`** (optional): Server URL (default: `http://localhost:12315`) - **`LOGSEQ_API_CONNECT_TIMEOUT`** (optional): HTTP connect timeout in seconds (default: `3`) - **`LOGSEQ_API_READ_TIMEOUT`** (optional): HTTP read timeout in seconds (default: `6`) +- **`LOGSEQ_LOG_LEVEL`** (optional): Log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (default: `INFO`). Logs go to stderr. +- **`LOGSEQ_LOG_FILE`** (optional): Path to a log file. When unset (the default), nothing is written to disk. Tool calls are logged with argument names and result sizes only, so page/block content does not appear in routine logs. - **`LOGSEQ_DB_MODE`** (optional): Set to `true` to enable DB-mode property support. Only for Logseq DB-mode graphs (beta). Markdown/file-based graph users should leave this unset. - **`LOGSEQ_EXCLUDE_TAGS`** (optional): Comma-separated tags — pages with these tags are hidden from all tools. See [Privacy & Access Control](#-privacy--access-control) below. - **`LOGSEQ_INCLUDE_NAMESPACES`** (optional): Comma-separated namespace allow-list (e.g. `work,projects`). When set, **only** pages in these namespaces and their sub-pages are accessible — everything else, including top-level pages without a namespace, is hidden from listings/search and denied on direct access. See [Privacy & Access Control](#-privacy--access-control) below. From 8826104e7c3afe0f3d4eaa05daee252bc47f1b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:36:01 +0300 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20address=20final=20review=20=E2=80=94?= =?UTF-8?q?=20no=20property=20values=20in=20logs,=20cap=20MCP=20SDK=20logg?= =?UTF-8?q?er=20at=20DEBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- src/mcp_logseq/__init__.py | 9 +++++++-- src/mcp_logseq/logseq.py | 2 +- tests/unit/test_logging.py | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/mcp_logseq/__init__.py b/src/mcp_logseq/__init__.py index efdd503..8d2d956 100644 --- a/src/mcp_logseq/__init__.py +++ b/src/mcp_logseq/__init__.py @@ -56,7 +56,7 @@ def _validate_http_options(args) -> None: _LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -def _setup_logging(): +def _setup_logging() -> None: """Configure process-wide logging for the CLI entrypoint. Level comes from LOGSEQ_LOG_LEVEL (default INFO). Logs go to stderr; @@ -68,7 +68,7 @@ def _setup_logging(): import os import sys - level_name = os.environ.get("LOGSEQ_LOG_LEVEL", "INFO").upper() + level_name = (os.environ.get("LOGSEQ_LOG_LEVEL") or "INFO").upper() level = logging.getLevelName(level_name) invalid_level = not isinstance(level, int) if invalid_level: @@ -85,6 +85,11 @@ def _setup_logging(): logging.basicConfig(level=level, format=_LOG_FORMAT, handlers=handlers, force=True) + if level < logging.INFO: + # At DEBUG the MCP SDK logs entire inbound JSON-RPC requests (tool + # arguments included); cap it so redaction survives verbose mode. + logging.getLogger("mcp").setLevel(logging.INFO) + logger = logging.getLogger("mcp-logseq") if invalid_level: logger.warning( diff --git a/src/mcp_logseq/logseq.py b/src/mcp_logseq/logseq.py index d66772c..84d4d1b 100644 --- a/src/mcp_logseq/logseq.py +++ b/src/mcp_logseq/logseq.py @@ -324,7 +324,7 @@ def create_page_with_blocks( # Insert all blocks as siblings after the first block self.insert_batch_block(first_block_uuid, blocks, sibling=True) - logger.info(f"api_props={api_props!r}, will delete first block: {not api_props}") + logger.info(f"api_props keys={sorted(api_props)}, will delete first block: {not api_props}") if not api_props: # No properties — remove the empty placeholder block self.delete_block(first_block_uuid) diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index cfbe0e8..61c5588 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -101,6 +101,32 @@ def test_unopenable_log_file_degrades_to_stderr(self, monkeypatch, clean_root_lo ) assert "LOGSEQ_LOG_FILE" in capsys.readouterr().err + def test_debug_level_caps_mcp_sdk_logger(self, monkeypatch, clean_root_logger): + mcp_logger = logging.getLogger("mcp") + saved = mcp_logger.level + try: + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "DEBUG") + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert mcp_logger.level == logging.INFO + finally: + mcp_logger.setLevel(saved) + + def test_info_level_leaves_mcp_sdk_logger_alone(self, monkeypatch, clean_root_logger): + mcp_logger = logging.getLogger("mcp") + saved = mcp_logger.level + try: + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + # Assert against the saved value rather than NOTSET: an earlier + # test in the same session may have already set the "mcp" + # logger's level (e.g. via a DEBUG-level run), and this test + # only needs to prove _setup_logging() didn't touch it at INFO. + assert mcp_logger.level == saved + finally: + mcp_logger.setLevel(saved) + class _FakeHandler: def run_tool(self, arguments): From b9e9f4b4109e0ee768366fd9f9e168262d9ea761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salih=20Erg=C3=BCt?= Date: Sun, 19 Jul 2026 07:40:02 +0300 Subject: [PATCH 8/8] chore: deterministic mcp-logger tests, document DEBUG-mode SDK cap Co-Authored-By: Claude --- README.md | 2 +- tests/unit/test_logging.py | 36 +++++++++++++++--------------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0ee18f1..adeb836 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ If you hit the "already exists" error mid-ingest, use `get_page_content` to see - **`LOGSEQ_API_URL`** (optional): Server URL (default: `http://localhost:12315`) - **`LOGSEQ_API_CONNECT_TIMEOUT`** (optional): HTTP connect timeout in seconds (default: `3`) - **`LOGSEQ_API_READ_TIMEOUT`** (optional): HTTP read timeout in seconds (default: `6`) -- **`LOGSEQ_LOG_LEVEL`** (optional): Log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (default: `INFO`). Logs go to stderr. +- **`LOGSEQ_LOG_LEVEL`** (optional): Log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (default: `INFO`). Logs go to stderr. At `DEBUG`, the MCP SDK's own logger stays capped at `INFO` so full request payloads are not logged. - **`LOGSEQ_LOG_FILE`** (optional): Path to a log file. When unset (the default), nothing is written to disk. Tool calls are logged with argument names and result sizes only, so page/block content does not appear in routine logs. - **`LOGSEQ_DB_MODE`** (optional): Set to `true` to enable DB-mode property support. Only for Logseq DB-mode graphs (beta). Markdown/file-based graph users should leave this unset. - **`LOGSEQ_EXCLUDE_TAGS`** (optional): Comma-separated tags — pages with these tags are hidden from all tools. See [Privacy & Access Control](#-privacy--access-control) below. diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 61c5588..0157031 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -37,14 +37,19 @@ def clean_root_logger(): """Snapshot and restore root logger handlers/level around a test. _setup_logging() uses basicConfig(force=True), which would otherwise leak - handler changes into the rest of the test session. + handler changes into the rest of the test session. Also snapshots and + restores the "mcp" SDK logger's level, since _setup_logging() may cap it + at INFO as a side effect and that would otherwise leak between tests. """ root = logging.getLogger() saved_handlers = root.handlers[:] saved_level = root.level + mcp_logger = logging.getLogger("mcp") + saved_mcp_level = mcp_logger.level yield root root.handlers[:] = saved_handlers root.setLevel(saved_level) + mcp_logger.setLevel(saved_mcp_level) class TestSetupLogging: @@ -103,29 +108,18 @@ def test_unopenable_log_file_degrades_to_stderr(self, monkeypatch, clean_root_lo def test_debug_level_caps_mcp_sdk_logger(self, monkeypatch, clean_root_logger): mcp_logger = logging.getLogger("mcp") - saved = mcp_logger.level - try: - monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "DEBUG") - monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) - mcp_logseq._setup_logging() - assert mcp_logger.level == logging.INFO - finally: - mcp_logger.setLevel(saved) + monkeypatch.setenv("LOGSEQ_LOG_LEVEL", "DEBUG") + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert mcp_logger.level == logging.INFO def test_info_level_leaves_mcp_sdk_logger_alone(self, monkeypatch, clean_root_logger): mcp_logger = logging.getLogger("mcp") - saved = mcp_logger.level - try: - monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) - monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) - mcp_logseq._setup_logging() - # Assert against the saved value rather than NOTSET: an earlier - # test in the same session may have already set the "mcp" - # logger's level (e.g. via a DEBUG-level run), and this test - # only needs to prove _setup_logging() didn't touch it at INFO. - assert mcp_logger.level == saved - finally: - mcp_logger.setLevel(saved) + mcp_logger.setLevel(logging.NOTSET) + monkeypatch.delenv("LOGSEQ_LOG_LEVEL", raising=False) + monkeypatch.delenv("LOGSEQ_LOG_FILE", raising=False) + mcp_logseq._setup_logging() + assert mcp_logger.level == logging.NOTSET class _FakeHandler: