From 29be0299f012b7d143ab3622cd89d50133218aa5 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 01:10:21 -0500 Subject: [PATCH] feat(mcp): add basic_memory_diagnostics tool for version and system info Adds a new read-only MCP tool `basic_memory_diagnostics` that surfaces: - Basic Memory package version and API version (from __init__.py) - Python version and platform/architecture details - Config file path and full config.json contents (cloud_api_key redacted) Resolves #187. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/mcp/tools/__init__.py | 4 + .../mcp/tools/basic_memory_diagnostics.py | 84 ++++++++++ .../mcp/test_tool_basic_memory_diagnostics.py | 146 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/basic_memory/mcp/tools/basic_memory_diagnostics.py create mode 100644 tests/mcp/test_tool_basic_memory_diagnostics.py diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 66e93125..a46cdb37 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -36,7 +36,11 @@ from basic_memory.mcp.tools.chatgpt_tools import search, fetch # Schema tools from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff +# Diagnostics tool +from basic_memory.mcp.tools.basic_memory_diagnostics import basic_memory_diagnostics + __all__ = [ + "basic_memory_diagnostics", "build_context", "canvas", "cloud_info", diff --git a/src/basic_memory/mcp/tools/basic_memory_diagnostics.py b/src/basic_memory/mcp/tools/basic_memory_diagnostics.py new file mode 100644 index 00000000..5c1a1e0e --- /dev/null +++ b/src/basic_memory/mcp/tools/basic_memory_diagnostics.py @@ -0,0 +1,84 @@ +"""Diagnostic tool for Basic Memory version and system information.""" + +import json +import platform +import sys + +import basic_memory +from basic_memory.config import CONFIG_FILE_NAME, ConfigManager +from basic_memory.mcp.server import mcp + +# Fields in BasicMemoryConfig that contain secrets and must never be surfaced. +_SECRET_FIELDS = frozenset({"cloud_api_key"}) + + +def _redact_config(raw: dict) -> dict: + """Return a copy of the raw config dict with secret fields removed. + + Only top-level keys are redacted. Nested secret-looking keys within + project entries are not currently present, but the pattern is explicit + so it is easy to extend. + """ + return {k: v for k, v in raw.items() if k not in _SECRET_FIELDS} + + +@mcp.tool( + "basic_memory_diagnostics", + annotations={"readOnlyHint": True, "openWorldHint": False}, +) +def basic_memory_diagnostics() -> str: + """Return version, system, and configuration diagnostics for Basic Memory. + + Provides: + - Basic Memory package version and API version + - Python version and platform details + - Config file path and its contents (secrets redacted) + + Useful for troubleshooting installations and gathering information for + support requests. Read-only; never emits secrets or API keys. + """ + # --- Version information --- + bm_version = basic_memory.__version__ + api_version = basic_memory.__api_version__ + + # --- System information --- + python_version = sys.version + platform_info = platform.platform() + machine = platform.machine() + + # --- Configuration --- + manager = ConfigManager() + config_file = manager.config_dir / CONFIG_FILE_NAME + config_exists = config_file.exists() + + if config_exists: + try: + raw_config = json.loads(config_file.read_text(encoding="utf-8")) + safe_config = _redact_config(raw_config) + config_dump = json.dumps(safe_config, indent=2, default=str) + except Exception as exc: # pragma: no cover + config_dump = f"" + else: + config_dump = "" + + lines = [ + "# Basic Memory Diagnostics", + "", + "## Version", + f"- basic-memory: {bm_version}", + f"- API version: {api_version}", + "", + "## System", + f"- Python: {python_version}", + f"- Platform: {platform_info}", + f"- Architecture: {machine}", + "", + "## Configuration", + f"- Config path: {config_file}", + f"- Config exists: {config_exists}", + "", + "```json", + config_dump, + "```", + ] + return "\n".join(lines) diff --git a/tests/mcp/test_tool_basic_memory_diagnostics.py b/tests/mcp/test_tool_basic_memory_diagnostics.py new file mode 100644 index 00000000..8a144510 --- /dev/null +++ b/tests/mcp/test_tool_basic_memory_diagnostics.py @@ -0,0 +1,146 @@ +"""Tests for the basic_memory_diagnostics MCP tool.""" + +import json +import platform +import sys +from unittest.mock import MagicMock, patch + +import basic_memory +from basic_memory.mcp.tools.basic_memory_diagnostics import ( + _redact_config, + basic_memory_diagnostics, +) + + +# --------------------------------------------------------------------------- +# Unit tests for _redact_config helper +# --------------------------------------------------------------------------- + + +def test_redact_config_removes_cloud_api_key(): + raw = {"cloud_api_key": "bmc_secret", "default_project": "main", "projects": {}} + result = _redact_config(raw) + assert "cloud_api_key" not in result + assert result["default_project"] == "main" + assert "projects" in result + + +def test_redact_config_passes_through_safe_fields(): + raw = {"default_project": "main", "log_level": "INFO", "env": "dev"} + result = _redact_config(raw) + assert result == raw + + +def test_redact_config_empty_dict(): + assert _redact_config({}) == {} + + +# --------------------------------------------------------------------------- +# Tests for the basic_memory_diagnostics tool +# --------------------------------------------------------------------------- + + +def test_diagnostics_returns_string(): + result = basic_memory_diagnostics() + assert isinstance(result, str) + + +def test_diagnostics_includes_version(): + result = basic_memory_diagnostics() + assert basic_memory.__version__ in result + assert basic_memory.__api_version__ in result + + +def test_diagnostics_includes_python_version(): + result = basic_memory_diagnostics() + # sys.version can be multi-line; just check the version tuple prefix + major_minor = f"{sys.version_info.major}.{sys.version_info.minor}" + assert major_minor in result + + +def test_diagnostics_includes_platform(): + result = basic_memory_diagnostics() + assert platform.machine() in result + + +def test_diagnostics_includes_config_path(tmp_path): + """Config path section should appear in output.""" + with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr: + mock_mgr = MagicMock() + mock_mgr.config_dir = tmp_path + MockMgr.return_value = mock_mgr + + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"default_project": "main", "projects": {}})) + + result = basic_memory_diagnostics() + + assert str(tmp_path) in result + assert "Config path:" in result + + +def test_diagnostics_config_exists_with_valid_json(tmp_path): + """When config file exists, its safe contents should appear as JSON.""" + config_data = { + "default_project": "research", + "projects": {"research": {"path": str(tmp_path / "research")}}, + } + with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr: + mock_mgr = MagicMock() + mock_mgr.config_dir = tmp_path + MockMgr.return_value = mock_mgr + + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + + result = basic_memory_diagnostics() + + assert "research" in result + assert "```json" in result + + +def test_diagnostics_redacts_cloud_api_key(tmp_path): + """cloud_api_key must never appear in diagnostic output.""" + config_data = { + "default_project": "main", + "cloud_api_key": "bmc_super_secret_token", + "projects": {}, + } + with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr: + mock_mgr = MagicMock() + mock_mgr.config_dir = tmp_path + MockMgr.return_value = mock_mgr + + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + + result = basic_memory_diagnostics() + + assert "bmc_super_secret_token" not in result + assert "cloud_api_key" not in result + + +def test_diagnostics_config_missing(tmp_path): + """When config file does not exist, output should say so.""" + with patch("basic_memory.mcp.tools.basic_memory_diagnostics.ConfigManager") as MockMgr: + mock_mgr = MagicMock() + mock_mgr.config_dir = tmp_path + MockMgr.return_value = mock_mgr + + # Ensure no config.json is present + config_file = tmp_path / "config.json" + assert not config_file.exists() + + result = basic_memory_diagnostics() + + assert "Config exists: False" in result + assert "" in result + + +def test_diagnostics_output_sections(): + """All expected section headers should be present.""" + result = basic_memory_diagnostics() + assert "# Basic Memory Diagnostics" in result + assert "## Version" in result + assert "## System" in result + assert "## Configuration" in result