diff --git a/src/basic_memory/__init__.py b/src/basic_memory/__init__.py index 4b753b1ad..10bd93938 100644 --- a/src/basic_memory/__init__.py +++ b/src/basic_memory/__init__.py @@ -4,4 +4,4 @@ __version__ = "0.22.1" # API version for FastAPI - independent of package version -__api_version__ = "v0" +__api_version__ = "v2" diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 66e931253..12211c3b3 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -6,6 +6,7 @@ """ # Import tools to register them with MCP +from basic_memory.mcp.tools.basic_memory_diagnostics import basic_memory_diagnostics from basic_memory.mcp.tools.delete_note import delete_note from basic_memory.mcp.tools.read_content import read_content from basic_memory.mcp.tools.build_context import build_context @@ -37,6 +38,7 @@ from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff __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 000000000..b6b658d04 --- /dev/null +++ b/src/basic_memory/mcp/tools/basic_memory_diagnostics.py @@ -0,0 +1,182 @@ +"""Diagnostic tool for Basic Memory version and system information.""" + +import json +import platform +import sys +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +import basic_memory +from basic_memory.config import CONFIG_FILE_NAME, resolve_data_dir +from basic_memory.mcp.server import mcp + +# Fields in BasicMemoryConfig that contain secrets and must never be surfaced. +_SECRET_FIELDS = frozenset({"cloud_api_key"}) + +# Fields whose values are URLs that may embed user:password credentials. +# The userinfo component is stripped before surfacing. +_URL_FIELDS = frozenset({"database_url"}) + +_SECRET_QUERY_KEYS = frozenset( + { + "api_key", + "apikey", + "credential", + "password", + "passwd", + "pwd", + "secret", + "secret_key", + "sslpassword", + "token", + } +) + + +def _query_key_is_secret(key: str) -> bool: + normalized = key.strip().lower().replace("-", "_") + return normalized in _SECRET_QUERY_KEYS or normalized.endswith( + ("_password", "_secret", "_token", "_key") + ) + + +def _redact_query_secrets(query: str) -> str: + """Mask credential-bearing query values while preserving diagnostic options.""" + pairs = parse_qsl(query, keep_blank_values=True) + if not any(_query_key_is_secret(key) for key, _ in pairs): + return query + return urlencode([(key, "***" if _query_key_is_secret(key) else value) for key, value in pairs]) + + +def _redact_url(url: str) -> str: + """Strip userinfo and credential-bearing query values from a URL string. + + Replaces any credentials with *** so the host/path remain visible for + diagnostics (e.g. ``postgresql://***@localhost/mydb``). If the value + cannot be parsed as a URL it is returned unchanged. + """ + try: + parsed = urlparse(url) + except ValueError: + # A malformed authority can still contain credentials. Redact the + # userinfo and query conservatively rather than returning a secret unchanged. + base, query_separator, query = url.partition("?") + safe_url = f"{base}?{_redact_query_secrets(query)}" if query_separator else base + scheme, separator, remainder = safe_url.partition("://") + if separator and "@" in remainder: + _, _, authority = remainder.rpartition("@") + return f"{scheme}://***@{authority}" + return safe_url + + redacted_query = _redact_query_secrets(parsed.query) + if "@" not in parsed.netloc and redacted_query == parsed.query: + # Neither URL userinfo nor known secret query parameters are present. + return url + + redacted_netloc = parsed.netloc + if "@" in parsed.netloc: + # Preserve the authority verbatim after the final @. In particular, using + # parsed.hostname here would discard the brackets required around IPv6 hosts. + _, _, authority = parsed.netloc.rpartition("@") + redacted_netloc = f"***@{authority}" + + return urlunparse(parsed._replace(netloc=redacted_netloc, query=redacted_query)) + + +def _redact_config(raw: dict) -> dict: + """Return a copy of the raw config dict with secret fields removed. + + - Keys in ``_SECRET_FIELDS`` are dropped entirely. + - Keys in ``_URL_FIELDS`` have userinfo and credential-bearing query values + stripped so that safe host, database, and connection options remain visible. + + Only top-level keys are processed. Nested keys within project entries are + not currently credential-bearing, but the two sets make the pattern easy + to extend. + """ + result: dict = {} + for k, v in raw.items(): + if k in _SECRET_FIELDS: + # Drop entirely — value has no diagnostic value. + continue + if k in _URL_FIELDS and isinstance(v, str): + result[k] = _redact_url(v) + else: + result[k] = v + return result + + +@mcp.tool( + "basic_memory_diagnostics", + title="Basic Memory Diagnostics", + tags={"diagnostics"}, + annotations={ + "title": "Basic Memory Diagnostics", + "readOnlyHint": True, + "destructiveHint": False, + "openWorldHint": False, + }, + # The report is a markdown string; suppress FastMCP's wrap_result so the + # payload isn't duplicated into structuredContent. + output_schema=None, +) +def basic_memory_diagnostics() -> str: + """Return version, system, and configuration diagnostics for Basic Memory. + + Provides: + - Basic Memory package 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 --- + # resolve_data_dir only computes the path. ConfigManager would create and + # chmod the directory, violating this tool's read-only contract. + config_file = resolve_data_dir() / CONFIG_FILE_NAME + config_exists = config_file.exists() + + if config_exists: + try: + raw_config = json.loads(config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + config_dump = f"" + else: + if isinstance(raw_config, dict): + safe_config = _redact_config(raw_config) + config_dump = json.dumps(safe_config, indent=2, default=str) + else: + config_dump = "" + else: + config_dump = "" + + lines = [ + "# Basic Memory Diagnostics", + "", + "## Version", + f"- basic-memory: {bm_version}", + f"- API: {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 000000000..a5b7c5f94 --- /dev/null +++ b/tests/mcp/test_tool_basic_memory_diagnostics.py @@ -0,0 +1,341 @@ +"""Tests for the basic_memory_diagnostics MCP tool.""" + +import json +import platform +import sys +from pathlib import Path + +import basic_memory +import pytest +from basic_memory.mcp.tools.basic_memory_diagnostics import ( + _redact_config, + _redact_url, + basic_memory_diagnostics, +) + + +@pytest.fixture(autouse=True) +def isolate_config_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Keep every diagnostics test isolated from the developer's real config.""" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path)) + + +# --------------------------------------------------------------------------- +# 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__ == "v2" + assert f"API: {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.""" + 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")}}, + } + 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": {}, + } + 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.""" + 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_does_not_create_config_directory(monkeypatch, tmp_path): + """Resolving diagnostics must remain read-only when no config exists.""" + missing_config_dir = tmp_path / "does-not-exist" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(missing_config_dir)) + + result = basic_memory_diagnostics() + + assert "Config exists: False" in result + assert not missing_config_dir.exists() + + +def test_diagnostics_reports_invalid_json(tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text("{not valid json", encoding="utf-8") + + result = basic_memory_diagnostics() + + assert "" in result + + +def test_diagnostics_reports_config_read_error(monkeypatch, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text("{}", encoding="utf-8") + + def fail_read_text(self, *args, **kwargs): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", fail_read_text) + + result = basic_memory_diagnostics() + + 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 + + +# --------------------------------------------------------------------------- +# Unit tests for _redact_url helper +# --------------------------------------------------------------------------- + + +def test_redact_url_strips_password(): + url = "postgresql://user:secret@localhost/mydb" + result = _redact_url(url) + assert "secret" not in result + assert "user" not in result + assert "localhost" in result + assert "mydb" in result + assert "***" in result + + +def test_redact_url_strips_only_password_when_no_username(): + # password-only userinfo (unusual but valid per RFC) + url = "postgresql://:secret@db.example.com/app" + assert _redact_url(url) == "postgresql://***@db.example.com/app" + + +def test_redact_url_preserves_port(): + url = "postgresql://admin:pw@db.internal:5432/prod" + assert _redact_url(url) == "postgresql://***@db.internal:5432/prod" + + +def test_redact_url_preserves_ipv6_brackets(): + url = "postgresql://admin:pw@[::1]:5432/prod" + assert _redact_url(url) == "postgresql://***@[::1]:5432/prod" + + +def test_redact_url_scrubs_credentials_from_malformed_url(): + url = "postgresql://admin:pw@[::1" + assert _redact_url(url) == "postgresql://***@[::1" + + +def test_redact_url_scrubs_query_credentials_from_malformed_url(): + url = "postgresql://[::1?sslpassword=query-secret" + assert _redact_url(url) == "postgresql://[::1?sslpassword=%2A%2A%2A" + + +def test_redact_url_leaves_malformed_url_without_credentials_unchanged(): + url = "postgresql://[::1" + assert _redact_url(url) == url + + +def test_redact_url_no_credentials_unchanged(): + url = "postgresql://db.internal:5432/prod" + assert _redact_url(url) == url + + +def test_redact_url_masks_query_password_and_preserves_safe_options(): + url = "postgresql://db.internal/prod?sslmode=require&sslpassword=query-secret" + result = _redact_url(url) + + assert "query-secret" not in result + assert result == "postgresql://db.internal/prod?sslmode=require&sslpassword=%2A%2A%2A" + + +def test_redact_url_masks_userinfo_and_query_secrets_together(): + url = "postgresql://dbuser:user-secret@db.internal/prod?password=query-secret" + result = _redact_url(url) + + assert "dbuser" not in result + assert "user-secret" not in result + assert "query-secret" not in result + assert result == "postgresql://***@db.internal/prod?password=%2A%2A%2A" + + +def test_redact_url_non_url_string_unchanged(): + # Bare file paths / non-URL values must not be mangled. + path = "/home/user/.local/share/basic-memory/main.db" + assert _redact_url(path) == path + + +# --------------------------------------------------------------------------- +# _redact_config tests for database_url +# --------------------------------------------------------------------------- + + +def test_redact_config_scrubs_database_url_credentials(): + raw = { + "default_project": "main", + "database_url": "postgresql://dbuser:dbpass@host.example.com:5432/bm", + "projects": {}, + } + result = _redact_config(raw) + # Exact match: credentials replaced, host/port/db preserved for diagnostics. + assert result["database_url"] == "postgresql://***@host.example.com:5432/bm" + + +def test_redact_config_leaves_database_url_without_credentials(): + raw = {"database_url": "sqlite:////tmp/basic-memory/main.db"} + result = _redact_config(raw) + assert result["database_url"] == "sqlite:////tmp/basic-memory/main.db" + + +def test_redact_config_drops_secret_fields_independently(): + raw = { + "cloud_api_key": "bmc_top_secret", + "database_url": "postgresql://dbuser:dbpassword@host/db", + "default_project": "main", + } + result = _redact_config(raw) + assert "cloud_api_key" not in result + assert "dbpassword" not in result["database_url"] + assert "dbuser" not in result["database_url"] + assert "main" == result["default_project"] + + +# --------------------------------------------------------------------------- +# Integration: database_url redaction surfaces in diagnostic output +# --------------------------------------------------------------------------- + + +def test_diagnostics_redacts_database_url_password(tmp_path): + """Postgres password in database_url must not appear in diagnostic output.""" + config_data = { + "default_project": "main", + "database_url": "postgresql://pguser:supersecret@db.internal:5432/basicmemory", + "projects": {}, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + + result = basic_memory_diagnostics() + + assert "supersecret" not in result + assert "pguser" not in result + # Host and port remain visible for diagnostics. + assert "db.internal" in result + assert "5432" in result + + +def test_diagnostics_redacts_database_url_query_password(tmp_path): + """Query-string credentials must not escape through diagnostic output.""" + config_data = { + "default_project": "main", + "database_url": ( + "postgresql://db.internal:5432/basicmemory" + "?sslmode=require&sslpassword=query-supersecret" + ), + "projects": {}, + } + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + + result = basic_memory_diagnostics() + + assert "query-supersecret" not in result + assert "sslmode=require" in result + assert "sslpassword=%2A%2A%2A" in result diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 8c4d71e72..2eefcf0dc 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -13,6 +13,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = { + "basic_memory_diagnostics": [], "build_context": [ "url", "project", @@ -124,6 +125,7 @@ # every tool must set annotations.title and explicit readOnlyHint, destructiveHint, and # openWorldHint values. EXPECTED_TOOL_ANNOTATIONS: dict[str, dict[str, bool]] = { + "basic_memory_diagnostics": {"readOnlyHint": True, "destructiveHint": False}, "build_context": {"readOnlyHint": True, "destructiveHint": False}, "cloud_info": {"readOnlyHint": True, "destructiveHint": False}, "fetch": {"readOnlyHint": True, "destructiveHint": False}, @@ -176,6 +178,7 @@ TOOL_FUNCTIONS: dict[str, object] = { + "basic_memory_diagnostics": tools.basic_memory_diagnostics, "build_context": tools.build_context, "canvas": tools.canvas, "cloud_info": tools.cloud_info,