Compare commits

..

4 Commits

Author SHA1 Message Date
Drew Cain 4d0566c983 fix(mcp): drop stale API version from diagnostics, suppress duplicated structured output
- Remove orphaned __api_version__ ('v0' since 2025; real API routes are /v2,
  and nothing else reads the constant)
- output_schema=None so FastMCP doesn't duplicate the markdown report into
  structuredContent (halves the payload)
- Add title/tags annotations so #963 and #968 are merge-order independent

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 14:56:35 -05:00
Drew Cain 8f444f9fe9 test(mcp): use exact-equality assertions for redacted URLs
CodeQL flags dotted-host substring checks against URLs
(py/incomplete-url-substring-sanitization); exact matches are also
stronger assertions.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 09:42:22 -05:00
Drew Cain cbc2d5feb1 fix(mcp): redact database_url credentials in diagnostics output
Extends the existing _SECRET_FIELDS redaction to also strip the
user:password userinfo component from any URL-bearing config fields
(database_url) before surfacing them in diagnostics output. Adds
_redact_url() helper and a _URL_FIELDS set so future credential-bearing
URL fields are easy to add. Postgres passwords no longer leak to MCP
clients or support transcripts; host/port/db-name remain visible.

Fixes the P1 security finding raised in review of PR #963.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 09:37:00 -05:00
Drew Cain 29be0299f0 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 <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 01:10:21 -05:00
6 changed files with 383 additions and 2062 deletions
-1333
View File
File diff suppressed because it is too large Load Diff
-726
View File
@@ -1,726 +0,0 @@
#!/usr/bin/env python3
"""Generate MCP tool reference documentation from tool docstrings.
Introspects all MCP tool definitions in src/basic_memory/mcp/tools/,
extracts names, descriptions, parameters, and docstrings, then emits
a single deterministic markdown reference to docs/mcp-tools.md.
Usage:
uv run scripts/generate_tool_docs.py
# Verify idempotency (diff should be empty):
uv run scripts/generate_tool_docs.py && uv run scripts/generate_tool_docs.py
git diff docs/mcp-tools.md
"""
from __future__ import annotations
import ast
import inspect
import re
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[1]
TOOLS_DIR = ROOT / "src" / "basic_memory" / "mcp" / "tools"
OUT_FILE = ROOT / "docs" / "mcp-tools.md"
# Only files that contain public MCP tools (skip helpers/internals).
# NOTE: ui_sdk.py is intentionally omitted — its two tools are commented out
# in src/basic_memory/mcp/__init__.py and therefore not public.
TOOL_FILES = [
"build_context.py",
"canvas.py",
"chatgpt_tools.py",
"cloud_info.py",
"delete_note.py",
"edit_note.py",
"list_directory.py",
"move_note.py",
"project_management.py",
"read_content.py",
"read_note.py",
"recent_activity.py",
"release_notes.py",
"schema.py",
"search.py",
"view_note.py",
"workspaces.py",
"write_note.py",
]
# ---------------------------------------------------------------------------
# AST-based extraction (no import side-effects)
# ---------------------------------------------------------------------------
def _clean_docstring(raw: str | None) -> str:
"""Dedent and strip a raw docstring."""
if not raw:
return ""
return inspect.cleandoc(raw)
def _get_default_repr(node: ast.expr | None) -> str:
"""Return a short string representation for a default-value AST node."""
if node is None:
return ""
try:
return ast.unparse(node)
except Exception:
return "..."
def _unwrap_annotated(text: str) -> str:
"""Bracket-aware unwrap of ``Annotated[X, ...]`` → ``X``.
The naïve regex ``Annotated\\[([^,\\]]+),.*?\\]`` breaks on types whose
first argument itself contains brackets or commas, e.g.
``Annotated[Dict[str, Any] | None, ...]`` or
``Annotated[List[str] | None, BeforeValidator(...), ...]``.
This function scans character-by-character to find the first
*top-level* comma that separates the type from the metadata args,
then discards everything from that comma to the matching closing ``]``.
"""
prefix = "Annotated["
result = []
i = 0
while i < len(text):
if text[i:].startswith(prefix):
# Walk past "Annotated[" and collect the first top-level argument.
j = i + len(prefix)
depth = 0
start = j
while j < len(text):
ch = text[j]
if ch in "([{":
depth += 1
elif ch in ")]}":
if depth == 0:
# Closing bracket of Annotated[...] with no comma found
# (shouldn't happen in valid annotations, but be safe).
break
depth -= 1
elif ch == "," and depth == 0:
# Found the separator between the type arg and metadata.
break
j += 1
inner_type = text[start:j]
result.append(inner_type)
# Skip past the rest of the Annotated[...] construct.
# We need to find the matching ']' for the original 'Annotated['.
depth = 0
while j < len(text):
ch = text[j]
if ch in "([{":
depth += 1
elif ch == "]" and depth == 0:
j += 1 # consume the closing ']'
break
elif ch in ")]}":
depth -= 1
j += 1
i = j
else:
result.append(text[i])
i += 1
return "".join(result)
def _annotation_repr(node: ast.expr | None) -> str:
"""Return a readable string for a type-annotation AST node."""
if node is None:
return ""
try:
text = ast.unparse(node)
except Exception:
return ""
# Unwrap Annotated[X, ...] → X (keeps output readable).
# Use a bracket-aware unwrap so types like Dict[str, Any] | None are
# preserved correctly instead of being truncated at the first comma.
text = _unwrap_annotated(text)
return text
_SENTINEL = object() # returned when decorator IS @mcp.tool but has no description string
def _mcp_tool_description(decorator: ast.expr) -> str | None | object:
"""Extract the ``description=`` keyword from an ``@mcp.tool(...)`` decorator call.
Returns:
- A string when the decorator carries ``description="..."``
- ``_SENTINEL`` when the decorator IS an mcp.tool call but has no description
- ``None`` when the decorator is not an mcp.tool call at all
This distinction lets callers treat mcp.tool-decorated functions without a
decorator description string as still valid tools (description comes from the
docstring in that case).
"""
if not isinstance(decorator, ast.Call):
return None
func = decorator.func
# Accept both ``mcp.tool(...)`` and ``tool(...)``
if not (
(isinstance(func, ast.Attribute) and func.attr == "tool")
or (isinstance(func, ast.Name) and func.id == "tool")
):
return None
for kw in decorator.keywords:
if kw.arg == "description" and isinstance(kw.value, ast.Constant):
return kw.value.value
# Also accept a positional string as the tool name (no description)
# e.g. @mcp.tool("list_memory_projects", annotations={...})
# In this case, the description is not in the decorator; fall through to docstring.
return _SENTINEL
class ToolInfo:
"""Holds extracted metadata for a single MCP tool."""
__slots__ = (
"name",
"decorator_description",
"docstring",
"params",
"source_file",
)
def __init__(
self,
name: str,
decorator_description: str,
docstring: str,
params: list[dict[str, str]],
source_file: str,
) -> None:
self.name = name
self.decorator_description = decorator_description
self.docstring = docstring
self.params = params
self.source_file = source_file
# Parameters that are MCP/framework plumbing — not useful to document.
_SKIP_PARAMS = frozenset({"context", "self", "cls"})
def extract_tools_from_file(path: Path) -> list[ToolInfo]:
"""Parse *path* with AST and return a list of ToolInfo for every @mcp.tool function."""
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError as exc:
print(f"WARNING: could not parse {path}: {exc}", file=sys.stderr)
return []
tools: list[ToolInfo] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
# Only process functions that have an @mcp.tool (or @tool) decorator.
# decorator_desc is:
# - a non-empty string when found in the decorator
# - _SENTINEL when the decorator is mcp.tool but carries no description
# - None when no mcp.tool decorator is present
decorator_desc: str | object | None = None
for dec in node.decorator_list:
result = _mcp_tool_description(dec)
if result is not None:
decorator_desc = result
break
if decorator_desc is None:
continue
# When the decorator doesn't carry a description string, fall back to
# the first sentence of the docstring (populated below).
decorator_has_description = isinstance(decorator_desc, str)
docstring = _clean_docstring(ast.get_docstring(node))
# --- Parameters ---
params: list[dict[str, str]] = []
args = node.args
# Defaults are right-aligned against the arg list
n_defaults = len(args.defaults)
n_args = len(args.args)
defaults_padded = [None] * (n_args - n_defaults) + list(args.defaults) # type: ignore[list-item]
kw_defaults = args.kw_defaults # may contain None for "no default"
for i, arg in enumerate(args.args):
if arg.arg in _SKIP_PARAMS:
continue
default = defaults_padded[i]
params.append(
{
"name": arg.arg,
"type": _annotation_repr(arg.annotation),
"default": _get_default_repr(default) if default is not None else "",
}
)
for i, arg in enumerate(args.kwonlyargs):
if arg.arg in _SKIP_PARAMS:
continue
default = kw_defaults[i] if i < len(kw_defaults) else None
params.append(
{
"name": arg.arg,
"type": _annotation_repr(arg.annotation),
"default": _get_default_repr(default) if default is not None else "",
}
)
# Build the short one-liner description:
# - prefer the explicit decorator description=
# - fall back to the first non-blank line of the docstring
if decorator_has_description:
short_desc = decorator_desc.strip() # type: ignore[union-attr]
else:
first_line = next((ln.strip() for ln in docstring.splitlines() if ln.strip()), "")
short_desc = first_line
tools.append(
ToolInfo(
name=node.name,
decorator_description=short_desc,
docstring=docstring,
params=params,
source_file=path.name,
)
)
return tools
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
def _escape_table_cell(text: str) -> str:
"""Escape ``|`` characters so they do not break GitHub-flavoured markdown tables."""
return text.replace("|", "\\|")
def _params_table(
params: list[dict[str, str]], arg_descriptions: dict[str, str] | None = None
) -> str:
"""Render parameters as a markdown table.
``arg_descriptions`` maps parameter name → one-line description extracted
from the docstring ``Args:`` block.
"""
if not params:
return ""
arg_descriptions = arg_descriptions or {}
rows = [
"| Parameter | Type | Default | Description |",
"|-----------|------|---------|-------------|",
]
for p in params:
name = f"`{p['name']}`"
# Pipe characters inside a table cell break GitHub's renderer; escape them.
raw_type = p["type"]
typ = f"`{_escape_table_cell(raw_type)}`" if raw_type else ""
default_val = p["default"]
default = f"`{_escape_table_cell(default_val)}`" if default_val else "*(required)*"
desc = _escape_table_cell(arg_descriptions.get(p["name"], ""))
rows.append(f"| {name} | {typ} | {default} | {desc} |")
return "\n".join(rows)
def _extract_examples_section(docstring: str) -> tuple[str, str]:
"""Split docstring into (body_without_examples, examples_block).
Looks for an 'Examples:' section (with or without leading '#') and
returns everything before it as the body, and everything from the
Examples header to the next top-level section (e.g. 'Raises:',
'Returns:') as examples. Trailing sections after Examples are
re-appended to the body so they are not silently dropped.
"""
# Match headings like "Examples:", "# Examples", "## Examples", etc.
example_pattern = re.compile(r"^(#{1,3}\s*)?Examples:?\s*$", re.MULTILINE | re.IGNORECASE)
match = example_pattern.search(docstring)
if not match:
return docstring.strip(), ""
body_before = docstring[: match.start()].strip()
after_header = docstring[match.end() :]
# Find the next top-level section that is NOT indented (Raises:, Returns:, etc.)
# so we do not swallow it into the examples block.
next_section = re.search(r"\n(?=[A-Z][^\n]*:$)", after_header, re.MULTILINE)
if next_section:
examples_raw = after_header[: next_section.start()].strip()
trailing = after_header[next_section.start() :].strip()
# Re-attach the trailing section to the body.
body = (body_before + "\n\n" + trailing).strip() if trailing else body_before
else:
examples_raw = after_header.strip()
body = body_before
return body, examples_raw
def _parse_args_block(args_text: str) -> dict[str, str]:
"""Parse a docstring ``Args:`` block into a ``{param_name: description}`` dict.
Handles the standard Google-style format with optional leading indentation::
param_name: First line of description.
Continuation lines are more deeply indented.
Works with both un-indented and uniformly-indented blocks (e.g. when the
raw docstring is already cleandoc'd but the Args entries still have a
consistent leading indent from the original source indentation).
Returns a dict mapping each parameter name to its single-line summary
(first line only, stripped) for use in the parameters table.
"""
result: dict[str, str] = {}
# Determine the base indent level: the minimum indent of non-empty lines.
# Parameter entries sit at this level; continuation lines are deeper.
non_empty = [ln for ln in args_text.splitlines() if ln.strip()]
if not non_empty:
return result
base_indent = min(len(ln) - len(ln.lstrip()) for ln in non_empty)
current_name: str | None = None
current_lines: list[str] = []
for line in args_text.splitlines():
if not line.strip():
continue
indent = len(line) - len(line.lstrip())
stripped = line.strip()
# A new parameter entry sits at the base indent level with "name: ..."
if indent == base_indent:
top_level = re.match(r"(\w+)\s*:(.*)", stripped)
if top_level:
if current_name is not None:
result[current_name] = " ".join(current_lines).strip()
current_name = top_level.group(1)
rest = top_level.group(2).strip()
current_lines = [rest] if rest else []
continue
# Continuation line — only capture the first continuation line to
# keep descriptions terse enough for a table cell.
if current_name is not None and not current_lines:
current_lines.append(stripped)
if current_name is not None:
result[current_name] = " ".join(current_lines).strip()
return result
def _format_args_section(docstring: str) -> tuple[str, dict[str, str]]:
"""Remove the 'Args:' block from a docstring and return (cleaned_body, arg_descriptions).
``arg_descriptions`` maps each parameter name to its one-line description
so callers can populate the Description column of the params table.
"""
pattern = re.compile(r"^Args:\s*$", re.MULTILINE)
match = pattern.search(docstring)
if not match:
return docstring.strip(), {}
before = docstring[: match.start()].strip()
after_start = match.end()
# The Args block ends at the next top-level section (line that starts
# without indentation and ends with ':'), or at end-of-string.
next_section = re.search(r"\n(?=[A-Z][^\n:]*:$)", docstring[after_start:], re.MULTILINE)
if next_section:
# Use lstrip("\n") not strip() so that leading spaces (indent) on the
# first arg line are preserved for _parse_args_block's indent detection.
args_text = docstring[after_start : after_start + next_section.start()].lstrip("\n")
remainder = docstring[after_start + next_section.start() :].strip()
body = (before + "\n\n" + remainder).strip()
else:
args_text = docstring[after_start:].lstrip("\n")
body = before.strip()
return body, _parse_args_block(args_text)
def render_tool_section(tool: ToolInfo, anchor_suffix: str = "") -> str:
"""Return a markdown section for a single tool.
``anchor_suffix`` is appended to the HTML id anchor injected before the
heading when the tool name would collide with a group-section anchor
(e.g. the ``search`` tool inside the ``## Search`` group).
"""
lines: list[str] = []
if anchor_suffix:
# Inject an explicit anchor so the TOC link resolves to the tool
# section rather than the same-named group header.
lines.append(f'<a id="{tool.name.replace("_", "-")}{anchor_suffix}"></a>')
lines.append("")
lines.append(f"### `{tool.name}`")
lines.append("")
# One-liner description from the decorator (most concise)
lines.append(tool.decorator_description)
lines.append("")
# Full docstring body (strip Args: and Examples: sub-sections which get
# their own formatting below)
doc = tool.docstring
doc, arg_descriptions = _format_args_section(doc)
doc, examples_text = _extract_examples_section(doc)
# Avoid duplicating the decorator description: if the docstring's first
# non-blank line is essentially the same sentence, drop it.
if doc:
doc_lines = doc.splitlines()
first_nonempty_idx = next(
(i for i, ln in enumerate(doc_lines) if ln.strip()),
None,
)
if first_nonempty_idx is not None:
first_line = doc_lines[first_nonempty_idx].strip()
# Normalise both sides for comparison (lowercase, strip punctuation)
norm = lambda s: re.sub(r"[^a-z0-9]", "", s.lower()) # noqa: E731
if norm(first_line) == norm(tool.decorator_description):
doc_lines.pop(first_nonempty_idx)
doc = "\n".join(doc_lines).strip()
# Demote any `###` sub-headers in the body to `####` so they do not
# collide with tool-name headers in GitHub's outline sidebar.
doc = re.sub(r"^### ", "#### ", doc, flags=re.MULTILINE)
if doc:
lines.append(doc)
lines.append("")
# Parameters table
if tool.params:
lines.append("**Parameters**")
lines.append("")
lines.append(_params_table(tool.params, arg_descriptions))
lines.append("")
# Examples block
if examples_text:
lines.append("**Examples**")
lines.append("")
# Wrap in a python code fence if not already fenced
if "```" not in examples_text:
lines.append("```python")
lines.append(examples_text)
lines.append("```")
else:
lines.append(examples_text)
lines.append("")
lines.append(f"*Source: `src/basic_memory/mcp/tools/{tool.source_file}`*")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Grouping
# ---------------------------------------------------------------------------
# Stable, hand-curated grouping of tools into logical categories.
# Tools not listed here fall into "Other Tools".
TOOL_GROUPS: dict[str, list[str]] = {
"Note Management": [
"write_note",
"read_note",
"view_note",
"edit_note",
"move_note",
"delete_note",
],
"Reading & Navigation": [
"read_content",
"build_context",
"recent_activity",
"list_directory",
],
"Search": [
"search_notes",
"search",
"fetch",
],
"Project & Workspace Management": [
"list_memory_projects",
"create_memory_project",
"delete_project",
"list_workspaces",
],
"Schema Tools": [
"schema_validate",
"schema_infer",
"schema_diff",
],
"Visualization": [
"canvas",
],
"Info & Utilities": [
"cloud_info",
"release_notes",
],
}
def group_tools(tools: list[ToolInfo]) -> dict[str, list[ToolInfo]]:
"""Return an ordered dict mapping group name → list of ToolInfo."""
by_name: dict[str, ToolInfo] = {t.name: t for t in tools}
result: dict[str, list[ToolInfo]] = {}
placed: set[str] = set()
for group, names in TOOL_GROUPS.items():
members = [by_name[n] for n in names if n in by_name]
if members:
result[group] = members
placed.update(n for n in names if n in by_name)
# Anything not in TOOL_GROUPS goes to "Other Tools", sorted alphabetically
other = sorted([t for t in tools if t.name not in placed], key=lambda t: t.name)
if other:
result["Other Tools"] = other
return result
# ---------------------------------------------------------------------------
# Top-level document builder
# ---------------------------------------------------------------------------
HEADER = """\
<!--
This file is AUTO-GENERATED. Do not edit it by hand.
To regenerate:
uv run scripts/generate_tool_docs.py
Source: scripts/generate_tool_docs.py
-->
# Basic Memory MCP Tool Reference
Complete reference for all MCP tools exposed by the Basic Memory server.
Tools are grouped by function. Parameters marked *(required)* have no default value.
> **Regenerating this file**: run `uv run scripts/generate_tool_docs.py` from the
> repository root. The output is deterministic; running it twice should produce
> an identical file (zero diff).
## Table of Contents
"""
def _tool_anchor(tool_name: str, group_anchors: set[str]) -> str:
"""Return a stable GitHub anchor for a tool heading.
If the plain ``tool_name`` (with ``_`` → ``-``) would collide with a
group-section anchor (e.g. the ``search`` tool inside the ``## Search``
group), append ``-tool`` to disambiguate.
"""
base = tool_name.replace("_", "-")
if base in group_anchors:
return base + "-tool"
return base
def build_toc(groups: dict[str, list[ToolInfo]]) -> str:
lines: list[str] = []
# Collect all group anchors first so tool anchors can avoid collisions.
group_anchors: set[str] = set()
for group in groups:
anchor = re.sub(r"[^\w\s-]", "", group.lower())
anchor = re.sub(r"\s+", "-", anchor.strip())
group_anchors.add(anchor)
for group, members in groups.items():
# GitHub-style anchor: lowercase, spaces → hyphens, drop punctuation
anchor = re.sub(r"[^\w\s-]", "", group.lower())
anchor = re.sub(r"\s+", "-", anchor.strip())
lines.append(f"- [{group}](#{anchor})")
for tool in members:
t_anchor = _tool_anchor(tool.name, group_anchors)
lines.append(f" - [`{tool.name}`](#{t_anchor})")
return "\n".join(lines)
def build_document(groups: dict[str, list[ToolInfo]]) -> str:
# Pre-compute group anchors so tool-section rendering can inject explicit
# HTML id attributes where tool names would collide with group headers.
group_anchors: set[str] = set()
for group in groups:
anchor = re.sub(r"[^\w\s-]", "", group.lower())
anchor = re.sub(r"\s+", "-", anchor.strip())
group_anchors.add(anchor)
parts: list[str] = [HEADER]
parts.append(build_toc(groups))
parts.append("\n\n---\n")
for group, members in groups.items():
parts.append(f"\n## {group}\n")
for tool in members:
base = tool.name.replace("_", "-")
suffix = "-tool" if base in group_anchors else ""
parts.append(render_tool_section(tool, anchor_suffix=suffix))
parts.append("---\n")
# Trailing newline
return "\n".join(parts) + "\n"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
all_tools: list[ToolInfo] = []
for filename in TOOL_FILES:
path = TOOLS_DIR / filename
if not path.exists():
print(f"WARNING: {path} does not exist — skipping", file=sys.stderr)
continue
file_tools = extract_tools_from_file(path)
all_tools.extend(file_tools)
if not all_tools:
print("ERROR: no tools found — check TOOLS_DIR path", file=sys.stderr)
return 1
# Stable sort: group order is defined by TOOL_GROUPS; within groups, order
# is defined by TOOL_GROUPS list. Global sort here is just for determinism
# of "Other Tools".
groups = group_tools(all_tools)
document = build_document(groups)
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
OUT_FILE.write_text(document, encoding="utf-8")
total = sum(len(m) for m in groups.values())
print(
f"Generated {OUT_FILE.relative_to(ROOT)} ({total} tools, {len(OUT_FILE.read_text().splitlines())} lines)"
)
return 0
if __name__ == "__main__":
sys.exit(main())
-3
View File
@@ -2,6 +2,3 @@
# Package version - updated by release automation
__version__ = "0.22.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+4
View File
@@ -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",
@@ -0,0 +1,132 @@
"""Diagnostic tool for Basic Memory version and system information."""
import json
import platform
import sys
from urllib.parse import urlparse, urlunparse
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"})
# Fields whose values are URLs that may embed user:password credentials.
# The userinfo component is stripped before surfacing.
_URL_FIELDS = frozenset({"database_url"})
def _redact_url(url: str) -> str:
"""Strip the userinfo (user:password) 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 Exception: # pragma: no cover — urlparse is very permissive
return url
if not parsed.hostname:
# Not a meaningful URL (e.g. a bare file path); leave it alone.
return url
if parsed.username or parsed.password:
# Rebuild with credentials replaced by a placeholder.
netloc = f"***@{parsed.hostname}"
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc)
return urlunparse(redacted)
return url
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 their userinfo component stripped so that
host and database name remain visible for diagnostics.
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={"readOnlyHint": True, "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__
# --- 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"<error reading config: {exc}>"
else:
config_dump = "<config file not found>"
lines = [
"# Basic Memory Diagnostics",
"",
"## Version",
f"- basic-memory: {bm_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)
@@ -0,0 +1,247 @@
"""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,
_redact_url,
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
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 "<config file not found>" 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_no_credentials_unchanged():
url = "postgresql://db.internal:5432/prod"
assert _redact_url(url) == url
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": {},
}
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 "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