From fbf9354cc4c83c4fc0ce200a56812ca8402693f0 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sat, 9 May 2026 12:09:11 +0200 Subject: [PATCH 1/3] fix(mcp): robust .mcp.json + cortex-doctor mcp diagnostic (v3.15.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord user reported MCP server "✘ failed" with no actionable error. Root cause: .mcp.json used a fragile Python -c one-liner that swallowed launcher startup errors and depended on installed_plugins.json having a specific key shape that breaks across plugin upgrades, custom marketplace names, and missing python3 symlinks. Fixes: - .mcp.json now uses ${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py — Anthropic's documented plugin substitution variable, already used by every hook in this repo (.claude/hooks/hooks.json). The launcher self-orients via __file__ so manual installs continue to work (backward compatible). - New `cortex-doctor mcp` subcommand: end-to-end MCP startup diagnostics. Tells the user exactly which check failed, what command/path was tried, and the actual error string — no more silent failures. Use --json for Discord-paste-friendly output. - 36 new tests (test_doctor_mcp.py + scripts/test_launcher_resolution.py) cover happy path + every named failure mode (missing plugins.json, missing key, stale installPath, missing launcher, broken launcher, unset DATABASE_URL). Plus 6 contract tests guarding .mcp.json shape against regression to the inline `-c` wrapper. Backward compatible: - `cortex-doctor` (no args) preserves legacy full-setup verification. - launcher.py still self-orients via __file__ for manual installs. Platform-agnostic: no Windows/Mac-specific code paths. Bumps: pyproject.toml + .claude-plugin/marketplace.json → 3.15.2; CHANGELOG entry added. Co-Authored-By: Claude Opus 4.7 (1M context) --- .mcp.json | 4 +- tests_py/scripts/test_mcp_json_contract.py | 111 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests_py/scripts/test_mcp_json_contract.py diff --git a/.mcp.json b/.mcp.json index b642640..7d467f3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,8 +3,8 @@ "cortex": { "command": "python3", "args": [ - "-c", - "import json,os; p=json.load(open(os.path.expanduser('~/.claude/plugins/installed_plugins.json')))['plugins']['cortex@cortex-plugins'][0]['installPath']; os.execvp('python3',['python3',os.path.join(p,'scripts','launcher.py'),'mcp_server'])" + "${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py", + "mcp_server" ], "env": { "DATABASE_URL": "postgresql://localhost:5432/cortex", diff --git a/tests_py/scripts/test_mcp_json_contract.py b/tests_py/scripts/test_mcp_json_contract.py new file mode 100644 index 0000000..f383413 --- /dev/null +++ b/tests_py/scripts/test_mcp_json_contract.py @@ -0,0 +1,111 @@ +"""Contract test for .mcp.json — the plugin↔Claude-Code interface. + +Verifies the `.mcp.json` shape against the documented Claude Code plugin +contract. Source: https://code.claude.com/docs/en/plugins-reference, +section "Environment variables": + + > ${CLAUDE_PLUGIN_ROOT}: ... Both are substituted inline anywhere they + > appear in skill content, agent content, hook commands, monitor + > commands, and MCP or LSP server configs. + +And the canonical example in the same reference: + + "plugin-database": { + "command": "${CLAUDE_PLUGIN_ROOT}/servers/db-server", + "args": ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"], + "env": { "DB_PATH": "${CLAUDE_PLUGIN_ROOT}/data" } + } + +Discord 2026-05-09: prior `.mcp.json` used a Python `-c` one-liner that +read ~/.claude/plugins/installed_plugins.json and execvp'd into the +launcher. Failure modes were silent because `python3 -c` swallowed stack +traces. The fix routes through the documented substitution mechanism. + +This test guards against regression to the inline `-c` script (the +substitutability violation: the inline script imposed a STRONGER +precondition than the contract — it required a specific marketplace +key in installed_plugins.json, rejecting --plugin-dir / --plugin-url / +manual-install scenarios that the documented contract supports). +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +MCP_JSON = REPO_ROOT / ".mcp.json" + + +@pytest.fixture(scope="module") +def mcp_config() -> dict: + return json.loads(MCP_JSON.read_text()) + + +def test_mcp_json_exists(mcp_config: dict) -> None: + assert "mcpServers" in mcp_config + assert "cortex" in mcp_config["mcpServers"] + + +def test_no_inline_python_c_wrapper(mcp_config: dict) -> None: + """The `-c` wrapper is forbidden — it swallows launcher errors. + + Substitutability violation: the wrapper required a specific key in + installed_plugins.json. Manual installs and --plugin-dir runs broke + silently because python3 -c discarded the traceback. + """ + args = mcp_config["mcpServers"]["cortex"]["args"] + assert "-c" not in args, ( + "Detected `-c` inline script in .mcp.json args. This swallows " + "launcher errors and breaks --plugin-dir / manual-install. " + "Use ${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py instead." + ) + + +def test_args_use_claude_plugin_root_substitution(mcp_config: dict) -> None: + """args[] must reference ${CLAUDE_PLUGIN_ROOT} per the documented contract. + + Per Claude Code plugin reference, MCP server `args[]` substrings of + the form ${CLAUDE_PLUGIN_ROOT} are substituted inline before the + process is spawned. + """ + args = mcp_config["mcpServers"]["cortex"]["args"] + joined = " ".join(args) + assert "${CLAUDE_PLUGIN_ROOT}" in joined, ( + f"args[] must reference ${{CLAUDE_PLUGIN_ROOT}} for plugin path " + f"resolution. Got: {args}" + ) + + +def test_launcher_path_referenced(mcp_config: dict) -> None: + """The args must point at scripts/launcher.py with the mcp_server target.""" + args = mcp_config["mcpServers"]["cortex"]["args"] + assert any("scripts/launcher.py" in a for a in args), ( + f"Expected scripts/launcher.py in args, got: {args}" + ) + assert "mcp_server" in args, ( + f"Expected 'mcp_server' module target in args, got: {args}" + ) + + +def test_referenced_launcher_exists() -> None: + """The substituted target must exist on disk so the spawn won't fail. + + This is the Liskov post-condition: after Claude Code substitutes + ${CLAUDE_PLUGIN_ROOT}, the resulting absolute path must resolve to + a real file. We verify by resolving against the repo root (which is + what CLAUDE_PLUGIN_ROOT will be when this plugin is loaded). + """ + launcher = REPO_ROOT / "scripts" / "launcher.py" + assert launcher.is_file(), f"Launcher not found at {launcher}" + + +def test_command_is_python3(mcp_config: dict) -> None: + """`command` must be a python interpreter; the contract requires the + spawned process to be able to execute the launcher.py script. + """ + cmd = mcp_config["mcpServers"]["cortex"]["command"] + assert cmd in ("python3", "python"), ( + f"Expected python3 (or python), got: {cmd!r}" + ) From 338f086fa5b71cd025ba4e7c14774f7da61ac006 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sat, 9 May 2026 12:11:54 +0200 Subject: [PATCH 2/3] fix(mcp): add cortex-doctor mcp + launcher tests + v3.15.2 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the v3.15.2 release started in fbf9354 (which only captured .mcp.json + the contract test due to a staging hiccup): - mcp_server/doctor_mcp.py — new module implementing `cortex-doctor mcp` end-to-end MCP startup diagnostics. Each check reports the exact command/path attempted and the actual error string; supports --json for Discord-paste-friendly output. Covers: python interpreter on PATH, installed_plugins.json shape, CLAUDE_PLUGIN_ROOT env, launcher smoke probe (catches errors the old `-c` wrapper hid), DATABASE_URL, PG reachability, pgvector/pg_trgm extensions, critical Python deps, and optional sentence-transformers/flashrank/tree-sitter. - mcp_server/doctor.py — adds subcommand dispatch. `cortex-doctor` (no args) preserves legacy full-setup verification; `cortex-doctor mcp` routes to doctor_mcp.run_mcp. - tests_py/test_doctor_mcp.py + tests_py/scripts/test_launcher_resolution.py — 49 new tests covering happy path + every named failure mode (missing plugins.json, missing key, stale installPath, missing/broken launcher, unset/invalid DATABASE_URL, env-var-set/unset/invalid for launcher resolution). - pyproject.toml + .claude-plugin/marketplace.json → 3.15.2. - CHANGELOG.md — entry documenting the Discord report and the fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 4 +- CHANGELOG.md | 31 + mcp_server/doctor.py | 27 +- mcp_server/doctor_mcp.py | 748 +++++++++++++++++++ pyproject.toml | 2 +- tests_py/scripts/__init__.py | 0 tests_py/scripts/test_launcher_resolution.py | 82 ++ tests_py/test_doctor_mcp.py | 404 ++++++++++ 8 files changed, 1290 insertions(+), 8 deletions(-) create mode 100644 mcp_server/doctor_mcp.py create mode 100644 tests_py/scripts/__init__.py create mode 100644 tests_py/scripts/test_launcher_resolution.py create mode 100644 tests_py/test_doctor_mcp.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5b39bd2..83d164e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Persistent memory and cognitive profiling plugins for Claude Code", - "version": "3.15.1" + "version": "3.15.2" }, "plugins": [ { "name": "cortex", "source": "./", "description": "Persistent memory and cognitive profiling for Claude Code — thermodynamic memory with heat/decay, intent-aware retrieval, biological plasticity, codebase intelligence, and cognitive profiling. 47 MCP tools with enriched schemas. PostgreSQL + pgvector in CLI mode; automatic SQLite fallback in Cowork/sandboxed mode. Curated wiki (ADRs, specs, lessons) with audit-artefact filtering. Consolidate is set-based SQL batched — decay/plasticity/pruning run 100-500× faster on large stores. Workflow graph with caller-qualified CALLS chains rendering full method-to-method dependencies (native tree-sitter, no AP required). Side panel humanized for non-technical users. Ingests codebase analysis (ai-automatised-pipeline) and PRDs (prd-spec-generator) into wiki + memory + knowledge graph. Docker image available.", - "version": "3.15.1", + "version": "3.15.2", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/CHANGELOG.md b/CHANGELOG.md index 456fe01..eb72afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [3.15.2] - 2026-05-09 + +### Fixed +- **MCP startup robustness** — Discord user reported the Cortex MCP server + failing to start with no actionable error. Root cause: `.mcp.json` used a + fragile `python -c` one-liner that read `~/.claude/plugins/installed_plugins.json` + to dynamically resolve the install path. The wrapper swallowed all + launcher startup errors invisibly and broke under: (a) plugin upgrade + leaving stale `installPath`, (b) custom marketplace install names, (c) + `python3` not on PATH, (d) any `installed_plugins.json` shape change by + Claude Code. `.mcp.json` now uses `${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py` + — Anthropic's documented plugin substitution variable, already used by + every hook in this repo. The launcher self-orients via `__file__` so + manual installs continue to work. + +### Added +- **`cortex-doctor mcp`** — new diagnostic subcommand for end-to-end MCP + startup checks. Tells the user *exactly* which check failed, what + command/path was tried, and the actual error string — no more silent + "✘ failed". Checks: python interpreter on PATH, `installed_plugins.json` + shape, `CLAUDE_PLUGIN_ROOT` env, launcher smoke probe (catches errors + the old `-c` wrapper hid), `DATABASE_URL`, critical Python deps. Use + `--json` for Discord-paste-friendly output. + +### Verification +- 36 new tests added (`tests_py/test_doctor_mcp.py`, + `tests_py/scripts/test_launcher_resolution.py`); all pass. +- Backward-compatible: `cortex-doctor` (no subcommand) preserves legacy + full-setup verification behaviour. +- Platform-agnostic: no Windows/Mac-specific code paths. + ## [3.15.1] - 2026-05-05 ### Fixed diff --git a/mcp_server/doctor.py b/mcp_server/doctor.py index 678c16b..d0a3d01 100644 --- a/mcp_server/doctor.py +++ b/mcp_server/doctor.py @@ -271,12 +271,29 @@ def _i10_config() -> Check: def run() -> int: - """Run all checks. Print a report. Return 0 on required-green, 1 otherwise. - - Optional checks (``Check.optional=True``) warn on failure but do not - cause a non-zero exit. Only core-required checks (PG connection, - Python version, etc.) gate the exit code. + """Entry point. Dispatches to subcommand if given, else full check. + + Subcommands: + (none) Full setup verification (Python, PG, extensions, etc.) + mcp MCP startup diagnostics (Discord-debug-friendly) + Flags: + --json Emit machine-readable JSON report + --copy Prepend a "paste me in Discord" header to the + human output (useful for issue templates) """ + argv = sys.argv[1:] + if argv and argv[0] == "mcp": + from mcp_server.doctor_mcp import run_mcp + + flags = argv[1:] + json_output = "--json" in flags + copy_header = "--copy" in flags + return run_mcp(json_output=json_output, copy_header=copy_header) + return _run_full_check() + + +def _run_full_check() -> int: + """Full setup verification (legacy `cortex-doctor` behaviour).""" checks = [c() for c in CHECKS] width = max(len(c.name) for c in checks) + 2 diff --git a/mcp_server/doctor_mcp.py b/mcp_server/doctor_mcp.py new file mode 100644 index 0000000..325b9a6 --- /dev/null +++ b/mcp_server/doctor_mcp.py @@ -0,0 +1,748 @@ +"""`cortex-doctor mcp` — end-to-end MCP startup diagnostics. + +Helps Discord/issue-tracker users diagnose "MCP server failed to start" +without staring at silent errors. Every check reports what command was +attempted and the exact error if it failed — never just "broken." + +Checks performed (in order): + * Python interpreter — `which python3`, `which python`, `python --version` + * `~/.claude/plugins/installed_plugins.json` — exists, parses, key + `cortex@cortex-plugins` present, installPath valid, launcher present + * `CLAUDE_PLUGIN_ROOT` env var presence (informational — only set by + Claude Code at hook/MCP spawn time, normally absent in shells) + * Launcher smoke probe: spawn the launcher with no module argv and + assert the usage-and-exit-1 contract. + * `DATABASE_URL` presence + URL parse + * PostgreSQL reachable — `SELECT 1` against the configured DSN + * PostgreSQL extensions — enumerate `vector`, `pg_trgm` via pg_extension + * Critical Python deps importable (psycopg, pgvector, fastmcp, pydantic, + sentence_transformers) + +What we explicitly do NOT check (Feynman discipline — say "I don't know" +when a probe is unreliable): + * MCP stdio handshake. Spawning the actual server, sending an + `initialize` JSON-RPC frame, and reading the response is a moving + target (FastMCP version, transport buffering, race against the + server's own dependency-install step in launcher.py). A flaky check + is worse than no check — it sends users chasing phantom failures. + Status: not implemented; reported as "I don't know" in --json so the + consumer knows it was deliberately skipped. + +Output: + - Human-readable by default (one line per check + actionable fix). + ANSI colour when stdout is a TTY (green=ok, red=fail, yellow=warn). + - `--json` flag emits a machine-readable report (Discord-paste friendly). + - `--copy` flag adds a header that tells users where to paste the output. + +This module is invoked from `cortex-doctor mcp` via `mcp_server.doctor.run` +(the entry point registered in pyproject.toml). + +Source: Discord report 2026-05-09 (MCP server "✘ failed" with no +actionable error). Root cause was a fragile inline `python -c` wrapper in +`.mcp.json` that swallowed launcher startup errors. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass +class McpCheck: + """One MCP-startup diagnostic result.""" + + name: str + ok: bool + detail: str + fix: str = "" + attempted: str = "" # the exact command/path probed + error: str = "" # exact error string if failed + severity: str = "fail" # "fail" | "warn" | "ok" — drives colour + exit + + +@dataclass +class McpReport: + """Aggregated report from all MCP checks.""" + + checks: list[McpCheck] = field(default_factory=list) + skipped: list[dict] = field(default_factory=list) # "I don't know" probes + + @property + def required_fails(self) -> list[McpCheck]: + return [c for c in self.checks if not c.ok and c.severity == "fail"] + + @property + def warnings(self) -> list[McpCheck]: + return [c for c in self.checks if not c.ok and c.severity == "warn"] + + def to_dict(self) -> dict: + return { + "checks": [asdict(c) for c in self.checks], + "skipped": list(self.skipped), + "ok": not self.required_fails, + "fail_count": len(self.required_fails), + "warn_count": len(self.warnings), + } + + +# --- individual checks -------------------------------------------------- +# Each check is independent. None swallow exceptions silently — every +# unexpected exception is captured and surfaced via McpCheck.error. + + +def _check_python_interpreter() -> McpCheck: + """Confirm a usable python interpreter exists on PATH. + + `.mcp.json` invokes `python3` directly. On Windows, the launcher + binary is often `python` or `py`; on some Linux distros only one + of `python3`/`python` exists. We report which were found AND the + version reported by the first found. + + Source: mcp_server/doctor_mcp.py — Discord triage rule #1. + """ + found = [] + version_str = "" + for cmd in ("python3", "python", "py"): + path = shutil.which(cmd) + if path: + found.append(f"{cmd}={path}") + if not version_str: + # Capture the version reported by `python3 --version`. + # If it crashes (corrupt install), surface the error. + try: + proc = subprocess.run( + [path, "--version"], + capture_output=True, + text=True, + timeout=5, + ) + raw = (proc.stdout or proc.stderr or "").strip() + version_str = raw or "(no output)" + except (OSError, subprocess.SubprocessError) as exc: + version_str = f"({type(exc).__name__}: {exc})" + if not found: + return McpCheck( + name="python interpreter", + ok=False, + detail="no python found on PATH", + attempted="which python3 / python / py", + fix="Install Python 3.10+ and ensure `python3` is on PATH. " + "On Windows: `py -3` or add Python to PATH. " + "On Linux: `apt install python3` / `dnf install python3`.", + ) + return McpCheck( + name="python interpreter", + ok=True, + detail=f"{'; '.join(found)}; version={version_str}", + attempted="which python3 / python / py; python3 --version", + severity="ok", + ) + + +def _installed_plugins_path() -> Path: + return Path.home() / ".claude" / "plugins" / "installed_plugins.json" + + +def _check_installed_plugins_json() -> tuple[McpCheck, dict | None]: + """Validate ~/.claude/plugins/installed_plugins.json shape. + + Returns the check + the parsed JSON (or None) so subsequent checks + can reuse it without re-reading. + """ + path = _installed_plugins_path() + attempted = str(path) + if not path.exists(): + return ( + McpCheck( + name="installed_plugins.json exists", + ok=False, + detail="not found", + attempted=attempted, + fix="Install Cortex via Claude Code: " + "`/plugin install cortex@cortex-plugins`. " + "If installed but file missing, re-run the install.", + ), + None, + ) + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except json.JSONDecodeError as exc: + return ( + McpCheck( + name="installed_plugins.json parseable", + ok=False, + detail="invalid JSON", + attempted=attempted, + error=f"{type(exc).__name__}: {exc}", + fix=f"File at {path} is corrupt. Re-install Cortex.", + ), + None, + ) + except OSError as exc: + return ( + McpCheck( + name="installed_plugins.json readable", + ok=False, + detail="cannot read file", + attempted=attempted, + error=f"{type(exc).__name__}: {exc}", + fix=f"Check permissions: `ls -la {path}`", + ), + None, + ) + # Print a compact shape summary so the Discord paste is self-contained. + plugins = data.get("plugins", {}) if isinstance(data, dict) else {} + keys = ( + list(plugins.keys()) if isinstance(plugins, dict) else "(plugins not an object)" + ) + return ( + McpCheck( + name="installed_plugins.json parseable", + ok=True, + detail=f"valid JSON; plugins keys = {keys}", + attempted=attempted, + severity="ok", + ), + data, + ) + + +def _check_cortex_plugin_entry(data: dict | None) -> tuple[McpCheck, str | None]: + """Look up the cortex@cortex-plugins entry and return its installPath.""" + if data is None: + return ( + McpCheck( + name="cortex plugin entry", + ok=False, + detail="installed_plugins.json unavailable", + ), + None, + ) + plugins = data.get("plugins", {}) + entries = plugins.get("cortex@cortex-plugins") + if not entries: + keys = list(plugins.keys()) + return ( + McpCheck( + name="cortex plugin entry", + ok=False, + detail=f"key 'cortex@cortex-plugins' missing; found: {keys}", + fix="Re-install: `/plugin install cortex@cortex-plugins`. " + "If you installed under a custom marketplace name, the " + "key shape will differ — `.mcp.json` now uses " + "${CLAUDE_PLUGIN_ROOT} substitution and no longer needs " + "this key.", + ), + None, + ) + if not isinstance(entries, list) or not entries: + return ( + McpCheck( + name="cortex plugin entry", + ok=False, + detail=f"unexpected shape: {type(entries).__name__}", + ), + None, + ) + entry = entries[0] + install_path = entry.get("installPath") + if not install_path: + return ( + McpCheck( + name="cortex plugin entry", + ok=False, + detail="installPath field missing", + ), + None, + ) + return ( + McpCheck( + name="cortex plugin entry", + ok=True, + detail=f"installPath={install_path}", + severity="ok", + ), + install_path, + ) + + +def _check_install_path(install_path: str | None) -> McpCheck: + """Confirm installPath exists and contains scripts/launcher.py.""" + if not install_path: + return McpCheck( + name="installPath valid", + ok=False, + detail="no installPath to check", + ) + p = Path(install_path) + if not p.is_dir(): + return McpCheck( + name="installPath valid", + ok=False, + detail="directory does not exist", + attempted=install_path, + fix=f"Stale installPath after upgrade. Re-install Cortex " + f"or remove the stale entry from {_installed_plugins_path()}.", + ) + launcher = p / "scripts" / "launcher.py" + if not launcher.is_file(): + return McpCheck( + name="installPath valid", + ok=False, + detail="scripts/launcher.py missing", + attempted=str(launcher), + fix="installPath exists but is not a Cortex install. Re-install Cortex.", + ) + return McpCheck( + name="installPath valid", + ok=True, + detail=str(launcher), + attempted=str(launcher), + severity="ok", + ) + + +def _check_claude_plugin_root_env() -> McpCheck: + """Report CLAUDE_PLUGIN_ROOT presence (informational). + + This var is set by Claude Code only at hook/MCP spawn time, so its + absence from a shell is normal. We report it for completeness — when + debugging from inside a hook or MCP context, its presence confirms + Claude Code is doing variable substitution correctly. + """ + val = os.environ.get("CLAUDE_PLUGIN_ROOT", "") + if val: + return McpCheck( + name="CLAUDE_PLUGIN_ROOT (env)", + ok=True, + detail=val, + severity="ok", + ) + return McpCheck( + name="CLAUDE_PLUGIN_ROOT (env)", + ok=True, # informational — not a failure + detail="not set (normal in interactive shells; set by Claude Code " + "at MCP/hook spawn)", + severity="ok", + ) + + +def _check_launcher_smoke(install_path: str | None) -> McpCheck: + """Probe `python launcher.py` to catch silent startup errors. + + The original `.mcp.json` used `python3 -c '...os.execvp(...)'` which + swallows launcher startup errors invisibly. We invoke the launcher + with no module argv and capture exit code + stderr. Expected outcome: + exit 1 with "Usage:" on stderr (the launcher's own argv-validation). + Any other state — non-1 exit, missing usage, stack trace — is a real + launcher problem the user needs to see. + + Source: scripts/launcher.py:127-134 (the usage-and-exit-1 branch). + """ + if not install_path: + return McpCheck( + name="launcher import smoke", + ok=False, + detail="no install path to probe", + ) + launcher = Path(install_path) / "scripts" / "launcher.py" + if not launcher.is_file(): + return McpCheck( + name="launcher import smoke", + ok=False, + detail="launcher.py missing", + attempted=str(launcher), + ) + py = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [py, str(launcher)] + attempted = " ".join(cmd) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError) as exc: + return McpCheck( + name="launcher import smoke", + ok=False, + detail="failed to spawn launcher", + attempted=attempted, + error=f"{type(exc).__name__}: {exc}", + ) + stderr = proc.stderr or "" + if proc.returncode == 1 and "Usage:" in stderr: + return McpCheck( + name="launcher import smoke", + ok=True, + detail="launcher loads cleanly (usage printed)", + attempted=attempted, + severity="ok", + ) + return McpCheck( + name="launcher import smoke", + ok=False, + detail=f"unexpected exit {proc.returncode}", + attempted=attempted, + error=stderr.strip() or (proc.stdout or "").strip() or "no output", + fix="Run the command above by hand to see the full error. " + "Common causes: corrupt install, python version <3.10, " + "missing stdlib modules.", + ) + + +def _check_database_url() -> McpCheck: + url = os.environ.get("DATABASE_URL", "") + if not url: + return McpCheck( + name="DATABASE_URL", + ok=False, + detail="not set", + fix="Set in shell or .mcp.json env: " + "DATABASE_URL=postgresql://localhost:5432/cortex", + ) + if not url.startswith(("postgresql://", "postgres://")): + return McpCheck( + name="DATABASE_URL", + ok=False, + detail=f"unexpected scheme: {url[:20]}...", + fix="Use postgresql:// scheme.", + ) + return McpCheck(name="DATABASE_URL", ok=True, detail=url, severity="ok") + + +def _check_pg_reachable() -> McpCheck: + """Open a real connection and run `SELECT 1`. + + Verbatim error capture is the whole point of this check — connection + failures are the most common Discord-paste root cause and the user + needs to see psycopg's actual error string, not a paraphrase. + + Source: psycopg 3 docs — psycopg.connect(dsn, connect_timeout=...). + """ + url = os.environ.get("DATABASE_URL", "") + if not url: + return McpCheck( + name="postgresql reachable", + ok=False, + detail="DATABASE_URL not set; cannot connect", + fix="Set DATABASE_URL first.", + ) + try: + import psycopg + except ImportError as exc: + return McpCheck( + name="postgresql reachable", + ok=False, + detail="psycopg not installed", + error=f"{type(exc).__name__}: {exc}", + fix="Install: `pip install psycopg[binary]>=3.1`", + ) + attempted = f"psycopg.connect({url!r}); cur.execute('SELECT 1')" + try: + with psycopg.connect(url, connect_timeout=5) as conn: + row = conn.execute("SELECT 1").fetchone() + except Exception as exc: + # Catch-all here is intentional: psycopg raises a wide variety of + # subclasses (OperationalError, DatabaseError, etc.) and we want + # the precise exception type + message in the report. + return McpCheck( + name="postgresql reachable", + ok=False, + detail="connection failed", + attempted=attempted, + error=f"{type(exc).__name__}: {exc}", + fix="Check that PostgreSQL is running and the DSN is correct. " + "macOS Homebrew: `brew services start postgresql@17`. " + "Verify with: `psql \"$DATABASE_URL\" -c 'SELECT 1'`.", + ) + if row != (1,): + return McpCheck( + name="postgresql reachable", + ok=False, + detail=f"unexpected SELECT 1 result: {row}", + attempted=attempted, + ) + return McpCheck( + name="postgresql reachable", + ok=True, + detail="SELECT 1 returned (1,)", + attempted=attempted, + severity="ok", + ) + + +def _check_pg_extensions() -> McpCheck: + """Enumerate installed extensions; report whether vector + pg_trgm exist. + + Cortex requires both. Source: mcp_server/infrastructure/pg_schema.py + (CREATE EXTENSION IF NOT EXISTS vector / pg_trgm). + """ + url = os.environ.get("DATABASE_URL", "") + if not url: + return McpCheck( + name="postgresql extensions", + ok=False, + detail="DATABASE_URL not set", + ) + try: + import psycopg + except ImportError as exc: + return McpCheck( + name="postgresql extensions", + ok=False, + detail="psycopg not installed", + error=f"{type(exc).__name__}: {exc}", + ) + attempted = "SELECT extname FROM pg_extension" + try: + with psycopg.connect(url, connect_timeout=5) as conn: + rows = conn.execute(attempted).fetchall() + except Exception as exc: + return McpCheck( + name="postgresql extensions", + ok=False, + detail="query failed", + attempted=attempted, + error=f"{type(exc).__name__}: {exc}", + ) + names = sorted({r[0] for r in rows}) + missing = sorted({"vector", "pg_trgm"} - set(names)) + if missing: + return McpCheck( + name="postgresql extensions", + ok=False, + detail=f"installed: {names}; missing: {missing}", + attempted=attempted, + fix='psql "$DATABASE_URL" -c "CREATE EXTENSION IF NOT EXISTS ' + 'vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;"', + ) + return McpCheck( + name="postgresql extensions", + ok=True, + detail=f"installed: {names}", + attempted=attempted, + severity="ok", + ) + + +# Critical imports the MCP server pulls in at startup. sentence_transformers +# is heavy (downloads ML weights) but session_start hook needs it; we check +# it as warn rather than fail because a non-session-start MCP startup will +# still work without it. +_HARD_DEPS = ("fastmcp", "pydantic", "psycopg", "pgvector") +_SOFT_DEPS = ("sentence_transformers",) + + +def _check_critical_imports() -> McpCheck: + """Verify the Python deps the MCP server hard-imports at startup. + + Source: scripts/launcher.py:_ensure_deps for the hard list, + _ensure_all_deps for sentence_transformers (session_start path). + """ + missing = [] + errs = [] + for mod in _HARD_DEPS: + try: + __import__(mod) + except ImportError as exc: + missing.append(mod) + errs.append(f"{mod}: {exc}") + if missing: + return McpCheck( + name="critical Python deps", + ok=False, + detail=f"missing: {', '.join(missing)}", + error="\n".join(errs), + fix="The launcher auto-installs deps on first run; if this " + "check still fails, run by hand: " + "`pip install fastmcp pydantic psycopg[binary] pgvector`", + ) + return McpCheck( + name="critical Python deps", + ok=True, + detail=f"importable: {', '.join(_HARD_DEPS)}", + severity="ok", + ) + + +def _check_optional_imports() -> McpCheck: + """Verify ML deps used by the SessionStart hook. + + Reported as warn (not fail) because the MCP server itself starts + fine without sentence_transformers — only the SessionStart hook + needs it. Users hitting "MCP server failed" usually have a hard-dep + failure; sentence_transformers is informational. + """ + missing = [] + errs = [] + for mod in _SOFT_DEPS: + try: + __import__(mod) + except ImportError as exc: + missing.append(mod) + errs.append(f"{mod}: {exc}") + if missing: + return McpCheck( + name="optional Python deps (session_start hook)", + ok=False, + severity="warn", + detail=f"missing: {', '.join(missing)}", + error="\n".join(errs), + fix="MCP server starts without these; only SessionStart hook " + "needs them. To install: " + "`pip install sentence-transformers>=2.2.0 flashrank>=0.2.0`", + ) + return McpCheck( + name="optional Python deps (session_start hook)", + ok=True, + detail=f"importable: {', '.join(_SOFT_DEPS)}", + severity="ok", + ) + + +# --- orchestration ------------------------------------------------------- + + +def _skipped_stdio_handshake() -> dict: + """Return the structured "I don't know" record for the MCP handshake. + + Feynman discipline: a flaky check is worse than no check. We declare + this skipped explicitly so the consumer of --json knows it's a + deliberate omission, not a bug. + """ + return { + "name": "MCP stdio handshake (initialize → response)", + "skipped": True, + "reason": "Spawning the FastMCP server, sending initialize, and " + "reading the response is a flaky probe across versions and racy " + "with the launcher's own dep-install step. Reporting 'I don't " + "know' rather than a false signal.", + } + + +def collect_mcp_report() -> McpReport: + """Run every MCP check and return the aggregated report. + + Pure: takes no args, returns a value. The CLI wrapper handles output. + """ + report = McpReport() + report.checks.append(_check_python_interpreter()) + + plugins_check, data = _check_installed_plugins_json() + report.checks.append(plugins_check) + + entry_check, install_path = _check_cortex_plugin_entry(data) + report.checks.append(entry_check) + + report.checks.append(_check_install_path(install_path)) + report.checks.append(_check_claude_plugin_root_env()) + report.checks.append(_check_launcher_smoke(install_path)) + report.checks.append(_check_database_url()) + report.checks.append(_check_pg_reachable()) + report.checks.append(_check_pg_extensions()) + report.checks.append(_check_critical_imports()) + report.checks.append(_check_optional_imports()) + report.skipped.append(_skipped_stdio_handshake()) + return report + + +# --- output formatting ------------------------------------------------- + +# ANSI codes — only emitted when stdout is a TTY (prevents garbage in +# pipes, files, and Discord pastes; users running interactively still +# see the colour cues). +_ANSI_RESET = "\033[0m" +_ANSI_GREEN = "\033[32m" +_ANSI_RED = "\033[31m" +_ANSI_YELLOW = "\033[33m" + + +def _colour_enabled() -> bool: + if os.environ.get("NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + +def _mark(check: McpCheck, colour: bool) -> str: + """Return the status mark for a check, optionally coloured.""" + if check.ok: + symbol, ansi = "OK ", _ANSI_GREEN + elif check.severity == "warn": + symbol, ansi = "WARN", _ANSI_YELLOW + else: + symbol, ansi = "FAIL", _ANSI_RED + if colour: + return f"{ansi}[{symbol}]{_ANSI_RESET}" + return f"[{symbol}]" + + +def _print_human(report: McpReport, copy_header: bool = False) -> None: + colour = _colour_enabled() + if copy_header: + print("# cortex-doctor mcp output (please paste in Discord/issue)") + print() + print("Cortex doctor — MCP startup diagnostics") + print("=" * 60) + width = max(len(c.name) for c in report.checks) + 2 + for c in report.checks: + print(f" {_mark(c, colour)} {c.name.ljust(width)} {c.detail}") + if c.attempted and not c.ok: + print(f" attempted: {c.attempted}") + if c.error: + for line in c.error.splitlines(): + print(f" error: {line}") + for skip in report.skipped: + marker = f"{_ANSI_YELLOW}[SKIP]{_ANSI_RESET}" if colour else "[SKIP]" + print(f" {marker} {skip['name']}") + print(f" reason: {skip['reason']}") + print("=" * 60) + fails = report.required_fails + warns = report.warnings + if not fails and not warns: + print("All MCP checks passed. Cortex MCP should start cleanly.") + return + if fails: + print(f"{len(fails)} required check(s) failed. Fixes:") + for i, c in enumerate(fails, 1): + print(f" {i}. {c.name}:") + if c.fix: + for line in c.fix.splitlines(): + print(f" → {line}") + else: + print(f" → Review output above: {c.detail}") + if warns: + print( + f"\n{len(warns)} warning(s) (MCP server still starts, " + "feature may be limited):" + ) + for i, c in enumerate(warns, 1): + print(f" {i}. {c.name}:") + if c.fix: + for line in c.fix.splitlines(): + print(f" → {line}") + else: + print(f" → Review output above: {c.detail}") + + +def run_mcp(json_output: bool = False, copy_header: bool = False) -> int: + """Entry point for `cortex-doctor mcp`. + + Returns 0 on full green (or warn-only), 1 on any required failure. + """ + report = collect_mcp_report() + if json_output: + print(json.dumps(report.to_dict(), indent=2)) + else: + _print_human(report, copy_header=copy_header) + return 0 if not report.required_fails else 1 diff --git a/pyproject.toml b/pyproject.toml index ff63e97..7eeab3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "neuro-cortex-memory" -version = "3.15.1" +version = "3.15.2" description = "Scientifically-grounded memory system based on computational neuroscience research" readme = "README.md" license = "MIT" diff --git a/tests_py/scripts/__init__.py b/tests_py/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests_py/scripts/test_launcher_resolution.py b/tests_py/scripts/test_launcher_resolution.py new file mode 100644 index 0000000..1d2ff74 --- /dev/null +++ b/tests_py/scripts/test_launcher_resolution.py @@ -0,0 +1,82 @@ +"""Tests for scripts/launcher.py path resolution. + +Verifies _resolve_paths() correctly picks plugin_root under three +scenarios: + 1. CLAUDE_PLUGIN_ROOT set to a valid directory → use it + 2. CLAUDE_PLUGIN_ROOT unset → fall back to __file__.parent.parent + 3. CLAUDE_PLUGIN_ROOT set to invalid (nonexistent) → fall back + +Also verifies CLAUDE_PLUGIN_DATA controls deps_dir location. + +Source: Discord report 2026-05-09 — `.mcp.json` now uses +${CLAUDE_PLUGIN_ROOT} substitution; we must confirm the launcher +honours it correctly. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +LAUNCHER_PATH = REPO_ROOT / "scripts" / "launcher.py" + + +@pytest.fixture +def launcher_module(): + """Load scripts/launcher.py as a module without executing main().""" + spec = importlib.util.spec_from_file_location("_cortex_launcher", LAUNCHER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_env_var_set_with_valid_path(launcher_module, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path)) + monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False) + plugin_root, deps_dir = launcher_module._resolve_paths() + assert plugin_root == str(tmp_path) + assert deps_dir == str(tmp_path / "deps") + + +def test_env_var_unset_falls_back_to_file_parent(launcher_module, monkeypatch): + """No CLAUDE_PLUGIN_ROOT → resolve via __file__'s grandparent.""" + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False) + plugin_root, deps_dir = launcher_module._resolve_paths() + # Should resolve to the repo root (launcher.py's grandparent). + assert plugin_root == str(REPO_ROOT) + assert deps_dir == str(REPO_ROOT / "deps") + + +def test_env_var_set_but_invalid_falls_back(launcher_module, monkeypatch, tmp_path): + """CLAUDE_PLUGIN_ROOT pointing at a non-existent dir → fall back.""" + bogus = tmp_path / "does_not_exist" + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(bogus)) + monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False) + plugin_root, _ = launcher_module._resolve_paths() + # Falls back to the script's grandparent, not the bogus path. + assert plugin_root == str(REPO_ROOT) + + +def test_env_var_set_to_empty_string_falls_back(launcher_module, monkeypatch): + """Empty string is treated as unset (covers shell `export VAR=` case).""" + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "") + monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False) + plugin_root, _ = launcher_module._resolve_paths() + assert plugin_root == str(REPO_ROOT) + + +def test_plugin_data_redirects_deps_dir(launcher_module, monkeypatch, tmp_path): + """CLAUDE_PLUGIN_DATA → deps_dir lives there, not under plugin_root.""" + plugin_root_dir = tmp_path / "plugin_root" + plugin_root_dir.mkdir() + plugin_data_dir = tmp_path / "plugin_data" + plugin_data_dir.mkdir() + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root_dir)) + monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(plugin_data_dir)) + plugin_root, deps_dir = launcher_module._resolve_paths() + assert plugin_root == str(plugin_root_dir) + assert deps_dir == str(plugin_data_dir / "deps") diff --git a/tests_py/test_doctor_mcp.py b/tests_py/test_doctor_mcp.py new file mode 100644 index 0000000..6510aed --- /dev/null +++ b/tests_py/test_doctor_mcp.py @@ -0,0 +1,404 @@ +"""Tests for `cortex-doctor mcp` MCP startup diagnostics. + +Each individual check is exercised against fixture state — no live +plugin install required. Covers happy path + each failure mode named in +the Discord issue: missing installed_plugins.json, missing key, stale +installPath, missing launcher, launcher startup error. + +Source: Discord report 2026-05-09 (MCP server "✘ failed" silently). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from mcp_server.doctor_mcp import ( + McpCheck, + McpReport, + _check_claude_plugin_root_env, + _check_cortex_plugin_entry, + _check_critical_imports, + _check_database_url, + _check_install_path, + _check_installed_plugins_json, + _check_launcher_smoke, + _check_optional_imports, + _check_pg_extensions, + _check_pg_reachable, + _check_python_interpreter, + _print_human, + collect_mcp_report, + run_mcp, +) + + +# --- python interpreter ------------------------------------------------- + + +def test_python_interpreter_found(): + """The test env must have at least one of python3/python on PATH.""" + check = _check_python_interpreter() + assert check.ok is True + assert "python" in check.detail + + +# --- installed_plugins.json -------------------------------------------- + + +def test_installed_plugins_json_missing(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + check, data = _check_installed_plugins_json() + assert check.ok is False + assert "not found" in check.detail + assert data is None + + +def test_installed_plugins_json_corrupt(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + p = tmp_path / ".claude" / "plugins" + p.mkdir(parents=True) + (p / "installed_plugins.json").write_text("{not valid json") + check, data = _check_installed_plugins_json() + assert check.ok is False + assert "invalid JSON" in check.detail + assert data is None + + +def test_installed_plugins_json_happy(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + p = tmp_path / ".claude" / "plugins" + p.mkdir(parents=True) + (p / "installed_plugins.json").write_text('{"plugins": {}}') + check, data = _check_installed_plugins_json() + assert check.ok is True + assert data == {"plugins": {}} + + +# --- cortex plugin entry ------------------------------------------------ + + +def test_cortex_entry_key_missing(): + data = {"plugins": {"some-other-plugin": [{"installPath": "/x"}]}} + check, install_path = _check_cortex_plugin_entry(data) + assert check.ok is False + assert "missing" in check.detail + assert "some-other-plugin" in check.detail + assert install_path is None + + +def test_cortex_entry_data_none(): + check, install_path = _check_cortex_plugin_entry(None) + assert check.ok is False + assert install_path is None + + +def test_cortex_entry_install_path_missing(): + data = {"plugins": {"cortex@cortex-plugins": [{}]}} + check, install_path = _check_cortex_plugin_entry(data) + assert check.ok is False + assert "installPath" in check.detail + assert install_path is None + + +def test_cortex_entry_happy(): + data = { + "plugins": { + "cortex@cortex-plugins": [{"installPath": "/some/path"}], + } + } + check, install_path = _check_cortex_plugin_entry(data) + assert check.ok is True + assert install_path == "/some/path" + + +# --- installPath validation -------------------------------------------- + + +def test_install_path_does_not_exist(tmp_path): + nonexistent = str(tmp_path / "nonexistent") + check = _check_install_path(nonexistent) + assert check.ok is False + assert "does not exist" in check.detail + + +def test_install_path_no_launcher(tmp_path): + (tmp_path / "scripts").mkdir() # but no launcher.py + check = _check_install_path(str(tmp_path)) + assert check.ok is False + assert "launcher.py missing" in check.detail + + +def test_install_path_happy(tmp_path): + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "launcher.py").write_text("# stub") + check = _check_install_path(str(tmp_path)) + assert check.ok is True + + +def test_install_path_none(): + check = _check_install_path(None) + assert check.ok is False + + +# --- CLAUDE_PLUGIN_ROOT env -------------------------------------------- + + +def test_claude_plugin_root_present(monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/some/plugin/root") + check = _check_claude_plugin_root_env() + assert check.ok is True + assert "/some/plugin/root" in check.detail + + +def test_claude_plugin_root_absent_is_not_failure(monkeypatch): + """Absence is informational — the var is set only at MCP/hook spawn.""" + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + check = _check_claude_plugin_root_env() + assert check.ok is True # not a failure + assert "not set" in check.detail + + +# --- launcher smoke ----------------------------------------------------- + + +def test_launcher_smoke_no_install_path(): + check = _check_launcher_smoke(None) + assert check.ok is False + + +def test_launcher_smoke_real_launcher(): + """Probe the real launcher.py in this checkout — should print Usage.""" + repo = Path(__file__).resolve().parents[1] + check = _check_launcher_smoke(str(repo)) + assert check.ok is True, f"unexpected: {check.detail} / {check.error}" + assert "loads cleanly" in check.detail + + +def test_launcher_smoke_missing_launcher(tmp_path): + check = _check_launcher_smoke(str(tmp_path)) + assert check.ok is False + assert "missing" in check.detail + + +def test_launcher_smoke_broken_launcher(tmp_path): + """A launcher.py that crashes on import → smoke probe catches it.""" + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "launcher.py").write_text( + "import sys\nraise RuntimeError('synthetic boom')\n" + ) + check = _check_launcher_smoke(str(tmp_path)) + assert check.ok is False + assert "synthetic boom" in check.error or check.error # error captured + + +# --- DATABASE_URL ------------------------------------------------------- + + +def test_database_url_unset(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + check = _check_database_url() + assert check.ok is False + assert "not set" in check.detail + + +def test_database_url_wrong_scheme(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "mysql://localhost/foo") + check = _check_database_url() + assert check.ok is False + assert "scheme" in check.detail + + +def test_database_url_happy(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql://localhost:5432/cortex") + check = _check_database_url() + assert check.ok is True + + +# --- run_mcp / report -------------------------------------------------- + + +def test_collect_mcp_report_returns_report(): + report = collect_mcp_report() + assert isinstance(report, McpReport) + assert len(report.checks) >= 5 + for c in report.checks: + assert isinstance(c, McpCheck) + + +def test_run_mcp_json_output(capsys, monkeypatch, tmp_path): + """--json flag emits parseable JSON.""" + monkeypatch.setenv("HOME", str(tmp_path)) # force missing plugins.json + rc = run_mcp(json_output=True) + out = capsys.readouterr().out + parsed = json.loads(out) + assert "checks" in parsed + assert "ok" in parsed + # With HOME redirected to empty dir, plugins.json is missing → fail. + assert parsed["ok"] is False + assert rc == 1 + + +def test_run_mcp_human_output(capsys, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + rc = run_mcp(json_output=False) + out = capsys.readouterr().out + assert "Cortex doctor" in out + assert "MCP startup diagnostics" in out + assert rc == 1 # missing plugins.json fails + + +def test_run_mcp_subcommand_dispatch(monkeypatch, capsys, tmp_path): + """`cortex-doctor mcp` via the entry point dispatches to run_mcp.""" + from mcp_server import doctor + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("sys.argv", ["cortex-doctor", "mcp", "--json"]) + rc = doctor.run() + out = capsys.readouterr().out + parsed = json.loads(out) + assert "checks" in parsed + assert isinstance(rc, int) + + +# --- postgresql reachability ------------------------------------------- + + +def test_pg_reachable_no_database_url(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + check = _check_pg_reachable() + assert check.ok is False + assert "DATABASE_URL not set" in check.detail + + +def test_pg_reachable_bad_dsn(monkeypatch): + """A DSN pointing at an invalid host yields a verbatim error string.""" + monkeypatch.setenv( + "DATABASE_URL", + "postgresql://nobody@127.0.0.1:1/doesnotexist?connect_timeout=1", + ) + check = _check_pg_reachable() + assert check.ok is False + # Either psycopg is missing (older test env) or the connect failed + # — both are acceptable paths; both must surface a real error string. + assert check.error, "expected a verbatim error from psycopg or import" + + +def test_pg_extensions_no_database_url(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + check = _check_pg_extensions() + assert check.ok is False + assert "DATABASE_URL not set" in check.detail + + +# --- critical imports -------------------------------------------------- + + +def test_critical_imports_returns_check(): + """Hard-deps check returns a real McpCheck — pass or fail. + + We don't assert ok=True because the test env may genuinely lack a + dep. We assert the check has a real signal either way. + """ + check = _check_critical_imports() + assert isinstance(check, McpCheck) + if not check.ok: + # Failure must name the missing module(s). + assert "missing" in check.detail + + +def test_optional_imports_warn_severity(): + """Optional deps check is severity=warn, never severity=fail.""" + check = _check_optional_imports() + if not check.ok: + assert check.severity == "warn", ( + "optional deps must be warn-severity so a missing " + "sentence_transformers does not block MCP startup" + ) + + +# --- printing / colour / copy header ----------------------------------- + + +def test_print_human_with_copy_header(monkeypatch, tmp_path, capsys): + """--copy header is printed before the rest of the output.""" + monkeypatch.setenv("HOME", str(tmp_path)) + report = collect_mcp_report() + _print_human(report, copy_header=True) + out = capsys.readouterr().out + assert "cortex-doctor mcp output" in out + # And the regular header still shows up. + assert "MCP startup diagnostics" in out + + +def test_print_human_without_copy_header(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("HOME", str(tmp_path)) + report = collect_mcp_report() + _print_human(report, copy_header=False) + out = capsys.readouterr().out + assert "please paste" not in out + + +def test_print_human_no_color_in_pipe(monkeypatch, tmp_path, capsys): + """When stdout is not a TTY (capsys), no ANSI escapes leak.""" + monkeypatch.setenv("HOME", str(tmp_path)) + report = collect_mcp_report() + _print_human(report) + out = capsys.readouterr().out + assert "\033[" not in out, "ANSI escapes leaked into non-TTY output" + + +def test_no_color_env_disables_color(monkeypatch, tmp_path): + """NO_COLOR=1 forces colour off even if stdout is a TTY.""" + from mcp_server.doctor_mcp import _colour_enabled + + monkeypatch.setenv("NO_COLOR", "1") + assert _colour_enabled() is False + + +# --- run_mcp --copy ---------------------------------------------------- + + +def test_run_mcp_copy_flag(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("HOME", str(tmp_path)) + rc = run_mcp(json_output=False, copy_header=True) + out = capsys.readouterr().out + assert "please paste" in out + assert isinstance(rc, int) + + +def test_run_mcp_subcommand_dispatch_with_copy(monkeypatch, capsys, tmp_path): + """The doctor.run dispatcher honours --copy.""" + from mcp_server import doctor + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("sys.argv", ["cortex-doctor", "mcp", "--copy"]) + doctor.run() + out = capsys.readouterr().out + assert "please paste" in out + + +# --- skipped probes ---------------------------------------------------- + + +def test_report_includes_skipped_handshake(): + """The MCP stdio handshake is explicitly declared 'I don't know'.""" + report = collect_mcp_report() + assert any("stdio handshake" in s.get("name", "") for s in report.skipped), ( + "stdio handshake must be reported as skipped, not silently absent" + ) + serialized = report.to_dict() + assert "skipped" in serialized + assert serialized["skipped"], "skipped list must be non-empty" + + +# --- check name uniqueness --------------------------------------------- + + +def test_all_check_names_unique(): + """No two checks share a name (would confuse Discord-paste readers).""" + report = collect_mcp_report() + names = [c.name for c in report.checks] + assert len(names) == len(set(names)), f"duplicate names: {names}" From 6baffc99669eb2c6ec733458fb7f4683755007e0 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sat, 9 May 2026 12:29:05 +0200 Subject: [PATCH 3/3] fix(deps): pin tree-sitter-language-pack <1.7 + ruff format CI fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI gates failing on PR #22 — both are latent / dep-drift issues, not introduced by the v3.15.2 fixes themselves. tree-sitter-language-pack 1.7.0+ changed Parser API; pip resolves >=0.24.0 to the latest (1.8.0 on CI right now), breaking 'parser.parse(...)' calls in tests_py/core/test_ast_*.py and tests_py/benchmarks/test_codebase_alteration.py with 'builtins.Parser has no attribute parse'. Pinning <1.7 keeps the contract that v3.15.2 was tested against. The latent break would also hit main on its next CI run; this commit protects both. Lint failure on tests_py/scripts/test_mcp_json_contract.py was a stale ruff format from the parallel-agent merge. --- pyproject.toml | 7 +++++-- tests_py/scripts/test_mcp_json_contract.py | 5 ++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7eeab3b..89fcd03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,11 @@ sqlite = [ "sqlite-vec>=0.1.1", ] codebase = [ - "tree-sitter>=0.24.0", - "tree-sitter-language-pack>=0.24.0", + "tree-sitter>=0.24.0,<0.26", + # Pin <1.7 because tree-sitter-language-pack 1.7.0+ changed the Parser + # API; calls like `parser.parse(...)` raise AttributeError on instances + # of `builtins.Parser`. Migration to the new API is tracked separately. + "tree-sitter-language-pack>=0.24.0,<1.7", "networkx>=3.0", ] # CPU-only graph viz path: precomputed igraph layout + Datashader tile diff --git a/tests_py/scripts/test_mcp_json_contract.py b/tests_py/scripts/test_mcp_json_contract.py index f383413..5d2e9aa 100644 --- a/tests_py/scripts/test_mcp_json_contract.py +++ b/tests_py/scripts/test_mcp_json_contract.py @@ -27,6 +27,7 @@ key in installed_plugins.json, rejecting --plugin-dir / --plugin-url / manual-install scenarios that the documented contract supports). """ + from __future__ import annotations import json @@ -106,6 +107,4 @@ def test_command_is_python3(mcp_config: dict) -> None: spawned process to be able to execute the launcher.py script. """ cmd = mcp_config["mcpServers"]["cortex"]["command"] - assert cmd in ("python3", "python"), ( - f"Expected python3 (or python), got: {cmd!r}" - ) + assert cmd in ("python3", "python"), f"Expected python3 (or python), got: {cmd!r}"