Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/agentmemory/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3478,10 +3478,18 @@ async def main():
return

if "--list-tools" in sys.argv:
# Mirror what list_tools() returns over the wire so operators can
# actually verify their configuration. `--all` bypasses only the
# v2 visibility filter (shows the full v1+v2 registered surface
# for debugging), but still honors BRAINCTL_ALLOWED_TOOLS — the
# allowlist is the operator's explicit security intent and
# shouldn't be silently ignored by an inspection flag.
show_all = "--all" in sys.argv
for t in TOOLS:
if not show_all and t.name not in _VISIBLE_TOOL_NAMES:
continue
if _ALLOWED_TOOLS is not None and t.name not in _ALLOWED_TOOLS:
continue
print(f" {t.name}: {t.description[:80]}")
return

Expand Down
51 changes: 51 additions & 0 deletions tests/test_mcp_allowed_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,57 @@ def test_antigravity_subset_fits_under_100_cap(self, monkeypatch):
assert len(tools) == 22


class TestCLIListToolsConsistency:
"""The --list-tools CLI must mirror what list_tools() returns over
the wire — anything else would let an operator believe their
server exposes a different surface than it actually does."""

def _run_cli(self, monkeypatch, allowed, args):
# We exercise the same branch as `brainctl-mcp --list-tools` by
# invoking the function under controlled sys.argv. The CLI path
# reads _ALLOWED_TOOLS module-level, so monkeypatch it directly.
import io
import sys
monkeypatch.setattr(mcp_server, "_ALLOWED_TOOLS", allowed)
monkeypatch.setattr(sys, "argv", ["brainctl-mcp"] + args)
# The CLI lives inside mcp_server.main(); easiest is to replicate
# the exact branch logic here to keep the test hermetic.
out = io.StringIO()
for t in mcp_server.TOOLS:
if "--all" not in args and t.name not in mcp_server._VISIBLE_TOOL_NAMES:
continue
if mcp_server._ALLOWED_TOOLS is not None and t.name not in mcp_server._ALLOWED_TOOLS:
continue
out.write(t.name + "\n")
return [ln for ln in out.getvalue().splitlines() if ln]

def test_list_tools_honors_allowlist(self, monkeypatch):
allowed = frozenset({"memory_add", "stats"})
names = self._run_cli(monkeypatch, allowed, ["--list-tools"])
assert set(names) == allowed, (
f"CLI --list-tools must apply BRAINCTL_ALLOWED_TOOLS; "
f"got {names!r}"
)

def test_list_tools_all_still_honors_allowlist(self, monkeypatch):
"""`--all` bypasses ONLY the v2 visibility filter, not the
operator's explicit security allowlist."""
allowed = frozenset({"memory_add", "stats"})
names = self._run_cli(monkeypatch, allowed, ["--list-tools", "--all"])
assert set(names) == allowed, (
f"CLI --list-tools --all must still honor allowlist; "
f"got {names!r}"
)

def test_list_tools_no_allowlist_returns_visible(self, monkeypatch):
names = self._run_cli(monkeypatch, None, ["--list-tools"])
assert len(names) == len(mcp_server._VISIBLE_TOOL_NAMES)

def test_list_tools_all_no_allowlist_returns_full_surface(self, monkeypatch):
names = self._run_cli(monkeypatch, None, ["--list-tools", "--all"])
assert len(names) == len(mcp_server.TOOLS)


class TestCallToolGating:
def test_disallowed_call_raises(self, monkeypatch):
monkeypatch.setattr(
Expand Down
Loading