diff --git a/README.md b/README.md index b9f0792..59917fe 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,16 @@ pip install brainctl Requires Python 3.11+. SQLite is built-in. No other mandatory dependencies. +Optional features are pulled in via [extras](https://peps.python.org/pep-0508/#extras). Put them in the **same** install command (they are part of the `brainctl` package, not a separate step) — e.g. `pip install 'brainctl[mcp]'`. **To run the `brainctl-mcp` server you must install the `[mcp]` extra**; a plain `pip install brainctl` does not include it. + ```bash -pip install brainctl[mcp] # MCP server — 100 visible tools for Claude Desktop, Cursor, VS Code -pip install brainctl[vec] # vector similarity search (sqlite-vec + Ollama) -pip install brainctl[signing] # Ed25519-signed memory exports + optional Solana on-chain pinning -pip install brainctl[all] # everything +pip install 'brainctl[mcp]' # MCP server — 100 visible tools for Claude Desktop, Cursor, VS Code +pip install 'brainctl[vec]' # vector similarity search (sqlite-vec + Ollama) +pip install 'brainctl[signing]' # Ed25519-signed memory exports + optional Solana on-chain pinning +pip install 'brainctl[all]' # everything + +# combine extras in one install: +pip install 'brainctl[mcp,vec]' ``` ## 5-line example diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index e16cf13..de79dd9 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -33,7 +33,24 @@ # below at the former _surprise_score_mcp callsite. from agentmemory._impl import _surprise_score logger = logging.getLogger(__name__) -from mcp.server import Server +try: + from mcp.server import Server +except ModuleNotFoundError as _mcp_import_err: + # The MCP SDK ships via the optional `[mcp]` extra. A plain + # `pip install brainctl` (or `pipx install brainctl`) doesn't pull it, + # so without this guard `brainctl-mcp` dies with a bare traceback and + # the MCP client only sees "failed to connect". Surface the actual fix. + if (_mcp_import_err.name or "").split(".")[0] != "mcp": + raise + sys.stderr.write( + "brainctl-mcp requires the MCP server extra, which is not installed.\n" + "Install it with:\n\n" + " pip install 'brainctl[mcp]'\n\n" + "Or, if you installed brainctl with pipx:\n\n" + " pipx inject brainctl 'mcp>=1.27.0' starlette 'uvicorn[standard]' " + "python-json-logger\n" + ) + raise SystemExit(1) # Extension tool modules — each exports TOOLS: list[Tool] and DISPATCH: dict try: diff --git a/tests/test_mcp_missing_extra.py b/tests/test_mcp_missing_extra.py new file mode 100644 index 0000000..abeb71c --- /dev/null +++ b/tests/test_mcp_missing_extra.py @@ -0,0 +1,60 @@ +"""Regression test for the `[mcp]` extra not being installed. + +`brainctl-mcp` (entry point `agentmemory.mcp_server:run`) used to die with a +bare `ModuleNotFoundError: No module named 'mcp'` traceback when the optional +`[mcp]` extra was absent — the MCP client only saw "failed to connect". The +import is now guarded to exit with an actionable install hint instead. + +See issue #159. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "src" + +# A subprocess that hides the `mcp` package (it IS installed in the test env, +# so we block it at import time) and then imports the MCP server module. +_HIDE_MCP_AND_IMPORT = """ +import sys + +class _BlockMcp: + def find_spec(self, name, path, target=None): + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'", name="mcp") + return None + +# Drop anything already cached, then refuse future imports of `mcp`. +for _m in [m for m in sys.modules if m == "mcp" or m.startswith("mcp.")]: + del sys.modules[_m] +sys.meta_path.insert(0, _BlockMcp()) + +import agentmemory.mcp_server # noqa: F401 (import is the thing under test) +""" + + +def _run_with_mcp_hidden() -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", _HIDE_MCP_AND_IMPORT], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "PYTHONPATH": str(SRC)}, + ) + + +def test_missing_mcp_extra_exits_with_actionable_message(): + proc = _run_with_mcp_hidden() + + # Clean exit code, not an unhandled traceback (which would be 1 *with* a + # Traceback dump on stderr). + assert proc.returncode == 1, proc.stderr + assert "Traceback (most recent call last)" not in proc.stderr, proc.stderr + + # The message must tell the user exactly how to fix it. + assert "brainctl[mcp]" in proc.stderr + assert "pip install" in proc.stderr + assert "MCP server extra" in proc.stderr