From 55d5d9581acc7c7573f139b61b7452f2ebf6ee57 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:02:35 -0700 Subject: [PATCH 01/15] feat(mcp): add `aelf mcp` subcommand + python -m fallback for FastMCP server The MCP server module shipped without an entry point. pyproject.toml [project.scripts] exposed no aelf-mcp; cli.py had no `aelf mcp` subcommand; mcp_server.py had no __main__ block. Hosts configuring an MCP server entry had nothing to point at. Adds: - `aelf mcp` subcommand wired to a new _cmd_mcp handler that imports serve(), translates the [mcp]-extra-missing RuntimeError into an actionable stderr message + exit 1, and treats SIGINT as clean exit. - `if __name__ == "__main__": serve()` guard in mcp_server.py so `python -m aelfrice.mcp_server` is a usable fallback. - tests/test_cli_mcp.py covering: subcommand registration, --help visibility, missing-fastmcp error path, __main__ guard, module resolution, and a static guard against print()-to-stdout regressions (stdio MCP servers must keep stdout clean for JSON-RPC). Phase 1 C1 of the mcp-server-properly-built audit (closes critical gap: server is unstartable as shipped). --- src/aelfrice/cli.py | 44 ++++++++++++++ src/aelfrice/mcp_server.py | 7 +++ tests/test_cli_mcp.py | 117 +++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 tests/test_cli_mcp.py diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index fbb3ad041..cdd09c104 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -2292,6 +2292,40 @@ def _cmd_statusline(args: argparse.Namespace, out: object) -> int: return 0 +def _cmd_mcp(args: argparse.Namespace, out: object) -> int: + """Start the FastMCP stdio server exposing the aelfrice tool surface. + + Requires the `[mcp]` extra: `pip install 'aelfrice[mcp]'` (or + `uv tool install --with fastmcp aelfrice`). Blocks until the host + closes the stdio pipes; SIGINT exits cleanly with status 0. + + stdio MCP servers must never write to stdout — that channel carries + the JSON-RPC protocol. The aelfrice tool handlers return dicts and + never print; fastmcp itself respects the boundary. + """ + _ = (args, out) + try: + from aelfrice.mcp_server import serve + except ImportError as exc: # pragma: no cover — defensive + print( + f"error: aelfrice.mcp_server import failed: {exc}", + file=sys.stderr, + ) + return 1 + try: + serve() + except RuntimeError as exc: + # serve() raises RuntimeError when fastmcp is not installed — + # the message includes the install hint. + print(f"error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + # Clean stop on Ctrl-C; hosts may signal shutdown via SIGINT + # and a traceback would clutter their logs. + return 0 + return 0 + + _UPGRADE_CONTEXT_NOTE: dict[str, str] = { "uv_tool": "installed via uv tool — use uv to upgrade", "pipx": "installed via pipx — use pipx to upgrade", @@ -4427,6 +4461,16 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: p_statusline = sub.add_parser("statusline", help=argparse.SUPPRESS) p_statusline.set_defaults(func=_cmd_statusline) + # `aelf mcp`: start the FastMCP stdio server. Visible in --help so + # hosts (Claude Desktop / Claude Code) configuring an MCP entry can + # discover it; the [mcp] extra must be installed for it to actually + # run. + p_mcp = sub.add_parser( + "mcp", + help="start the FastMCP stdio server (requires aelfrice[mcp])", + ) + p_mcp.set_defaults(func=_cmd_mcp) + # Hidden: the orange statusline banner already prompts users when an # update is pending — direct CLI invocation is auxiliary. # diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index d6c8b0fde..7a7e6387c 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -688,3 +688,10 @@ def aelf_health() -> dict[str, Any]: del _registered mcp.run() + + +if __name__ == "__main__": # pragma: no cover — exercised by `aelf mcp` + # `python -m aelfrice.mcp_server` is the fallback entry point for + # hosts that prefer module invocation over the `aelf mcp` console + # script. Both routes call serve() identically. + serve() diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py new file mode 100644 index 000000000..ee653cd80 --- /dev/null +++ b/tests/test_cli_mcp.py @@ -0,0 +1,117 @@ +"""`aelf mcp` CLI subcommand — start the FastMCP stdio server. + +The MCP server module ships in every install, but the FastMCP runtime +ships under the `[mcp]` extra. These tests verify that the subcommand +is wired and that invoking it without the extra produces an actionable +error rather than an opaque ImportError or silent no-op. +""" +from __future__ import annotations + +import argparse +import io + + +def test_mcp_subcommand_registered() -> None: + from aelfrice.cli import _known_cli_subcommands + + assert "mcp" in _known_cli_subcommands() + + +def test_cmd_mcp_returns_one_with_actionable_error_when_fastmcp_missing( + capsys: object, +) -> None: + """fastmcp is not in dev deps; _cmd_mcp must return 1 + stderr hint.""" + from aelfrice.cli import _cmd_mcp + + ns = argparse.Namespace() + out = io.StringIO() + rc = _cmd_mcp(ns, out) + captured = capsys.readouterr() # type: ignore[attr-defined] + assert rc == 1 + assert "aelfrice[mcp]" in captured.err + assert "fastmcp" in captured.err.lower() + + +def test_mcp_server_module_has_main_guard() -> None: + """`python -m aelfrice.mcp_server` is the documented fallback entry. + + Verify the module file contains the `__main__` guard so tooling that + introspects the module can confirm the entry point exists. This is + a structural check, not a runtime check (running the guard would + block on stdio). + """ + import aelfrice.mcp_server as mod + + src = open(mod.__file__, "r", encoding="utf-8").read() + assert 'if __name__ == "__main__"' in src + assert "serve()" in src.split('if __name__ == "__main__"')[1] + + +def test_mcp_subcommand_help_string_mentions_mcp_extra() -> None: + """`aelf --help` should advertise that mcp needs the [mcp] extra.""" + from aelfrice.cli import build_parser + + parser = build_parser() + buf = io.StringIO() + parser.print_help(file=buf) + text = buf.getvalue() + assert "mcp" in text + assert "aelfrice[mcp]" in text + + +def test_mcp_subcommand_help_short_circuits_invocation() -> None: + """`aelf mcp --help` must not actually start the server.""" + from aelfrice.cli import build_parser + + parser = build_parser() + try: + parser.parse_args(["mcp", "--help"]) + except SystemExit as exc: + # argparse's --help raises SystemExit(0); the test passes if we + # got here without serve() ever running. + assert exc.code == 0 + return + assert False, "argparse --help should have raised SystemExit" + + +def test_python_dash_m_mcp_server_module_resolves() -> None: + """`python -m aelfrice.mcp_server` resolves to the right module. + + We don't actually run it (would block on stdio); we just confirm the + module is importable as a script target via runpy's name resolution. + """ + import importlib.util + + spec = importlib.util.find_spec("aelfrice.mcp_server") + assert spec is not None + assert spec.name == "aelfrice.mcp_server" + assert spec.origin and spec.origin.endswith("mcp_server.py") + + +# --- stdout discipline check (regression guard) ------------------------- + + +def test_mcp_handlers_never_print_to_stdout() -> None: + """stdio MCP servers must never write to stdout (it carries JSON-RPC). + + Static check: scan mcp_server.py source for `print(`. Any hit must be + prefixed with `# allow-print` to be acceptable. This catches future + regressions where someone adds a debug print without realizing the + constraint. + """ + import aelfrice.mcp_server as mod + + src = open(mod.__file__, "r", encoding="utf-8").read() + for lineno, line in enumerate(src.splitlines(), 1): + if "print(" in line and "# allow-print" not in line: + # `print(` may appear in strings or comments — only fail on + # actual statements. Crude heuristic: skip lines that are + # inside a docstring or comment. + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith('"'): + continue + assert False, ( + f"mcp_server.py:{lineno} contains a print() call: {line!r}. " + "stdio MCP servers must not write to stdout. Use stderr or " + "fastmcp's logging API instead." + ) From 7a65829f2788a53691a996ed2fd26c8217e064ec Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:07:34 -0700 Subject: [PATCH 02/15] feat(mcp): docstrings on all 12 @mcp.tool wrappers + AST regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastMCP reads the @mcp.tool-decorated function's docstring as the tool's `description` field exposed to the host LLM. The wrappers in serve() had none, so hosts got an empty description — tools were essentially undiscoverable, even though the underlying tool_* pure handlers had their own docstrings (which FastMCP never sees). Each wrapper now has: - one-line summary of purpose - read-only / mutating posture stated in prose - Args with examples + constraint hints - Returns with the discriminating `kind` enum and full payload schema Adds tests/test_cli_mcp.py::test_every_decorated_aelf_tool_has_a_docstring as a static regression guard: parses mcp_server.py with `ast`, finds all @mcp.tool decorators inside serve(), asserts each decorated fn has a non-empty docstring. Catches future tool additions that forget the description. Phase 1 C2 of the mcp-server-properly-built audit. --- src/aelfrice/mcp_server.py | 203 +++++++++++++++++++++++++++++++++++++ tests/test_cli_mcp.py | 50 +++++++++ 2 files changed, 253 insertions(+) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index 7a7e6387c..d0f56ae27 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -560,6 +560,29 @@ def aelf_onboard( session_id: str | None = None, classifications: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + """Polymorphic ingest entrypoint for a project's belief corpus. + + Three input shapes drive the same tool. The host LLM picks shape + by which fields it supplies; pass nothing to inspect state. + + Args: + path: Absolute filesystem path to the project root. When set, + starts an onboard session and returns the candidate + sentences for the host to classify (e.g. "/Users/me/proj"). + session_id: An ID returned by a prior path-shape call. When + set, finalizes that session by accepting the host's + classifications. + classifications: List of host verdicts to accept; required + when session_id is set. Each item: {"index": int, + "belief_type": str, "persist": bool}. + + Returns: dict with discriminating `kind`: + - "onboard.session_started": {session_id, n_already_present, + sentences:[{index, text, source}]} + - "onboard.session_completed": {session_id, inserted, + skipped_non_persisting, skipped_existing, skipped_unclassified} + - "onboard.status": {n_pending, pending_session_ids} + """ store = _open_default_store() try: return tool_onboard( @@ -573,6 +596,23 @@ def aelf_onboard( def aelf_search( query: str, budget: int = DEFAULT_TOKEN_BUDGET, ) -> dict[str, Any]: + """Retrieve beliefs matching a free-text query, ranked by BM25. + + Locked beliefs (L0) auto-load above BM25 hits (L1). Use this to + recall constraints, prior decisions, or facts the agent should + know before acting. Read-only; never mutates the store. + + Args: + query: Search string. Whitespace-separated terms; SQLite + FTS5 syntax is honored (e.g. "auth NEAR token", quoted + phrases). Examples: "release process", "uv tool upgrade". + budget: Soft token budget for the response. Defaults to the + module's DEFAULT_TOKEN_BUDGET; lower values trim hits. + + Returns: {"kind": "search.results", "n_hits": int, + "hits": [{"id": str, "content": str, + "lock_level": str, "type": str}, ...]} + """ store = _open_default_store() try: return tool_search(store, query=query, budget=budget) @@ -581,6 +621,21 @@ def aelf_search( @mcp.tool() def aelf_lock(statement: str) -> dict[str, Any]: + """Lock a statement as user-asserted ground truth (L0). + + Use when the user has explicitly stated a non-negotiable rule, + constraint, or fact that future sessions must respect. Re-locking + the same content refreshes the lock without creating a duplicate. + Mutates: creates or upgrades a belief. + + Args: + statement: The free-text claim to lock. Treated verbatim; + no rewriting. Example: "All commits must be signed". + + Returns: {"kind": one of [lock.created, lock.upgraded, + lock.corroborated, lock.error], + "id": belief_id, "action": str} + """ store = _open_default_store() try: return tool_lock(store, statement=statement) @@ -589,6 +644,21 @@ def aelf_lock(statement: str) -> dict[str, Any]: @mcp.tool() def aelf_locked(pressured: bool = False) -> dict[str, Any]: + """List all user-locked (L0) beliefs in the store. + + Use to show the user their current ground-truth set, or to find + candidates for unlock/demote. Read-only. + + Args: + pressured: If True, return only locks whose demotion_pressure + is greater than zero (i.e. ones being challenged by + contradicting evidence). Default False returns all locks. + + Returns: {"kind": "locked.list", "n": int, + "locked": [{"id": str, "content": str, + "demotion_pressure": int, + "locked_at": str}, ...]} + """ store = _open_default_store() try: return tool_locked(store, pressured=pressured) @@ -597,6 +667,22 @@ def aelf_locked(pressured: bool = False) -> dict[str, Any]: @mcp.tool() def aelf_demote(belief_id: str) -> dict[str, Any]: + """Demote a belief one tier — drop a lock OR devalidate. + + For a user-locked (L0) belief: clears the lock to L1. + For a user_validated belief: drops origin to agent_inferred. + For other beliefs: no-op. Mutates origin/lock fields. + + Args: + belief_id: Stable hash-prefix ID returned by aelf_search, + aelf_lock, or aelf_locked. + + Returns: {"kind": one of [demote.demoted, demote.devalidated, + demote.not_locked, demote.not_found], + "id": belief_id, "demoted": bool, + "tier": str (when devalidated), + "error": str (when not_found)} + """ store = _open_default_store() try: return tool_demote(store, belief_id=belief_id) @@ -607,6 +693,24 @@ def aelf_demote(belief_id: str) -> dict[str, Any]: def aelf_validate( belief_id: str, source: str = "user_validated", ) -> dict[str, Any]: + """Promote agent_inferred → user_validated (no lock applied). + + Use when the user explicitly confirms an agent-inferred claim is + correct, but does not want to lock it as ground truth. Writes + an audit row 'promotion:'. Mutates origin field. + + Args: + belief_id: ID of the agent_inferred belief to promote. + source: Audit-row source suffix; defaults to "user_validated". + Override only when a non-canonical source is appropriate + (e.g. an automated promotion pipeline). + + Returns: {"kind": one of [validate.promoted, validate.already, + validate.error], + "id": str, "prior_origin": str, "new_origin": str, + "audit_event_id": int (when promoted), + "error": str (when error)} + """ store = _open_default_store() try: return tool_validate( @@ -617,6 +721,21 @@ def aelf_validate( @mcp.tool() def aelf_unlock(belief_id: str) -> dict[str, Any]: + """Drop a user-lock without changing the belief's origin. + + Idempotent: calling on an already-unlocked belief returns + unlocked=False. Always writes a 'lock:unlock' audit row when the + lock was actually cleared. Mutates lock_level from L0 to L1. + + Args: + belief_id: ID of the locked belief to unlock. + + Returns: {"kind": one of [unlock.unlocked, unlock.already, + unlock.not_found], + "id": str, "unlocked": bool, + "audit_event_id": int (when unlocked), + "error": str (when not_found)} + """ store = _open_default_store() try: return tool_unlock(store, belief_id=belief_id) @@ -627,6 +746,14 @@ def aelf_unlock(belief_id: str) -> dict[str, Any]: def aelf_promote( belief_id: str, source: str = "user_validated", ) -> dict[str, Any]: + """Alias of aelf_validate. Identical semantics and return shape. + + Exposed under both names so callers can use whichever verb reads + more naturally for their use case ('promote' for tier transitions, + 'validate' for verification flows). + + Args, Returns: see aelf_validate. + """ store = _open_default_store() try: return tool_promote( @@ -639,6 +766,29 @@ def aelf_promote( def aelf_feedback( belief_id: str, signal: str, source: str = "user", ) -> dict[str, Any]: + """Record positive or negative feedback on a belief's usefulness. + + Updates the Beta-Bernoulli posterior (alpha for 'used', beta for + 'harmful'). Negative feedback also walks the contradiction graph + and increments demotion_pressure on supporting locks. Mutates + posterior + audit + (potentially) lock pressure. + + Args: + belief_id: Target belief ID. + signal: Either "used" (positive valence, +1) or "harmful" + (negative valence, -1). Other values return a bad_signal + error without mutating. + source: Free-text label for the feedback origin. Defaults to + "user". Used for audit and provenance. + + Returns: {"kind": one of [feedback.applied, feedback.bad_signal, + feedback.unknown_belief], + "id": str, "signal": str, + "prior_alpha": float, "new_alpha": float, + "prior_beta": float, "new_beta": float, + "pressured_locks": list[str], "demoted_locks": list[str], + "error": str (on error variants)} + """ store = _open_default_store() try: return tool_feedback( @@ -653,6 +803,28 @@ def aelf_confirm( source: str = _CONFIRM_SOURCE_DEFAULT, note: str = "", ) -> dict[str, Any]: + """Affirm an existing belief without locking it (bumps posterior). + + Use when the user reviews a belief and says it's correct, but the + commitment is softer than aelf_lock would imply. Records source + as 'user_confirmed' by default so confirms are distinguishable + from generic 'used' feedback in the audit table. Mutates + posterior and may pressure contradicting locks. + + Args: + belief_id: Target belief ID. + source: Audit source label. Default 'user_confirmed'. + note: Optional free-text annotation. Returned on the response + payload for the caller's context; NOT persisted. + + Returns: {"kind": one of [confirm.applied, confirm.unknown_belief], + "id": str, "source": str, + "prior_alpha": float, "new_alpha": float, + "prior_beta": float, "new_beta": float, + "pressured_locks": list[str], "demoted_locks": list[str], + "note": str (when supplied), + "error": str (when unknown_belief)} + """ store = _open_default_store() try: return tool_confirm( @@ -663,6 +835,20 @@ def aelf_confirm( @mcp.tool() def aelf_stats() -> dict[str, Any]: + """Return summary counts for the local belief store. + + Cheap snapshot — no graph walk, no scoring. Use to verify the + store is populated, to gauge corpus size, or as a heartbeat. + Read-only. + + Returns: {"kind": "stats.snapshot", + "beliefs": int, + "edges": int, # v1.0 key (deprecated, removed v1.2) + "threads": int, # v1.1 key (forward-compatible alias) + "locked": int, + "feedback_events": int, + "onboard_sessions_total": int} + """ store = _open_default_store() try: return tool_stats(store) @@ -671,6 +857,23 @@ def aelf_stats() -> dict[str, Any]: @mcp.tool() def aelf_health() -> dict[str, Any]: + """Classify the store's current operating regime + describe it. + + Runs the regime classifier over a small feature set (corpus size, + confidence stats, lock density, edge density). Use as a coarse + diagnostic before taking weighty mutating actions, or to drive + UX nudges (e.g. 'too few locks for this stage'). Read-only. + + Returns: {"kind": "health.report", + "regime": str, + "description": str, + "classification_confidence": float (omitted when + insufficient data), + "features": {n_beliefs, confidence_mean, + confidence_median, mass_mean, lock_per_1000, + edge_per_belief, thread_per_belief} + (omitted when insufficient data)} + """ store = _open_default_store() try: return tool_health(store) diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py index ee653cd80..e385949ca 100644 --- a/tests/test_cli_mcp.py +++ b/tests/test_cli_mcp.py @@ -88,6 +88,56 @@ def test_python_dash_m_mcp_server_module_resolves() -> None: assert spec.origin and spec.origin.endswith("mcp_server.py") +# --- tool description coverage (FastMCP reads decorator-fn docstring) -- + + +def test_every_decorated_aelf_tool_has_a_docstring() -> None: + """FastMCP exposes the @mcp.tool-decorated function's docstring as the + tool's `description`. An empty docstring means the host LLM gets no + guidance on when/how to call the tool — discoverability collapses. + + This is a static guard: parse mcp_server.py with `ast`, find every + `@mcp.tool()` decorator inside `serve()`, and assert the decorated + function has a non-empty docstring. + """ + import ast + import aelfrice.mcp_server as mod + + src = open(mod.__file__, "r", encoding="utf-8").read() + tree = ast.parse(src) + + serve_fn = next( + (node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "serve"), + None, + ) + assert serve_fn is not None, "serve() function not found in mcp_server.py" + + decorated_fns: list[ast.FunctionDef] = [] + for node in ast.walk(serve_fn): + if isinstance(node, ast.FunctionDef): + for dec in node.decorator_list: + # Match `@mcp.tool()` — Call whose func is Attribute(attr='tool') + if (isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and dec.func.attr == "tool"): + decorated_fns.append(node) + break + + assert len(decorated_fns) >= 12, ( + f"expected at least 12 @mcp.tool functions, found {len(decorated_fns)}" + ) + + missing = [ + fn.name for fn in decorated_fns + if not (ast.get_docstring(fn) or "").strip() + ] + assert not missing, ( + f"@mcp.tool functions missing docstrings (host LLM sees no " + f"description): {missing}" + ) + + # --- stdout discipline check (regression guard) ------------------------- From ce9b68988815b0b8fe84c16259b7c3db6f529afd Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:09:38 -0700 Subject: [PATCH 03/15] docs(mcp): document `aelf mcp` entrypoint + fix stale aelf-mcp/upgrade refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/MCP.md previously documented two non-existent invocation paths: - `aelf-mcp` console script (never wired in pyproject [project.scripts]) - `python -m aelfrice.mcp_server.serve` (invalid — `python -m` runs a module's __main__, not a function) Replaces with the two paths actually shipped in feat/mcp-server-properly-built: - `aelf mcp` (CLI subcommand) - `python -m aelfrice.mcp_server` (now resolvable via the new __main__ guard) Updated host-config example to use `command: "aelf", args: ["mcp"]` for end-user installs; kept the `uv run --project` form as a source-checkout variant. docs/COMMANDS.md: adds a `mcp` row to the lifecycle table and renames the `upgrade` row to `upgrade-cmd` to match the post-#427 canonical name (the deprecated alias note is preserved). Phase 1 C3 of the mcp-server-properly-built audit. Server is now both startable AND documented. --- docs/COMMANDS.md | 3 ++- docs/MCP.md | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index fdcd0db7b..26a5898ea 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -50,10 +50,11 @@ DB resolves from `$AELFRICE_DB`, then `/aelfrice/memory.db` when |---|---| | `setup` | Install the `UserPromptSubmit` hook + statusline notifier. Auto-detects scope (`project` if `cwd/.venv` matches the active interpreter, else `user`). Idempotent + atomic. Optional flags: `--transcript-ingest`, `--commit-ingest`, `--session-start`, `--rebuilder`. | | `unsetup` | Remove the hook and our statusline contribution. Composed statuslines are surgically unwrapped. Mirrors `setup` flags. | -| `upgrade [--check]` | Print the pip-upgrade command for the active env (venv / pipx / system). Includes wheel SHA-256 for hash-pinned installs. Does not run pip. | +| `upgrade-cmd [--check]` | Print the install-method-aware upgrade command (uv tool / pipx / venv / system). Includes wheel SHA-256 for hash-pinned installs. Does not run the upgrade itself — replacing the running interpreter mid-process is unreliable. (Bare `upgrade` remains as a deprecated alias for one minor.) | | `uninstall (--keep-db \| --archive PATH \| --purge)` | Tear down aelfrice. One disposition flag required. `--purge` has three confirmation gates. `--archive` writes a Fernet-encrypted file then deletes the original. | | `migrate [--from P] [--apply] [--all]` | Port beliefs from the legacy global DB into the active project's per-project DB. Dry-run by default. Read-only on the source. | | `statusline` | Emit the update-banner snippet (or empty). Reads cache only, no network. | +| `mcp` | Start the FastMCP stdio server exposing the 12 memory tools. Requires the `[mcp]` extra (`pip install 'aelfrice[mcp]'`). Blocks; SIGINT exits cleanly. Hosts can also use `python -m aelfrice.mcp_server`. See [MCP](MCP.md). | | `ingest-transcript [PATH \| --batch DIR] [--since DATE]` | Ingest one `turns.jsonl` file or batch-walk a directory. Auto-detects aelfrice and Claude Code formats. Idempotent. | | `rebuild [--transcript PATH] [--n N] [--budget N]` | Manual context-rebuilder run (alpha; normally fires on `PreCompact`). Prints the rebuild block to stdout. | | `project-warm [--debounce N]` | CwdChanged hook entry point. Resolves `` to a project root (git work-tree or `~/.aelfrice/projects//`-provisioned ancestor), pre-loads the SQLite + OS page cache, and writes a sentinel under `~/.aelfrice/projects//.last_warm`. Silent no-op for unknown paths, denied paths (default deny: `/tmp/**`, `/var/folders/**`, `~/Downloads/**`, `~/Desktop/**` — override via `~/.aelfrice/config.json` `project_warm.deny_globs`), and any call inside the 60-second debounce window. Always exits 0; never writes to stdout. | diff --git a/docs/MCP.md b/docs/MCP.md index 38cdea9d4..f67f7ed4d 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -2,23 +2,50 @@ aelfrice exposes twelve memory tools through a [Model Context Protocol](https://modelcontextprotocol.io) server. The agent calls them mid-turn; you don't have to invoke them yourself. -Lifecycle commands (`setup`, `unsetup`, `migrate`, `doctor`, `upgrade`, `uninstall`) are CLI-only. +Lifecycle commands (`setup`, `unsetup`, `migrate`, `doctor`, `upgrade-cmd`, `uninstall`) are CLI-only. ## Install + run +The MCP server ships in every install of aelfrice, but the FastMCP runtime is gated behind the `[mcp]` extra: + ```bash +# pip pip install "aelfrice[mcp]" -uv run python -m aelfrice.mcp_server # or just `aelf-mcp` after install + +# uv tool +uv tool install --with fastmcp aelfrice +``` + +Two equivalent ways to start the server (both speak stdio): + +```bash +aelf mcp # console-script entry (preferred) +python -m aelfrice.mcp_server # module-exec fallback +``` + +If `fastmcp` is missing, `aelf mcp` exits 1 with an actionable message (`error: fastmcp is not installed. Install with: pip install aelfrice[mcp]`) — no traceback, no half-started server. + +Host config — Claude Code, Codex, Claude Desktop, any MCP-capable host: + +```json +{ + "mcpServers": { + "aelfrice": { + "command": "aelf", + "args": ["mcp"] + } + } +} ``` -Host config — Claude Code, Codex, any MCP-capable host: +Working from a source checkout instead? Point the host at `uv` so it picks up the project's local interpreter: ```json { "mcpServers": { "aelfrice": { "command": "uv", - "args": ["run", "--project", "/abs/path/to/aelfrice", "python", "-m", "aelfrice.mcp_server"] + "args": ["run", "--project", "/abs/path/to/aelfrice", "aelf", "mcp"] } } } From 91b14c1f8ff3adfda3e2b2a16287dec841409594 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:10:19 -0700 Subject: [PATCH 04/15] =?UTF-8?q?gate:=20phase=201=20=E2=80=94=20MCP=20ser?= =?UTF-8?q?ver=20is=20now=20startable=20+=20discoverable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This gate closes phase 1 of the mcp-server-properly-built audit. Three atomic commits on this branch resolve the two CRITICAL findings (server unstartable as shipped; tools have empty descriptions) and add docs for the new entrypoint. Commits in scope: 9adca85 feat(mcp): add `aelf mcp` subcommand + python -m fallback 904bfb0 feat(mcp): docstrings on all 12 @mcp.tool wrappers + AST guard 455eaac docs(mcp): document `aelf mcp` entrypoint + fix stale refs Verification: - pytest tests/test_cli_mcp.py tests/test_mcp_server.py: 48 passed (40 prior + 8 new) - aelf --help: 'mcp' subcommand appears in subcommand list with help string - aelf mcp (no [mcp] extra): exits 1, stderr "error: fastmcp is not installed" - AST static guard test_every_decorated_aelf_tool_has_a_docstring: passes - AST static guard test_mcp_handlers_never_print_to_stdout: passes - python -m aelfrice.mcp_server: importlib resolves the module - docs/MCP.md: install/run section now matches shipped behavior - docs/COMMANDS.md: lifecycle table includes mcp row, upgrade-cmd canonical Blockers (require user decision before next phase): - [user] Greenlight phase 2 (annotations + instructions= + tool_lock assert fix + stale-docstring fix) on this same branch? - [user] Phase 1 alone is mergeable as-is — server works for the first time. Open PR now or wait for phases 2-4 to land first? - [user] Re-run `uv tool upgrade aelfrice` on your local install? Phase 1 has no impact until installed; you're still on v1.6.0. Open questions: (none) Rollback: - git revert 455eaac 904bfb0 9adca85 # in this order; rolls phase 1 back to a40d546 From 87758887fd7966c3e7b9135b4d649da6758ed64c Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:16:37 -0700 Subject: [PATCH 05/15] =?UTF-8?q?feat(mcp):=20annotations=20on=20every=20@?= =?UTF-8?q?mcp.tool=20=E2=80=94=20readOnly/destructive/idempotent/openWorl?= =?UTF-8?q?d=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP hosts use tool annotations to gate dangerous operations (e.g. require human approval for destructiveHint=True tools, or auto-allow read-only ones). Without annotations, fastmcp's defaults are destructiveHint=True + openWorldHint=True per spec — the worst-of-both-worlds default that forces approval prompts on read-only tools and discloses no constraint on actually-destructive ones. Annotation matrix (all openWorldHint=False — local SQLite, no network): Tool read destr idem aelf_search Y N Y (FTS5 lookup) aelf_locked Y N Y (list) aelf_stats Y N Y (counts) aelf_health Y N Y (regime classifier) aelf_lock N N Y (re-lock = upgrade, content-addressed) aelf_validate N N Y (origin promotion; already_validated no-op) aelf_unlock N N Y (lock clear; already_unlocked no-op) aelf_promote N N Y (alias of validate) aelf_demote N Y N (drops a tier; only mutating tool tagged destructive) aelf_feedback N N N (Beta posterior shifts each call) aelf_confirm N N N (Beta posterior shifts each call) aelf_onboard N N N (start/accept/status — worst-case posture) Adds tests/test_cli_mcp.py::test_every_decorated_aelf_tool_has_annotations as a static AST guard: every @mcp.tool() must pass an `annotations={...}` dict containing all four required hint keys. Catches future tool additions that forget to annotate. Phase 2 M1 of the mcp-server-properly-built audit. --- src/aelfrice/mcp_server.py | 120 +++++++++++++++++++++++++++++++++---- tests/test_cli_mcp.py | 67 +++++++++++++++++++++ 2 files changed, 175 insertions(+), 12 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index d0f56ae27..207b1a8ba 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -554,7 +554,15 @@ def serve() -> None: _FastMCP: Any = _FastMCPCls mcp: Any = _FastMCP(name="aelfrice") - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Onboard project into the belief store", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + ) def aelf_onboard( path: str | None = None, session_id: str | None = None, @@ -592,7 +600,15 @@ def aelf_onboard( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Search beliefs by query", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_search( query: str, budget: int = DEFAULT_TOKEN_BUDGET, ) -> dict[str, Any]: @@ -619,7 +635,15 @@ def aelf_search( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Lock a belief as ground truth", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_lock(statement: str) -> dict[str, Any]: """Lock a statement as user-asserted ground truth (L0). @@ -642,7 +666,15 @@ def aelf_lock(statement: str) -> dict[str, Any]: finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "List user-locked beliefs", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_locked(pressured: bool = False) -> dict[str, Any]: """List all user-locked (L0) beliefs in the store. @@ -665,7 +697,15 @@ def aelf_locked(pressured: bool = False) -> dict[str, Any]: finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Demote a belief one tier", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, + ) def aelf_demote(belief_id: str) -> dict[str, Any]: """Demote a belief one tier — drop a lock OR devalidate. @@ -689,7 +729,15 @@ def aelf_demote(belief_id: str) -> dict[str, Any]: finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Validate (promote) an agent-inferred belief", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_validate( belief_id: str, source: str = "user_validated", ) -> dict[str, Any]: @@ -719,7 +767,15 @@ def aelf_validate( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Unlock a belief (clears L0 lock)", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_unlock(belief_id: str) -> dict[str, Any]: """Drop a user-lock without changing the belief's origin. @@ -742,7 +798,15 @@ def aelf_unlock(belief_id: str) -> dict[str, Any]: finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Promote (validate) an agent-inferred belief", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_promote( belief_id: str, source: str = "user_validated", ) -> dict[str, Any]: @@ -762,7 +826,15 @@ def aelf_promote( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Record feedback on a belief", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + ) def aelf_feedback( belief_id: str, signal: str, source: str = "user", ) -> dict[str, Any]: @@ -797,7 +869,15 @@ def aelf_feedback( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Confirm a belief (positive valence)", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + ) def aelf_confirm( belief_id: str, source: str = _CONFIRM_SOURCE_DEFAULT, @@ -833,7 +913,15 @@ def aelf_confirm( finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Snapshot belief-store counts", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_stats() -> dict[str, Any]: """Return summary counts for the local belief store. @@ -855,7 +943,15 @@ def aelf_stats() -> dict[str, Any]: finally: store.close() - @mcp.tool() + @mcp.tool( + annotations={ + "title": "Classify store operating regime", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ) def aelf_health() -> dict[str, Any]: """Classify the store's current operating regime + describe it. diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py index e385949ca..f47dc452d 100644 --- a/tests/test_cli_mcp.py +++ b/tests/test_cli_mcp.py @@ -91,6 +91,73 @@ def test_python_dash_m_mcp_server_module_resolves() -> None: # --- tool description coverage (FastMCP reads decorator-fn docstring) -- +def test_every_decorated_aelf_tool_has_annotations() -> None: + """Every @mcp.tool() must pass an `annotations={...}` dict with the + four MCP behavioral hints. Hosts use these to gate dangerous tools + (e.g. require approval for destructiveHint=True). An unannotated + tool defaults to destructiveHint=True and openWorldHint=True per + spec — the worst-of-both-worlds default. + + Static AST guard: parse mcp_server.py, find every @mcp.tool() call + inside serve(), assert the call kwargs include 'annotations' and + that the annotations dict has all four required hint keys. + """ + import ast + import aelfrice.mcp_server as mod + + required_keys = { + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + } + + src = open(mod.__file__, "r", encoding="utf-8").read() + tree = ast.parse(src) + + serve_fn = next( + (n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == "serve"), + None, + ) + assert serve_fn is not None + + missing: list[str] = [] + incomplete: list[tuple[str, set[str]]] = [] + seen = 0 + for node in ast.walk(serve_fn): + if not isinstance(node, ast.FunctionDef): + continue + for dec in node.decorator_list: + if (isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and dec.func.attr == "tool"): + seen += 1 + ann_kw = next( + (kw for kw in dec.keywords if kw.arg == "annotations"), + None, + ) + if ann_kw is None: + missing.append(node.name) + break + if not isinstance(ann_kw.value, ast.Dict): + missing.append(node.name) + break + hint_keys = { + k.value for k in ann_kw.value.keys + if isinstance(k, ast.Constant) and isinstance(k.value, str) + } + if not required_keys.issubset(hint_keys): + incomplete.append((node.name, required_keys - hint_keys)) + break + + assert seen >= 12, f"expected >=12 @mcp.tool decorators, found {seen}" + assert not missing, f"@mcp.tool decorators missing annotations=: {missing}" + assert not incomplete, ( + f"@mcp.tool annotations missing required hint keys: {incomplete}" + ) + + def test_every_decorated_aelf_tool_has_a_docstring() -> None: """FastMCP exposes the @mcp.tool-decorated function's docstring as the tool's `description`. An empty docstring means the host LLM gets no From aa2016c0961e87fd27bf709a17f0f0caed618596 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:18:12 -0700 Subject: [PATCH 06/15] feat(mcp): server instructions= overview + fix stale 9-vs-12 tools comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a module-level _SERVER_INSTRUCTIONS constant and passes it to the FastMCP(...) constructor. Hosts that surface the instructions field (Claude Desktop, recent fastmcp clients) now receive a server-level overview at registration time grouping tools by READ / WRITE / TIER posture and naming the local-only network constraint. The overview is concise on purpose: hosts treat instructions as a hint, not a manual; per-tool docstrings (added in 904bfb0) carry detail. Also fixes the stale module docstring claim "exposing the 9 user-visible tools" — actual count is 12 (onboard, search, lock, locked, demote, validate, unlock, promote, feedback, confirm, stats, health). Adds unlock + promote rows to the surface table (they were missing entirely). Adds tests/test_cli_mcp.py::test_server_passes_instructions_to_fastmcp as a static AST guard on the constructor call + a sanity check that _SERVER_INSTRUCTIONS is non-trivial (>100 chars stripped). Phase 2 M2 + M3 of the mcp-server-properly-built audit. --- src/aelfrice/mcp_server.py | 33 +++++++++++++++++++++++++++-- tests/test_cli_mcp.py | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index 207b1a8ba..d17f5b849 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -1,5 +1,5 @@ # pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUntypedFunctionDecorator=false, reportUnusedFunction=false -"""MCP server exposing the 9 user-visible tools. +"""MCP server exposing the 12 user-visible tools. The same surface as the CLI, accessible from any host that speaks the Model Context Protocol. The handlers are pure Python — they take a @@ -23,6 +23,8 @@ aelf:locked {pressured?} -> locked beliefs aelf:demote {belief_id} -> demoted bool aelf:validate {belief_id, source?} -> origin promotion + aelf:unlock {belief_id} -> lock cleared + aelf:promote {belief_id, source?} -> alias of validate aelf:feedback {belief_id, signal, source?} -> updated priors aelf:confirm {belief_id, source?, note?} -> affirmed priors aelf:stats {} -> counts @@ -68,6 +70,30 @@ _FEEDBACK_VALENCES: Final[dict[str, float]] = {"used": 1.0, "harmful": -1.0} +# Server-level overview shown to host LLMs at registration time. Concise +# on purpose — hosts that surface the instructions field treat it as a +# hint, not a manual; per-tool docstrings carry the detail. +_SERVER_INSTRUCTIONS: Final[str] = """\ +aelfrice exposes a local belief store: a small SQLite-backed memory of +locked rules, validated facts, and decay-managed agent inferences for +the current project. Tools fall into three groups: + +- READ (search, locked, stats, health): retrieve or summarize beliefs + before acting. Cheap, idempotent, no host approval needed. +- WRITE (lock, validate, promote, unlock, feedback, confirm, onboard): + introduce or refine beliefs based on user signals. Idempotent where + marked; otherwise expect each call to shift posterior or audit state. +- TIER (demote): the only destructively-flagged tool. Drops a lock or + devalidates a belief one tier; reversible only by re-locking with + fresh evidence. + +When unsure what already exists, call aelf_search before aelf_lock. +When the user explicitly asserts a non-negotiable rule, prefer aelf_lock +over aelf_confirm. All tools operate against the LOCAL store only — no +network egress, no external APIs. +""" + + # --- Helpers ----------------------------------------------------------- @@ -552,7 +578,10 @@ def serve() -> None: ) from exc _FastMCP: Any = _FastMCPCls - mcp: Any = _FastMCP(name="aelfrice") + mcp: Any = _FastMCP( + name="aelfrice", + instructions=_SERVER_INSTRUCTIONS, + ) @mcp.tool( annotations={ diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py index f47dc452d..ad9d9ed2a 100644 --- a/tests/test_cli_mcp.py +++ b/tests/test_cli_mcp.py @@ -91,6 +91,49 @@ def test_python_dash_m_mcp_server_module_resolves() -> None: # --- tool description coverage (FastMCP reads decorator-fn docstring) -- +def test_server_passes_instructions_to_fastmcp() -> None: + """The FastMCP constructor call inside serve() must pass an + `instructions=` argument so hosts get a server-level overview of the + tool surface. Static AST guard. + """ + import ast + import aelfrice.mcp_server as mod + + src = open(mod.__file__, "r", encoding="utf-8").read() + tree = ast.parse(src) + + serve_fn = next( + (n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == "serve"), + None, + ) + assert serve_fn is not None + + # Be lenient on which symbol invokes the constructor — search for any + # call passing `name=` with a string arg, which is fastmcp's signature. + constructor_calls = [ + node for node in ast.walk(serve_fn) + if isinstance(node, ast.Call) + and any(kw.arg == "name" for kw in node.keywords) + ] + assert constructor_calls, "no FastMCP-style constructor call found in serve()" + + have_instructions = [ + c for c in constructor_calls + if any(kw.arg == "instructions" for kw in c.keywords) + ] + assert have_instructions, ( + "FastMCP(...) constructor missing instructions= kwarg — host LLMs " + "will receive no server-level overview at registration time" + ) + + # Sanity: the module must define a non-empty _SERVER_INSTRUCTIONS. + assert hasattr(mod, "_SERVER_INSTRUCTIONS") + assert len(mod._SERVER_INSTRUCTIONS.strip()) > 100, ( + "_SERVER_INSTRUCTIONS too short to be useful (< 100 chars stripped)" + ) + + def test_every_decorated_aelf_tool_has_annotations() -> None: """Every @mcp.tool() must pass an `annotations={...}` dict with the four MCP behavioral hints. Hosts use these to gate dangerous tools From 9b32bbf306eb91238a2a59aeecf50795e709eb0a Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:21:21 -0700 Subject: [PATCH 07/15] fix(mcp): tool_lock returns structured error instead of AssertionError on empty derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool_lock` had `assert derived.belief is not None` directly on the hot path. When the classifier sets persist=False (empty input post-strip, question-shaped statement, anything else the derivation worker rejects), the assert fires and the MCP tool surface crashes with an unhandled exception. A crashing tool is far worse host-UX than a kind-tagged error the agent can read and act on. Replaces with a structured return: {"kind": "lock.error", "id": "", "action": "error", "error": "derivation produced no belief from the supplied statement (likely empty after normalization)"} Matches the existing lock.error shape used downstream (run_worker reported empty derived_belief_ids list) and adds the populated `error` field for actionability. Adds tests/test_mcp_lock_via_worker.py:: test_lock_returns_structured_error_when_derivation_yields_no_belief — monkeypatches `derive` to return DerivationOutput(belief=None, skip_reason="empty") and asserts the new shape. Phase 2 M4 of the mcp-server-properly-built audit. Closes the last phase-2 finding (#9 in the audit list). --- src/aelfrice/mcp_server.py | 16 ++++++++++++++-- tests/test_mcp_lock_via_worker.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index d17f5b849..4914c3678 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -246,8 +246,20 @@ def tool_lock( ts=now, session_id=sid, )) - # mcp_remember always produces a belief. - assert derived.belief is not None + # mcp_remember always produces a belief; if it didn't, derivation + # broke (empty input post-strip, classifier dropped all spans, etc). + # Surface as a structured error rather than asserting — a crashing + # MCP tool is far worse host-UX than a kind we can read and grep. + if derived.belief is None: + return { + "kind": "lock.error", + "id": "", + "action": "error", + "error": ( + "derivation produced no belief from the supplied " + "statement (likely empty after normalization)" + ), + } lock_bid = derived.belief.id pre_existing_at_lock_id = store.get_belief(lock_bid) is not None ids_before: set[str] = set(store.list_belief_ids()) diff --git a/tests/test_mcp_lock_via_worker.py b/tests/test_mcp_lock_via_worker.py index 348d81f3b..32c81384e 100644 --- a/tests/test_mcp_lock_via_worker.py +++ b/tests/test_mcp_lock_via_worker.py @@ -18,6 +18,8 @@ import pytest +from aelfrice import mcp_server +from aelfrice.derivation import DerivationOutput from aelfrice.mcp_server import tool_lock from aelfrice.models import LOCK_USER, ORIGIN_USER_STATED from aelfrice.replay import replay_full_equality @@ -106,6 +108,32 @@ def test_replay_full_equality_passes_after_lock(store: MemoryStore) -> None: assert report.canonical_orphan == 0 +def test_lock_returns_structured_error_when_derivation_yields_no_belief( + store: MemoryStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hypothesis: when `derive()` returns DerivationOutput(belief=None) + (the classifier's persist=False path), tool_lock returns a + well-formed lock.error dict with a non-empty `error` field — NOT a + Python AssertionError that crashes the host's MCP tool surface. + + Falsifiable by an unhandled exception, an empty error string, or + return shape diverging from the documented {kind, id, action, error}. + """ + monkeypatch.setattr( + mcp_server, + "derive", + lambda inp: DerivationOutput(belief=None, skip_reason="empty"), + ) + out = tool_lock(store, statement="anything; derive will reject it") + assert out["kind"] == "lock.error" + assert out["action"] == "error" + assert out["id"] == "" + assert isinstance(out.get("error"), str) and out["error"], ( + "lock.error response missing populated `error` field" + ) + + def test_lock_idempotent_on_canonical_state(store: MemoryStore) -> None: """Hypothesis: re-locking is idempotent on the canonical belief set even though it adds new log rows (one per call). Falsifiable by a From d6bafcb9f85b2bbdf668b7b7c5b926b512eb8e8e Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:24:07 -0700 Subject: [PATCH 08/15] feat(mcp): Pydantic Field constraints on every tool param via Annotated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools previously took raw primitives (str, int, bool, dict). FastMCP's auto-schema-from-typehints exposed those without descriptions, length limits, or value patterns. The host LLM saw `aelf_lock(statement: str)` with no hint about what 'statement' meant or how long it could be. Adds `Annotated[type, Field(...)]` constraints to every wrapper param: - statement (lock): min_length=1, max_length=2000 - query (search): min_length=1, max_length=500 - budget (search): ge=1, le=100_000 - belief_id (six): min_length=1, max_length=64 — shared _BeliefId alias - source (four): max_length=128 — shared _SourceLabel alias - signal (feedback): pattern=r"^(used|harmful)$" (still validated at runtime; pattern is a hint to the host LLM) - note (confirm): max_length=2000 - path (onboard): max_length=4096 - session_id: max_length=128 - pressured (locked): bool with description - classifications: list[dict] with description `pydantic.Field` is imported lazily inside serve() (after fastmcp is confirmed present) so `aelfrice.mcp_server` stays importable without the [mcp] extra — preserving the existing test_module_imports_without_ fastmcp invariant. Field aliases (_BeliefId, _SourceLabel) are inline inside serve() for the same reason: they reference the lazily-imported Field symbol. Phase 3 I1 of the mcp-server-properly-built audit. Closes major audit gap #4 (no Pydantic input validation). --- src/aelfrice/mcp_server.py | 159 +++++++++++++++++++++++++++++++++---- 1 file changed, 144 insertions(+), 15 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index 4914c3678..8dcfbae79 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -39,7 +39,7 @@ from datetime import datetime, timezone from pathlib import Path -from typing import Any, Final, Sequence +from typing import Annotated, Any, Final, Sequence from aelfrice.classification import ( HostClassification, @@ -589,6 +589,36 @@ def serve() -> None: "fastmcp is not installed. Install with: pip install aelfrice[mcp]" ) from exc + # pydantic is a transitive dep of fastmcp — import here, not at + # module top, to keep `aelfrice.mcp_server` importable when the + # [mcp] extra is absent (the test suite relies on this). + from pydantic import Field # type: ignore[import-not-found] + + # Reusable Field constraints. Defined inline so they share scope + # with the lazily-imported `Field` symbol; promoting them to module + # level would force pydantic at import time. + _BeliefId = Annotated[ + str, + Field( + description=( + "Stable hash-prefix belief ID returned by aelf_search, " + "aelf_lock, or aelf_locked." + ), + min_length=1, + max_length=64, + ), + ] + _SourceLabel = Annotated[ + str, + Field( + description=( + "Audit-row source suffix. Override only when a " + "non-canonical source is appropriate." + ), + max_length=128, + ), + ] + _FastMCP: Any = _FastMCPCls mcp: Any = _FastMCP( name="aelfrice", @@ -605,9 +635,39 @@ def serve() -> None: }, ) def aelf_onboard( - path: str | None = None, - session_id: str | None = None, - classifications: list[dict[str, Any]] | None = None, + path: Annotated[ + str | None, + Field( + default=None, + description=( + "Absolute filesystem path to the project root. Set to " + "start an onboard session; leave None for the other " + "two shapes." + ), + max_length=4096, + ), + ] = None, + session_id: Annotated[ + str | None, + Field( + default=None, + description=( + "Session ID returned by a prior path-shape call. Set " + "with `classifications` to finalize." + ), + max_length=128, + ), + ] = None, + classifications: Annotated[ + list[dict[str, Any]] | None, + Field( + default=None, + description=( + "Host verdicts: each item {index: int, belief_type: " + "str, persist: bool}. Required when session_id is set." + ), + ), + ] = None, ) -> dict[str, Any]: """Polymorphic ingest entrypoint for a project's belief corpus. @@ -651,7 +711,29 @@ def aelf_onboard( }, ) def aelf_search( - query: str, budget: int = DEFAULT_TOKEN_BUDGET, + query: Annotated[ + str, + Field( + description=( + "Search string. Whitespace-separated terms; SQLite " + "FTS5 syntax honored (NEAR, quoted phrases). " + "Examples: 'release process', 'auth NEAR token'." + ), + min_length=1, + max_length=500, + ), + ], + budget: Annotated[ + int, + Field( + description=( + "Soft token budget for the response. Lower values " + "trim hits aggressively." + ), + ge=1, + le=100_000, + ), + ] = DEFAULT_TOKEN_BUDGET, ) -> dict[str, Any]: """Retrieve beliefs matching a free-text query, ranked by BM25. @@ -685,7 +767,20 @@ def aelf_search( "openWorldHint": False, }, ) - def aelf_lock(statement: str) -> dict[str, Any]: + def aelf_lock( + statement: Annotated[ + str, + Field( + description=( + "The free-text claim to lock as ground truth. " + "Treated verbatim; no rewriting. Example: 'All " + "commits must be signed'." + ), + min_length=1, + max_length=2000, + ), + ], + ) -> dict[str, Any]: """Lock a statement as user-asserted ground truth (L0). Use when the user has explicitly stated a non-negotiable rule, @@ -716,7 +811,18 @@ def aelf_lock(statement: str) -> dict[str, Any]: "openWorldHint": False, }, ) - def aelf_locked(pressured: bool = False) -> dict[str, Any]: + def aelf_locked( + pressured: Annotated[ + bool, + Field( + description=( + "If True, return only locks whose demotion_pressure " + "> 0 (challenged by contradicting evidence). " + "Default False returns all locks." + ), + ), + ] = False, + ) -> dict[str, Any]: """List all user-locked (L0) beliefs in the store. Use to show the user their current ground-truth set, or to find @@ -747,7 +853,7 @@ def aelf_locked(pressured: bool = False) -> dict[str, Any]: "openWorldHint": False, }, ) - def aelf_demote(belief_id: str) -> dict[str, Any]: + def aelf_demote(belief_id: _BeliefId) -> dict[str, Any]: """Demote a belief one tier — drop a lock OR devalidate. For a user-locked (L0) belief: clears the lock to L1. @@ -780,7 +886,8 @@ def aelf_demote(belief_id: str) -> dict[str, Any]: }, ) def aelf_validate( - belief_id: str, source: str = "user_validated", + belief_id: _BeliefId, + source: _SourceLabel = "user_validated", ) -> dict[str, Any]: """Promote agent_inferred → user_validated (no lock applied). @@ -817,7 +924,7 @@ def aelf_validate( "openWorldHint": False, }, ) - def aelf_unlock(belief_id: str) -> dict[str, Any]: + def aelf_unlock(belief_id: _BeliefId) -> dict[str, Any]: """Drop a user-lock without changing the belief's origin. Idempotent: calling on an already-unlocked belief returns @@ -849,7 +956,8 @@ def aelf_unlock(belief_id: str) -> dict[str, Any]: }, ) def aelf_promote( - belief_id: str, source: str = "user_validated", + belief_id: _BeliefId, + source: _SourceLabel = "user_validated", ) -> dict[str, Any]: """Alias of aelf_validate. Identical semantics and return shape. @@ -877,7 +985,19 @@ def aelf_promote( }, ) def aelf_feedback( - belief_id: str, signal: str, source: str = "user", + belief_id: _BeliefId, + signal: Annotated[ + str, + Field( + description=( + "Either 'used' (positive valence, +1) or 'harmful' " + "(negative valence, -1). Other values return a " + "bad_signal error without mutating." + ), + pattern=r"^(used|harmful)$", + ), + ], + source: _SourceLabel = "user", ) -> dict[str, Any]: """Record positive or negative feedback on a belief's usefulness. @@ -920,9 +1040,18 @@ def aelf_feedback( }, ) def aelf_confirm( - belief_id: str, - source: str = _CONFIRM_SOURCE_DEFAULT, - note: str = "", + belief_id: _BeliefId, + source: _SourceLabel = _CONFIRM_SOURCE_DEFAULT, + note: Annotated[ + str, + Field( + description=( + "Optional free-text annotation. Returned on the " + "response payload but NOT persisted." + ), + max_length=2000, + ), + ] = "", ) -> dict[str, Any]: """Affirm an existing belief without locking it (bumps posterior). From 930256df88e328b59493579fbdfd7e67377b8a10 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:25:40 -0700 Subject: [PATCH 09/15] feat(mcp): cursor pagination on tool_locked / aelf_locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per MCP best-practices guidance: tools that list resources must respect a limit param, return pagination metadata (has_more, next_offset, total), and never load unbounded result sets. tool_locked previously returned the entire locked-belief list with no bound — fine at today's typical corpus size (<100 locks) but a latent footgun for power users or future expansion. Pure handler tool_locked now takes: - limit (default 50, clamped to [1, 500]) - offset (default 0, clamped at zero on the low end) Defensive clamping happens in the pure handler, not just the wrapper — keeps tool_locked safe when called directly outside the FastMCP layer (no Pydantic constraint enforcement there). Return shape adds: total, offset (echoed), has_more, next_offset (None when has_more is False). Existing keys (kind, n, locked) preserved. Wrapper aelf_locked exposes the new params via Annotated[..., Field] with descriptions matching the audit guidance. 4 new tests cover: default-page first/second-page round-trip, oversize limit clamping to _LOCKED_MAX_LIMIT, negative offset clamping to zero. Phase 3 I3 of the mcp-server-properly-built audit. Closes audit gap #12 (tool_locked returns ALL locked beliefs with no pagination). --- src/aelfrice/mcp_server.py | 77 +++++++++++++++++++++++++++++++++----- tests/test_mcp_server.py | 41 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index 8dcfbae79..4f03ea42e 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -297,15 +297,42 @@ def tool_lock( return {"kind": "lock.created", "id": actual_id, "action": "locked"} +_LOCKED_DEFAULT_LIMIT: Final[int] = 50 +_LOCKED_MAX_LIMIT: Final[int] = 500 + + def tool_locked( - store: MemoryStore, *, pressured: bool = False, + store: MemoryStore, + *, + pressured: bool = False, + limit: int = _LOCKED_DEFAULT_LIMIT, + offset: int = 0, ) -> dict[str, Any]: + """List user-locked beliefs with stable cursor pagination. + + `limit` is clamped to [1, _LOCKED_MAX_LIMIT]; `offset` is clamped at + zero on the low end and unbounded on the high end (returning empty + when past the end). The full unpaginated count is returned as + `total` so callers know whether to keep paging. + """ locked = store.list_locked_beliefs() if pressured: locked = [b for b in locked if b.demotion_pressure > 0] + total = len(locked) + # Clamp pagination args defensively — pure handler is callable + # outside the wrapper layer where Pydantic constraints aren't enforced. + safe_offset = max(0, offset) + safe_limit = max(1, min(limit, _LOCKED_MAX_LIMIT)) + page = locked[safe_offset : safe_offset + safe_limit] + next_offset = safe_offset + len(page) + has_more = next_offset < total return { "kind": "locked.list", - "n": len(locked), + "n": len(page), + "total": total, + "offset": safe_offset, + "has_more": has_more, + "next_offset": next_offset if has_more else None, "locked": [ { "id": b.id, @@ -313,7 +340,7 @@ def tool_locked( "demotion_pressure": b.demotion_pressure, "locked_at": b.locked_at, } - for b in locked + for b in page ], } @@ -822,25 +849,57 @@ def aelf_locked( ), ), ] = False, + limit: Annotated[ + int, + Field( + description=( + "Maximum number of locks to return in this page. " + "Clamped to [1, 500]." + ), + ge=1, + le=_LOCKED_MAX_LIMIT, + ), + ] = _LOCKED_DEFAULT_LIMIT, + offset: Annotated[ + int, + Field( + description=( + "Number of locks to skip for pagination. Use the " + "previous response's `next_offset` to keep paging." + ), + ge=0, + ), + ] = 0, ) -> dict[str, Any]: - """List all user-locked (L0) beliefs in the store. + """List user-locked (L0) beliefs with cursor pagination. - Use to show the user their current ground-truth set, or to find - candidates for unlock/demote. Read-only. + Use to show the user their current ground-truth set, to find + candidates for unlock/demote, or to walk a large lock corpus + page by page. Read-only. Args: pressured: If True, return only locks whose demotion_pressure is greater than zero (i.e. ones being challenged by contradicting evidence). Default False returns all locks. - - Returns: {"kind": "locked.list", "n": int, + limit: Page size. Default 50, max 500. + offset: Pagination offset. Pass `next_offset` from the prior + response to advance. + + Returns: {"kind": "locked.list", + "n": int (count in this page), + "total": int (count across all pages), + "offset": int (echoed back), + "has_more": bool, + "next_offset": int | None, "locked": [{"id": str, "content": str, "demotion_pressure": int, "locked_at": str}, ...]} """ store = _open_default_store() try: - return tool_locked(store, pressured=pressured) + return tool_locked( + store, pressured=pressured, limit=limit, offset=offset, + ) finally: store.close() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 680b39d54..48c5cb848 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -187,6 +187,47 @@ def test_locked_pressured_filter_includes_pressured(store: MemoryStore) -> None: assert out["n"] == 1 +def test_locked_pagination_default_returns_first_page(store: MemoryStore) -> None: + """Default limit pages cleanly when corpus exceeds the page size.""" + for i in range(5): + tool_lock(store, statement=f"rule number {i}") + out = tool_locked(store, limit=3, offset=0) + assert out["n"] == 3 + assert out["total"] == 5 + assert out["offset"] == 0 + assert out["has_more"] is True + assert out["next_offset"] == 3 + + +def test_locked_pagination_second_page_returns_remainder(store: MemoryStore) -> None: + for i in range(5): + tool_lock(store, statement=f"rule number {i}") + out = tool_locked(store, limit=3, offset=3) + assert out["n"] == 2 + assert out["total"] == 5 + assert out["offset"] == 3 + assert out["has_more"] is False + assert out["next_offset"] is None + + +def test_locked_pagination_clamps_oversize_limit(store: MemoryStore) -> None: + """Limit is clamped to _LOCKED_MAX_LIMIT (defensive against caller bugs).""" + from aelfrice.mcp_server import _LOCKED_MAX_LIMIT + + tool_lock(store, statement="single") + out = tool_locked(store, limit=_LOCKED_MAX_LIMIT * 10, offset=0) + assert out["n"] == 1 + assert out["total"] == 1 + + +def test_locked_pagination_negative_offset_clamped_to_zero( + store: MemoryStore, +) -> None: + tool_lock(store, statement="x") + out = tool_locked(store, offset=-50) + assert out["offset"] == 0 + + # --- demote ------------------------------------------------------------ From 5faab7012be97e946f79dcaa013859af5b83b324 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:30:40 -0700 Subject: [PATCH 10/15] feat(mcp): response_format='markdown' on read tools (search/locked/stats/health) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per MCP best-practices guidance: tools that return structured data should support both JSON (machine-readable, default) and Markdown (human-readable). For aelfrice's four read-only tools, the LLM in the host loop natively reads JSON dicts; markdown is only useful when raw tool output flows through to a human display surface. Implements the markdown path with an always-dict return wrapper to preserve the dict-only return type: {"kind": ".markdown", "format": "markdown", "text": "rendered string"} Per-tool renderers (`_render_search_markdown`, `_render_locked_markdown`, `_render_stats_markdown`, `_render_health_markdown`) live as module-level helpers so they're testable without fastmcp installed. The pure handlers tool_search / tool_locked / tool_stats / tool_health gain a `response_format: str = "json"` kwarg; default behavior is unchanged. Wrappers expose the param via a shared _ResponseFormat Annotated alias with `pattern=r"^(json|markdown)$"` so hosts get a clean validation error on unknown formats. Defensive: pure handlers fall through to JSON for unrecognized format strings rather than raising — covered by a regression test. 6 new tests cover: markdown wrapping for each of the 4 read tools, JSON default unchanged, unknown format fall-through. Phase 3 I2 of the mcp-server-properly-built audit. Closes audit minor gap #11 (no JSON/Markdown response_format). --- src/aelfrice/mcp_server.py | 179 ++++++++++++++++++++++++++++++++++--- tests/test_mcp_server.py | 54 +++++++++++ 2 files changed, 219 insertions(+), 14 deletions(-) diff --git a/src/aelfrice/mcp_server.py b/src/aelfrice/mcp_server.py index 4f03ea42e..b78305d2b 100644 --- a/src/aelfrice/mcp_server.py +++ b/src/aelfrice/mcp_server.py @@ -112,6 +112,109 @@ def _open_default_store() -> MemoryStore: return MemoryStore(str(p)) +# --- Response formatting ------------------------------------------------ +# +# Read-only tools accept `response_format = "json" | "markdown"`. JSON +# (default) returns the structured dict the LLM can read natively. +# Markdown returns a wrapped dict {kind: ".markdown", format, +# text} where `text` is a human-readable rendering — useful when the +# host surface displays raw tool output to the user without LLM +# rephrasing. + +_RESPONSE_FORMAT_JSON: Final[str] = "json" +_RESPONSE_FORMAT_MARKDOWN: Final[str] = "markdown" +_RESPONSE_FORMATS: Final[frozenset[str]] = frozenset( + {_RESPONSE_FORMAT_JSON, _RESPONSE_FORMAT_MARKDOWN} +) + + +def _wrap_markdown(json_payload: dict[str, Any], text: str) -> dict[str, Any]: + """Wrap a markdown rendering with the JSON payload's metadata. + + Always emits a stable shape so callers can branch on format: + {"kind": ".markdown", "format": "markdown", "text": str} + """ + orig_kind = json_payload.get("kind", "unknown") + return { + "kind": f"{orig_kind}.markdown", + "format": "markdown", + "text": text, + } + + +def _render_search_markdown(payload: dict[str, Any]) -> str: + hits = payload.get("hits", []) + if not hits: + return f"# Search results\n\nNo hits ({payload.get('n_hits', 0)})." + lines = [f"# Search results — {payload['n_hits']} hits", ""] + for h in hits: + lines.append(f"## {h['id']} ({h.get('lock_level', 'L?')}, {h.get('type', '?')})") + lines.append(h.get("content", "").strip() or "(empty content)") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _render_locked_markdown(payload: dict[str, Any]) -> str: + locked = payload.get("locked", []) + total = payload.get("total", payload.get("n", 0)) + n = payload.get("n", 0) + offset = payload.get("offset", 0) + has_more = payload.get("has_more", False) + header = ( + f"# Locked beliefs — page {offset // max(n, 1) + 1 if n else 1}, " + f"{n} of {total} shown" + ) + if not locked: + return f"{header}\n\nNo locks found at offset {offset}.\n" + lines = [header, ""] + for b in locked: + pressure = b.get("demotion_pressure", 0) + suffix = f" (pressure={pressure})" if pressure > 0 else "" + lines.append(f"- **{b['id']}**{suffix}: {b.get('content', '').strip()}") + if has_more: + lines.append("") + lines.append( + f"…more available; pass `offset={payload.get('next_offset')}` to continue." + ) + return "\n".join(lines).rstrip() + "\n" + + +def _render_stats_markdown(payload: dict[str, Any]) -> str: + return ( + "# Aelfrice store snapshot\n\n" + f"- Beliefs: {payload.get('beliefs', 0)}\n" + f"- Threads: {payload.get('threads', payload.get('edges', 0))}\n" + f"- Locked: {payload.get('locked', 0)}\n" + f"- Feedback events: {payload.get('feedback_events', 0)}\n" + f"- Onboard sessions (total): " + f"{payload.get('onboard_sessions_total', 0)}\n" + ) + + +def _render_health_markdown(payload: dict[str, Any]) -> str: + regime = payload.get("regime", "unknown") + desc = payload.get("description", "") + lines = [f"# Store regime: **{regime}**", "", desc, ""] + if "features" in payload: + f = payload["features"] + lines.extend([ + "## Features", + f"- n_beliefs: {f.get('n_beliefs', 0)}", + f"- confidence_mean: {f.get('confidence_mean', 0):.3f}", + f"- confidence_median: {f.get('confidence_median', 0):.3f}", + f"- mass_mean: {f.get('mass_mean', 0):.3f}", + f"- lock_per_1000: {f.get('lock_per_1000', 0):.2f}", + f"- thread_per_belief: " + f"{f.get('thread_per_belief', f.get('edge_per_belief', 0)):.3f}", + ]) + if "classification_confidence" in payload: + lines.append( + f"\n_Classifier confidence: " + f"{payload['classification_confidence']:.3f}_" + ) + return "\n".join(lines).rstrip() + "\n" + + # --- Pure tool handlers (test target) ---------------------------------- # # Each `tool_*` is a pure function over (store, args) -> dict. Tests @@ -209,10 +312,14 @@ def tool_onboard_sync(store: MemoryStore, *, path: str) -> dict[str, Any]: def tool_search( - store: MemoryStore, *, query: str, budget: int = DEFAULT_TOKEN_BUDGET, + store: MemoryStore, + *, + query: str, + budget: int = DEFAULT_TOKEN_BUDGET, + response_format: str = _RESPONSE_FORMAT_JSON, ) -> dict[str, Any]: hits = retrieve(store, query, token_budget=budget) - return { + payload: dict[str, Any] = { "kind": "search.results", "n_hits": len(hits), "hits": [ @@ -225,6 +332,9 @@ def tool_search( for h in hits ], } + if response_format == _RESPONSE_FORMAT_MARKDOWN: + return _wrap_markdown(payload, _render_search_markdown(payload)) + return payload def tool_lock( @@ -307,6 +417,7 @@ def tool_locked( pressured: bool = False, limit: int = _LOCKED_DEFAULT_LIMIT, offset: int = 0, + response_format: str = _RESPONSE_FORMAT_JSON, ) -> dict[str, Any]: """List user-locked beliefs with stable cursor pagination. @@ -319,14 +430,12 @@ def tool_locked( if pressured: locked = [b for b in locked if b.demotion_pressure > 0] total = len(locked) - # Clamp pagination args defensively — pure handler is callable - # outside the wrapper layer where Pydantic constraints aren't enforced. safe_offset = max(0, offset) safe_limit = max(1, min(limit, _LOCKED_MAX_LIMIT)) page = locked[safe_offset : safe_offset + safe_limit] next_offset = safe_offset + len(page) has_more = next_offset < total - return { + payload: dict[str, Any] = { "kind": "locked.list", "n": len(page), "total": total, @@ -343,6 +452,9 @@ def tool_locked( for b in page ], } + if response_format == _RESPONSE_FORMAT_MARKDOWN: + return _wrap_markdown(payload, _render_locked_markdown(payload)) + return payload def tool_demote(store: MemoryStore, *, belief_id: str) -> dict[str, Any]: @@ -553,9 +665,13 @@ def tool_confirm( return payload -def tool_stats(store: MemoryStore) -> dict[str, Any]: +def tool_stats( + store: MemoryStore, + *, + response_format: str = _RESPONSE_FORMAT_JSON, +) -> dict[str, Any]: n_edges = store.count_edges() - return { + payload: dict[str, Any] = { "kind": "stats.snapshot", "beliefs": store.count_beliefs(), # `edges` is the v1.0 key. v1.1.0 adds `threads` as the @@ -567,9 +683,16 @@ def tool_stats(store: MemoryStore) -> dict[str, Any]: "feedback_events": store.count_feedback_events(), "onboard_sessions_total": store.count_onboard_sessions(), } + if response_format == _RESPONSE_FORMAT_MARKDOWN: + return _wrap_markdown(payload, _render_stats_markdown(payload)) + return payload -def tool_health(store: MemoryStore) -> dict[str, Any]: +def tool_health( + store: MemoryStore, + *, + response_format: str = _RESPONSE_FORMAT_JSON, +) -> dict[str, Any]: report = assess_health(store) payload: dict[str, Any] = { "kind": "health.report", @@ -589,6 +712,8 @@ def tool_health(store: MemoryStore) -> dict[str, Any]: "edge_per_belief": report.features.edge_per_belief, "thread_per_belief": report.features.edge_per_belief, } + if response_format == _RESPONSE_FORMAT_MARKDOWN: + return _wrap_markdown(payload, _render_health_markdown(payload)) return payload @@ -645,6 +770,17 @@ def serve() -> None: max_length=128, ), ] + _ResponseFormat = Annotated[ + str, + Field( + description=( + "'json' (default) returns the structured payload the " + "LLM reads natively. 'markdown' returns a wrapped dict " + "{kind, format, text} suitable for direct human display." + ), + pattern=r"^(json|markdown)$", + ), + ] _FastMCP: Any = _FastMCPCls mcp: Any = _FastMCP( @@ -761,6 +897,7 @@ def aelf_search( le=100_000, ), ] = DEFAULT_TOKEN_BUDGET, + response_format: _ResponseFormat = _RESPONSE_FORMAT_JSON, ) -> dict[str, Any]: """Retrieve beliefs matching a free-text query, ranked by BM25. @@ -781,7 +918,12 @@ def aelf_search( """ store = _open_default_store() try: - return tool_search(store, query=query, budget=budget) + return tool_search( + store, + query=query, + budget=budget, + response_format=response_format, + ) finally: store.close() @@ -870,6 +1012,7 @@ def aelf_locked( ge=0, ), ] = 0, + response_format: _ResponseFormat = _RESPONSE_FORMAT_JSON, ) -> dict[str, Any]: """List user-locked (L0) beliefs with cursor pagination. @@ -898,7 +1041,11 @@ def aelf_locked( store = _open_default_store() try: return tool_locked( - store, pressured=pressured, limit=limit, offset=offset, + store, + pressured=pressured, + limit=limit, + offset=offset, + response_format=response_format, ) finally: store.close() @@ -1151,7 +1298,9 @@ def aelf_confirm( "openWorldHint": False, }, ) - def aelf_stats() -> dict[str, Any]: + def aelf_stats( + response_format: _ResponseFormat = _RESPONSE_FORMAT_JSON, + ) -> dict[str, Any]: """Return summary counts for the local belief store. Cheap snapshot — no graph walk, no scoring. Use to verify the @@ -1168,7 +1317,7 @@ def aelf_stats() -> dict[str, Any]: """ store = _open_default_store() try: - return tool_stats(store) + return tool_stats(store, response_format=response_format) finally: store.close() @@ -1181,7 +1330,9 @@ def aelf_stats() -> dict[str, Any]: "openWorldHint": False, }, ) - def aelf_health() -> dict[str, Any]: + def aelf_health( + response_format: _ResponseFormat = _RESPONSE_FORMAT_JSON, + ) -> dict[str, Any]: """Classify the store's current operating regime + describe it. Runs the regime classifier over a small feature set (corpus size, @@ -1201,7 +1352,7 @@ def aelf_health() -> dict[str, Any]: """ store = _open_default_store() try: - return tool_health(store) + return tool_health(store, response_format=response_format) finally: store.close() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 48c5cb848..bb0f1acc3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -228,6 +228,60 @@ def test_locked_pagination_negative_offset_clamped_to_zero( assert out["offset"] == 0 +# --- response_format markdown ------------------------------------------ + + +def test_search_markdown_returns_wrapped_payload(store: MemoryStore) -> None: + _put_belief(store, content="quick brown fox", id="bX") + out = tool_search(store, query="brown", response_format="markdown") + assert out["kind"] == "search.results.markdown" + assert out["format"] == "markdown" + assert isinstance(out["text"], str) and "Search results" in out["text"] + assert "bX" in out["text"] + + +def test_search_json_default_unchanged(store: MemoryStore) -> None: + _put_belief(store, content="alpha") + out = tool_search(store, query="alpha") + assert out["kind"] == "search.results" + assert "format" not in out and "text" not in out + + +def test_locked_markdown_includes_pressure_marker(store: MemoryStore) -> None: + bid = tool_lock(store, statement="never push to main")["id"] + b = store.get_belief(bid) + assert b is not None + b.demotion_pressure = 5 + store.update_belief(b) + out = tool_locked(store, response_format="markdown") + assert out["kind"] == "locked.list.markdown" + assert "pressure=5" in out["text"] + + +def test_stats_markdown_renders_counts(store: MemoryStore) -> None: + tool_lock(store, statement="rule 1") + tool_lock(store, statement="rule 2") + out = tool_stats(store, response_format="markdown") + assert out["kind"] == "stats.snapshot.markdown" + assert "# Aelfrice store snapshot" in out["text"] + assert "Locked: 2" in out["text"] + + +def test_health_markdown_renders_regime(store: MemoryStore) -> None: + out = tool_health(store, response_format="markdown") + assert out["kind"] == "health.report.markdown" + assert "# Store regime:" in out["text"] + + +def test_unknown_response_format_falls_through_to_json(store: MemoryStore) -> None: + """Defensive: an unrecognized response_format string returns JSON, not + a crash. The wrapper-layer Pydantic Field constraint blocks unknown + values, but the pure handler should be permissive (no exception).""" + _put_belief(store, content="anchor") + out = tool_search(store, query="anchor", response_format="xml") + assert out["kind"] == "search.results" # JSON path, not markdown + + # --- demote ------------------------------------------------------------ From d051efd2cbea16306462d5668d73b46bfdfeedf7 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:32:13 -0700 Subject: [PATCH 11/15] test(mcp): wrapper-layer tests via static AST + fastmcp shim Closes the last audit major-gap: registration layer was untested. All 40 prior MCP tests targeted pure tool_* handlers; nothing exercised the @mcp.tool-decorated aelf_* wrappers, so any regression in the decorator-call shape, store lifetime, or annotation propagation would have shipped silently. Two test strategies in tests/test_mcp_wrapper_layer.py: 1. Static AST guards (work without fastmcp installed): - test_all_expected_wrappers_present: 12 aelf_* wrappers exist - test_each_wrapper_calls_its_matching_pure_handler: aelf_X delegates to tool_X (catches typos in delegation) - test_each_wrapper_opens_and_closes_store: try/finally store lifetime hygiene (catches leak refactors) 2. fastmcp shim (works against any stub interpreter): - Installs a minimal _FakeFastMCP into sys.modules['fastmcp'] - Reloads aelfrice.mcp_server, calls serve() - Captures every @mcp.tool registration + decorator kwargs - Asserts: 12 tools registered, all have annotations, all four hint keys present, readOnlyHint set is exactly the 4 read tools, destructiveHint set is exactly {aelf_demote}, instructions= passed, name="aelfrice" passed. The shim also stubs `pydantic` if absent in the dev env, since serve() imports `pydantic.Field` lazily after the fastmcp import. Phase 4 of the mcp-server-properly-built audit. 76 MCP tests now cover the full surface (40 pure handlers + 8 cli/main + 28 wrapper + markdown + pagination). --- tests/test_mcp_wrapper_layer.py | 274 ++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 tests/test_mcp_wrapper_layer.py diff --git a/tests/test_mcp_wrapper_layer.py b/tests/test_mcp_wrapper_layer.py new file mode 100644 index 000000000..e056a5426 --- /dev/null +++ b/tests/test_mcp_wrapper_layer.py @@ -0,0 +1,274 @@ +"""Tests for the @mcp.tool-decorated wrapper layer in mcp_server.serve(). + +The pure-handler tests (tests/test_mcp_server.py) cover the business +logic. These tests cover the wiring layer that the host LLM actually +sees: tool registration, schema generation from Pydantic Field hints, +annotation propagation, error path through fastmcp. + +Two strategies: + +1. **Static AST guards** — work without fastmcp installed. Verify each + `aelf_*` wrapper inside serve() delegates to its matching `tool_*` + pure handler and manages the store lifetime via try/finally. + +2. **fastmcp shim** — when fastmcp is unavailable in dev, install a + minimal fake into sys.modules so `serve()` runs and we can capture + registered tools. This catches drift in the registration call shape + that the AST-only tests would miss. +""" +from __future__ import annotations + +import ast +import importlib +import sys +import types +from typing import Any + +import pytest + + +# --------------------------------------------------------------------------- +# Static AST guards +# --------------------------------------------------------------------------- + + +def _serve_function() -> ast.FunctionDef: + import aelfrice.mcp_server as mod + + src = open(mod.__file__, "r", encoding="utf-8").read() + tree = ast.parse(src) + serve_fn = next( + (n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == "serve"), + None, + ) + assert serve_fn is not None, "serve() not found" + return serve_fn + + +_EXPECTED_TOOLS: dict[str, str] = { + "aelf_onboard": "tool_onboard", + "aelf_search": "tool_search", + "aelf_lock": "tool_lock", + "aelf_locked": "tool_locked", + "aelf_demote": "tool_demote", + "aelf_validate": "tool_validate", + "aelf_unlock": "tool_unlock", + "aelf_promote": "tool_promote", + "aelf_feedback": "tool_feedback", + "aelf_confirm": "tool_confirm", + "aelf_stats": "tool_stats", + "aelf_health": "tool_health", +} + + +def _wrapper_funcs(serve_fn: ast.FunctionDef) -> dict[str, ast.FunctionDef]: + """Return mapping of aelf_* wrapper name -> ast.FunctionDef.""" + out: dict[str, ast.FunctionDef] = {} + for node in ast.walk(serve_fn): + if isinstance(node, ast.FunctionDef) and node.name.startswith("aelf_"): + out[node.name] = node + return out + + +def test_all_expected_wrappers_present() -> None: + serve_fn = _serve_function() + wrappers = _wrapper_funcs(serve_fn) + missing = set(_EXPECTED_TOOLS) - set(wrappers) + assert not missing, f"missing wrapper functions in serve(): {missing}" + + +def test_each_wrapper_calls_its_matching_pure_handler() -> None: + """aelf_X must internally call tool_X with the store. Catches typos + where a wrapper accidentally points at the wrong handler.""" + serve_fn = _serve_function() + wrappers = _wrapper_funcs(serve_fn) + + bad: list[tuple[str, str]] = [] + for wrapper_name, expected_handler in _EXPECTED_TOOLS.items(): + fn = wrappers[wrapper_name] + called = { + node.func.id + for node in ast.walk(fn) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + } + if expected_handler not in called: + bad.append((wrapper_name, expected_handler)) + assert not bad, ( + f"wrappers not delegating to expected handlers: {bad}" + ) + + +def test_each_wrapper_opens_and_closes_store() -> None: + """Wrapper bodies must open a store via _open_default_store() and + close it in a finally block. Catches resource-leak refactors.""" + serve_fn = _serve_function() + wrappers = _wrapper_funcs(serve_fn) + + leaks: list[str] = [] + for name, fn in wrappers.items(): + opens = any( + isinstance(c, ast.Call) + and isinstance(c.func, ast.Name) + and c.func.id == "_open_default_store" + for c in ast.walk(fn) + ) + closes_in_finally = False + for try_node in (n for n in ast.walk(fn) if isinstance(n, ast.Try)): + for stmt in try_node.finalbody: + for c in ast.walk(stmt): + if ( + isinstance(c, ast.Call) + and isinstance(c.func, ast.Attribute) + and c.func.attr == "close" + ): + closes_in_finally = True + if not (opens and closes_in_finally): + leaks.append( + f"{name} (opens={opens}, closes_in_finally={closes_in_finally})" + ) + assert not leaks, ( + f"wrappers missing store lifetime hygiene: {leaks}" + ) + + +# --------------------------------------------------------------------------- +# fastmcp shim — capture tool registrations dynamically +# --------------------------------------------------------------------------- + + +class _CapturedTool: + __slots__ = ("fn", "annotations") + + def __init__(self, fn: Any, annotations: dict[str, Any] | None) -> None: + self.fn = fn + self.annotations = annotations or {} + + +class _FakeFastMCP: + """Minimal stand-in for fastmcp.FastMCP. Records every decorated + function and the kwargs passed to @mcp.tool(...).""" + + def __init__(self, **server_kwargs: Any) -> None: + self.server_kwargs = server_kwargs + self.tools: dict[str, _CapturedTool] = {} + + def tool(self, **decorator_kwargs: Any): + annotations = decorator_kwargs.get("annotations") + + def decorator(fn: Any) -> Any: + self.tools[fn.__name__] = _CapturedTool(fn, annotations) + return fn + + return decorator + + # serve() also calls mcp.run() at the end. Make it a no-op so the + # test exits cleanly instead of blocking on stdio. + def run(self) -> None: + return None + + +@pytest.fixture +def fastmcp_shim( + monkeypatch: pytest.MonkeyPatch, +) -> _FakeFastMCP: + """Install a fake fastmcp module + return the shim instance the + serve() under test will receive (after we call it).""" + captured: dict[str, _FakeFastMCP] = {} + + fake_module = types.ModuleType("fastmcp") + + class _FakeFactory: + def __call__(self, **kwargs: Any) -> _FakeFastMCP: + shim = _FakeFastMCP(**kwargs) + captured["instance"] = shim + return shim + + fake_module.FastMCP = _FakeFactory() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fastmcp", fake_module) + + # serve() also imports `pydantic.Field` lazily. pydantic is a + # transitive dep of fastmcp; provide a minimal stand-in if it's + # absent in dev. Real Field is a callable returning a sentinel; for + # AST/registration purposes any callable works. + if "pydantic" not in sys.modules: + fake_pydantic = types.ModuleType("pydantic") + fake_pydantic.Field = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pydantic", fake_pydantic) + + # Force a fresh import so serve() re-binds against the fake fastmcp. + if "aelfrice.mcp_server" in sys.modules: + importlib.reload(sys.modules["aelfrice.mcp_server"]) + + from aelfrice.mcp_server import serve + + serve() + shim = captured.get("instance") + assert shim is not None, "_FakeFastMCP was never instantiated" + return shim + + +def test_shim_registers_all_twelve_tools(fastmcp_shim: _FakeFastMCP) -> None: + expected = set(_EXPECTED_TOOLS) + got = set(fastmcp_shim.tools) + missing = expected - got + extra = got - expected + assert not missing, f"tools never registered: {missing}" + assert not extra, f"unexpected tools registered: {extra}" + + +def test_shim_every_tool_has_annotations_dict(fastmcp_shim: _FakeFastMCP) -> None: + bad = [ + name for name, captured in fastmcp_shim.tools.items() + if not captured.annotations + ] + assert not bad, f"tools registered without annotations: {bad}" + + +def test_shim_annotation_keys_are_complete(fastmcp_shim: _FakeFastMCP) -> None: + required = {"readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"} + incomplete: list[tuple[str, set[str]]] = [] + for name, captured in fastmcp_shim.tools.items(): + missing = required - set(captured.annotations) + if missing: + incomplete.append((name, missing)) + assert not incomplete, ( + f"tools missing required annotation keys: {incomplete}" + ) + + +def test_shim_read_only_set_matches_expected(fastmcp_shim: _FakeFastMCP) -> None: + """Catch annotation drift: the read-only set is canonical.""" + expected_read_only = {"aelf_search", "aelf_locked", "aelf_stats", "aelf_health"} + got_read_only = { + name for name, captured in fastmcp_shim.tools.items() + if captured.annotations.get("readOnlyHint") is True + } + assert got_read_only == expected_read_only, ( + f"readOnlyHint set drifted: expected {expected_read_only}, " + f"got {got_read_only}" + ) + + +def test_shim_destructive_set_matches_expected(fastmcp_shim: _FakeFastMCP) -> None: + """Only aelf_demote is annotated destructiveHint=True.""" + expected_destructive = {"aelf_demote"} + got_destructive = { + name for name, captured in fastmcp_shim.tools.items() + if captured.annotations.get("destructiveHint") is True + } + assert got_destructive == expected_destructive + + +def test_shim_server_received_instructions_kwarg( + fastmcp_shim: _FakeFastMCP, +) -> None: + instructions = fastmcp_shim.server_kwargs.get("instructions") + assert instructions, "FastMCP(instructions=) not passed to constructor" + assert isinstance(instructions, str) + assert len(instructions.strip()) > 100 + + +def test_shim_server_name_is_aelfrice(fastmcp_shim: _FakeFastMCP) -> None: + assert fastmcp_shim.server_kwargs.get("name") == "aelfrice" From 7224e2ccf0093643eed9360dc63de3f28c0469d7 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:33:55 -0700 Subject: [PATCH 12/15] =?UTF-8?q?gate:=20mcp-server-properly-built=20?= =?UTF-8?q?=E2=80=94=20all=20four=20phases=20shipped,=20branch=20ready=20f?= =?UTF-8?q?or=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the mcp-builder audit of src/aelfrice/mcp_server.py started this session. Resolves both CRITICAL findings, all six MAJOR findings, and five of the eight MINOR findings. The deferred minors are documented in this gate body for triage in follow-up issues. ## Commits in scope (10 atomic + 1 phase-1 gate before this) Phase 1 — server is startable and discoverable 9adca85 feat(mcp): aelf mcp subcommand + python -m fallback 904bfb0 feat(mcp): docstrings on all 12 wrappers + AST regression guard 455eaac docs(mcp): document `aelf mcp` entrypoint + fix stale refs 46df710 gate: phase 1 Phase 2 — well-formed a7576ad feat(mcp): annotations on every @mcp.tool 232d8ab feat(mcp): instructions= overview + 9-vs-12 stale comment fix 5f21c29 fix(mcp): tool_lock structured error vs AssertionError Phase 3 — input/output discipline 6c6bf6c feat(mcp): Pydantic Field constraints via Annotated 174d245 feat(mcp): cursor pagination on tool_locked 7f04675 feat(mcp): response_format=markdown on read tools Phase 4 — wrapper-layer testing 0214c17 test(mcp): wrapper-layer tests via static AST + fastmcp shim ## Verification - pytest 76/76 passing across MCP test files (40 prior + 36 new): test_mcp_server.py, test_mcp_lock_via_worker.py, test_mcp_wrapper_layer.py, test_cli_mcp.py - aelf --help: 'mcp' subcommand visible with help string - aelf mcp (no [mcp] extra): exits 1 with actionable stderr - Static AST guards: docstrings, annotations, instructions=, store lifetime, no print()-to-stdout, all green - fastmcp shim test: 12 tools register, all with full annotations, read-only / destructive sets match expected - Discretion grep: clean (only mentions Claude Code/Desktop, both pre-existing on main as the intended MCP host targets) ## Audit findings closure CRITICAL #1 Server unstartable → CLOSED (Phase 1 C1) CRITICAL #2 Empty tool descriptions → CLOSED (Phase 1 C2) MAJOR #3 No tool annotations → CLOSED (Phase 2 M1) MAJOR #4 No Pydantic input validation → CLOSED (Phase 3 I1) MAJOR #5 No server instructions= → CLOSED (Phase 2 M2) MAJOR #6 No README/docs MCP setup section → CLOSED (Phase 1 C3) MAJOR #7 Registration layer untested → CLOSED (Phase 4) MAJOR #8 tool_lock hard assert → CLOSED (Phase 2 M4) MINOR #11 No response_format enum → CLOSED (Phase 3 I2) MINOR #12 No pagination on aelf_locked → CLOSED (Phase 3 I3) MINOR #13 Stale 9-vs-12 tools comment → CLOSED (Phase 2 M3) ## Deferred (file follow-up issues) MINOR #1 Server name "aelfrice" vs convention "aelfrice_mcp" — backwards-compat break, not load-bearing. Defer. MINOR #6 Sync handlers (no async def) — defensible for SQLite, revisit if/when an async I/O dep lands. MINOR #14 Polymorphic tool_onboard (3 input shapes in one tool) — design call. Re-evaluate after host telemetry. MINOR #15 Untyped fastmcp cast `_FastMCP: Any` — fastmcp lacks stubs upstream. Pragmatic; revisit when stubs ship. ## Behavior changes worth flagging in PR description - `tool_locked` return shape: ADDED keys total/has_more/next_offset. Existing keys (kind/n/locked) preserved. Callers that consumed `n` as "total locks across all locks" should switch to `total`. - `tool_locked` returns first 50 by default instead of all locks. Tests on stores with <50 locks behave identically. - `tool_lock` returns lock.error dict (with `error` field) instead of raising AssertionError on empty derivation. Callers that grep for AssertionError need to update; callers that check `out["kind"]` are fine. ## Blockers (require user decision before next phase) - [user] Open PR? Branch is local-only on `feat/mcp-server-properly-built`. Need a `git push -u github feat/mcp-server-properly-built` to land it, then `gh pr create`. Not done by this gate. - [user] File the four deferred-minor follow-up issues now or after PR merge? - [user] Resume parked feat/aelf-upgrade-imperative work next session? That branch has no commits but the worktree state is captured in the end-of-session handoff. Open questions: (none) Rollback: - Whole branch: never merged → just delete `feat/mcp-server-properly-built`. - Per-phase: `git revert ` (see commit list above). From 327a4d0d9df8082f48e53e6a6adb58547b41a6c3 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 22:57:24 -0700 Subject: [PATCH 13/15] docs(mcp): scrub banned-vocab additions from MCP-branch diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push hook's banned-vocabulary list (~/projects/aelfrice/.git/ hooks/pre-push) flags any added line containing 'Claude Code' or other host-product names — the policy treats even legitimate product references in committed content as session-context leakage. Two added lines on this branch tripped the check: - docs/MCP.md:28 host-config preamble that listed host products by name (the equivalent line on main also names 'Claude Code' but the hook only checks `+` lines, so unmodified ones pass) - src/aelfrice/cli.py:4388 docstring/comment naming host products as the audience Scrubbed both: - docs/MCP.md: 'Host config — any MCP-capable host:' (was 'Host config — Claude Code, Codex, Claude Desktop, any MCP-capable host:') - cli.py: 'MCP-capable hosts configuring a server entry can discover it' (was 'hosts (Claude Desktop / Claude Code) configuring an MCP entry can discover it') Net cumulative diff (main..HEAD) no longer has any `+` line containing banned vocab. Pre-push hook now passes. Functionality and link semantics are unchanged — the host-product names were prose context, not normative content. --- docs/MCP.md | 2 +- src/aelfrice/cli.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index f67f7ed4d..3b0eb362c 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -25,7 +25,7 @@ python -m aelfrice.mcp_server # module-exec fallback If `fastmcp` is missing, `aelf mcp` exits 1 with an actionable message (`error: fastmcp is not installed. Install with: pip install aelfrice[mcp]`) — no traceback, no half-started server. -Host config — Claude Code, Codex, Claude Desktop, any MCP-capable host: +Host config — any MCP-capable host: ```json { diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index cdd09c104..d7d32a544 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -4462,9 +4462,8 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: p_statusline.set_defaults(func=_cmd_statusline) # `aelf mcp`: start the FastMCP stdio server. Visible in --help so - # hosts (Claude Desktop / Claude Code) configuring an MCP entry can - # discover it; the [mcp] extra must be installed for it to actually - # run. + # MCP-capable hosts configuring a server entry can discover it; + # the [mcp] extra must be installed for it to actually run. p_mcp = sub.add_parser( "mcp", help="start the FastMCP stdio server (requires aelfrice[mcp])", From 31fd31678e14a3b0d171f8bb58d4bc15cb5496af Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 23:09:30 -0700 Subject: [PATCH 14/15] test(slash): register 'mcp' subcommand in HIDDEN_SUBCOMMANDS closed-world set --- tests/test_slash_commands.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_slash_commands.py b/tests/test_slash_commands.py index 9165e1474..2fcbc8381 100644 --- a/tests/test_slash_commands.py +++ b/tests/test_slash_commands.py @@ -145,6 +145,10 @@ def test_no_extra_files_in_slash_commands_dir() -> None: # install-aware upgrade command). The imperative slash command # `/aelf:upgrade` calls it. CLI verb has no slash file of its own. "upgrade-cmd", + # FastMCP server entrypoint — `aelf mcp` runs the MCP server over + # stdio for host integration. Hidden because it's not a user-facing + # workflow verb; hosts wire it via their MCP server config. + "mcp", }) From 75f66af758e8aae80a94381e7c3c5cf06878be9c Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Fri, 8 May 2026 23:09:37 -0700 Subject: [PATCH 15/15] build(mcp): declare pydantic>=2 in [mcp] extra to satisfy deptry DEP001 mcp_server.py imports `from pydantic import Field` lazily inside serve() (gated to fastmcp availability) for tool-parameter Annotated constraints. Previously satisfied transitively via fastmcp; deptry's static analysis flags the direct import as undeclared. Declared in the existing [mcp] optional group so the default install stays lean (pydantic only resolves when [mcp] is requested, alongside fastmcp which already pulls it transitively). --- pyproject.toml | 7 +++++++ uv.lock | 2 ++ 2 files changed, 9 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2c8901bd3..5efe97002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,13 @@ aelf-session-start-hook = "aelfrice.hook:main_session_start" [project.optional-dependencies] mcp = [ "fastmcp>=0.2.0", + # Used directly via `from pydantic import Field` inside `serve()` + # for tool-parameter constraints (Annotated[..., Field(...)]). + # The import is lazy and gated to fastmcp availability, so pydantic + # only needs to be present when the [mcp] extra is installed — + # which is also when fastmcp pulls it in transitively. Declared + # explicitly to satisfy deptry DEP001. + "pydantic>=2", ] onboard-llm = [ # Required only for `aelf onboard --llm-classify` (or diff --git a/uv.lock b/uv.lock index 96de786ea..3ef7edcae 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,7 @@ benchmarks = [ ] mcp = [ { name = "fastmcp" }, + { name = "pydantic" }, ] onboard-llm = [ { name = "anthropic" }, @@ -54,6 +55,7 @@ requires-dist = [ { name = "huggingface-hub", marker = "extra == 'benchmarks'", specifier = ">=0.20" }, { name = "nltk", marker = "extra == 'benchmarks'", specifier = ">=3.9" }, { name = "numpy", specifier = ">=2.0" }, + { name = "pydantic", marker = "extra == 'mcp'", specifier = ">=2" }, { name = "scipy", specifier = ">=1.11" }, { name = "snowballstemmer", specifier = ">=2.2" }, { name = "tiktoken", marker = "extra == 'benchmarks'", specifier = ">=0.7" },