feat(mcp): enable POSIX tools by default (#1507) · basicmachines-co/basic-memory@8b09d06 · GitHub
Skip to content

Commit 8b09d06

Browse files
authored
feat(mcp): enable POSIX tools by default (#1507)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 368e607 commit 8b09d06

7 files changed

Lines changed: 159 additions & 20 deletions

File tree

‎src/basic_memory/config_models.py‎

Lines changed: 2 additions & 1 deletion

‎src/basic_memory/mcp/server.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async def lifespan(app: FastMCP):
111111
# Trigger: the enable_posix_tools config flag.
112112
# Why: tools register at import time; the composition root is the one place
113113
# config decides what clients see (no if-checks inside tool bodies).
114-
# Outcome: flag off (default) keeps the tool listing identical to today.
114+
# Outcome: enabled by default; an explicit opt-out hides the POSIX group.
115115
set_posix_tools_visibility(app, config.enable_posix_tools)
116116

117117
standalone_redis_url = None if container.mode.is_cloud else config.redis_url
@@ -242,6 +242,17 @@ async def lifespan(app: FastMCP):
242242
"Memory gives them persistent notes shared between the user and their AI, and offer to save "
243243
"something useful from this conversation as their first note with `write_note` — then wait "
244244
"for them to agree before writing anything. Do not create notes unprompted.\n\n"
245+
"When available in your tool list, use the read-only POSIX tools `ls`, `find`, "
246+
"`grep`, `cat`, `tail`, and `man` for compact navigation. If they are absent, use "
247+
"the existing rich tools instead. Projects are mount points: "
248+
'`ls(path="/")` without a project constraint lists '
249+
'addressable projects; `ls(path="research/notes")` and '
250+
'`cat(identifier="research/notes/topic.md")` route into the research project. '
251+
"Use returned project-qualified paths for follow-up reads. With multiple projects, "
252+
"qualify paths or pass `project` (also required for `grep` and `tail`); an explicit "
253+
"project must agree with any path prefix. `cat` supports bounded line, section, and "
254+
"token-budget reads; `find` can return selected metadata fields. All existing tools "
255+
"remain available. Set `enable_posix_tools=false` to hide the POSIX tools.\n\n"
245256
"For a fuller guide, read the `memory://ai_assistant_guide` resource. The manual has a "
246257
"page for nearly every tool, with verified examples and gotchas: `memory://man` lists "
247258
"them, and `memory://man/<tool>(3)` (for example `memory://man/search-notes(3)`) is one "

‎src/basic_memory/mcp/tools/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
# ChatGPT-compatible tools
3232
from basic_memory.mcp.tools.chatgpt_tools import search, fetch
3333

34-
# POSIX-style read-side tools (hidden unless enable_posix_tools is set, #1399)
34+
# POSIX-style read-side tools (enabled by default at server startup, #1476)
3535
from basic_memory.mcp.tools.posix_tools import cat, find, grep, ls, man, tail
3636

3737
# Schema tools

‎src/basic_memory/mcp/tools/posix_tools.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
Six familiar Unix verbs — cat, grep, ls, find, tail, man — each a thin
44
translation over the same typed API clients the canonical tools use. They are
5-
tagged ``POSIX_TOOLS_TAG`` and hidden by default: the composition root in
6-
``basic_memory.mcp.server`` flips their visibility from the
5+
tagged ``POSIX_TOOLS_TAG`` and hidden until startup: the composition root in
6+
``basic_memory.mcp.server`` sets their visibility from the default-enabled
77
``enable_posix_tools`` config flag at lifespan startup, so no tool body ever
88
checks config itself.
99
@@ -1431,6 +1431,6 @@ async def man(
14311431
return response.model_dump(mode="json", exclude_none=True)
14321432

14331433

1434-
# Default-hidden until the composition root reads config in lifespan. Keeps the
1435-
# tool listing identical to today for any consumer that lists before startup.
1434+
# Hidden until the composition root reads config, so importing tools never
1435+
# bypasses an explicit opt-out before startup.
14361436
set_posix_tools_visibility(mcp, False)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Default POSIX discovery and navigation through the MCP client transport."""
2+
3+
import json
4+
5+
import pytest
6+
from fastmcp import Client
7+
8+
from basic_memory.mcp.server import set_posix_tools_visibility
9+
10+
11+
POSIX_TOOLS = {"cat", "grep", "ls", "find", "tail", "man"}
12+
13+
14+
@pytest.mark.asyncio
15+
@pytest.mark.parametrize("setting", [None, False, True])
16+
@pytest.mark.parametrize("mode", ["auto", "legacy"])
17+
async def test_posix_registration_over_mcp(mcp_server, config_manager, setting, mode) -> None:
18+
# Omit the key entirely to exercise existing configs that inherit new defaults.
19+
saved = json.loads(config_manager.config_file.read_text())
20+
saved.pop("enable_posix_tools", None)
21+
if setting is not None:
22+
saved["enable_posix_tools"] = setting
23+
config_manager.config_file.write_text(json.dumps(saved))
24+
25+
try:
26+
async with Client(mcp_server, mode=mode) as client:
27+
tools = {tool.name: tool for tool in await client.list_tools()}
28+
assert {"read_note", "search_notes", "write_note", "build_context"} <= tools.keys()
29+
instructions = client.instructions
30+
assert instructions is not None
31+
assert "When available in your tool list" in instructions
32+
assert "If they are absent, use the existing rich tools instead" in instructions
33+
if setting is False:
34+
assert POSIX_TOOLS.isdisjoint(tools)
35+
hidden = await client.call_tool("man", {}, raise_on_error=False)
36+
assert hidden.is_error
37+
else:
38+
assert POSIX_TOOLS <= tools.keys()
39+
for name in POSIX_TOOLS:
40+
annotations = tools[name].annotations
41+
assert annotations is not None
42+
assert annotations.read_only_hint is True
43+
assert annotations.destructive_hint is False
44+
assert "Projects are mount points" in instructions
45+
assert 'cat(identifier="research/notes/topic.md")' in instructions
46+
finally:
47+
set_posix_tools_visibility(mcp_server, False)
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_posix_default_navigation_workflow(mcp_server, app, test_project) -> None:
52+
"""Follow advertised mount and result paths, then scan a bounded note window."""
53+
try:
54+
async with Client(mcp_server) as client:
55+
await client.call_tool(
56+
"write_note",
57+
{
58+
"title": "POSIX Runbook",
59+
"directory": "notes",
60+
"content": "# Recovery\n\nbefore\nretry the task\nafter\nremaining",
61+
"metadata": {"status": "active", "priority": 3},
62+
"project": test_project.name,
63+
},
64+
)
65+
mounts = await client.call_tool("ls", {"path": "/"})
66+
mount = next(row for row in mounts.data["nodes"] if row["name"] == test_project.name)
67+
note_dir = f"{mount['directory_path']}/notes"
68+
listing = await client.call_tool("ls", {"path": note_dir})
69+
note_path = listing.data["nodes"][0]["file_path"]
70+
full = await client.call_tool("cat", {"identifier": note_path})
71+
assert "retry the task" in full.data["content"]
72+
73+
found = await client.call_tool("find", {"path": note_dir, "name": "*.md"})
74+
assert found.data["nodes"][0]["file_path"] == note_path
75+
projected = await client.call_tool(
76+
"find",
77+
{"path": note_dir, "meta": ["status=active"], "fields": ["priority"]},
78+
)
79+
row = projected.data["results"][0]
80+
assert row["file_path"] == note_path
81+
assert row["fields"] == {"priority": 3}
82+
assert "content" not in row
83+
84+
matches = await client.call_tool(
85+
"grep",
86+
{
87+
"pattern": "retry",
88+
"literal": True,
89+
"context_lines": 1,
90+
"project": test_project.name,
91+
},
92+
)
93+
window = matches.data["results"][0]["windows"][0]
94+
sliced = await client.call_tool(
95+
"cat",
96+
{
97+
"identifier": note_path,
98+
"start_line": window["start_line"],
99+
"end_line": window["end_line"],
100+
},
101+
)
102+
assert sliced.data["content"] == window["content"] == "before\nretry the task\nafter"
103+
assert len(sliced.data["content"]) < len(full.data["content"])
104+
105+
recent = await client.call_tool("tail", {"project": test_project.name, "lines": 1})
106+
assert recent.data[0]["title"] == "POSIX Runbook"
107+
manual = await client.call_tool("man", {"page": "cat(1)"})
108+
assert "--lines" in manual.data
109+
finally:
110+
set_posix_tools_visibility(mcp_server, False)

‎tests/mcp/test_posix_tool_gate.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,53 @@
33
The POSIX tools register on the shared FastMCP server at import time but stay
44
hidden until the composition root reads config in lifespan. These tests drive
55
both directions of the gate; visibility marks stack with the last one winning,
6-
so each test restores the default-hidden state it started from.
6+
so each test restores the pre-startup hidden state it started from.
77
"""
88

9+
import subprocess
10+
import sys
11+
912
import pytest
1013

1114
from basic_memory.mcp.server import lifespan, mcp, set_posix_tools_visibility
1215

1316
POSIX_TOOL_NAMES = {"cat", "find", "grep", "ls", "man", "tail"}
1417

1518

16-
@pytest.mark.asyncio
17-
async def test_posix_tools_hidden_by_default():
18-
"""Importing the tools must not change what clients see before startup."""
19-
names = {tool.name for tool in await mcp.list_tools()}
20-
assert names.isdisjoint(POSIX_TOOL_NAMES)
19+
def test_posix_tools_hidden_before_startup():
20+
"""A fresh interpreter proves import behavior independently of prior lifespans."""
21+
result = subprocess.run(
22+
[
23+
sys.executable,
24+
"-c",
25+
"import asyncio; import basic_memory.mcp.tools; "
26+
"from basic_memory.mcp.server import mcp; "
27+
"names = {tool.name for tool in asyncio.run(mcp.list_tools())}; "
28+
"assert 'read_note' in names; "
29+
"assert names.isdisjoint({'cat', 'find', 'grep', 'ls', 'man', 'tail'})",
30+
],
31+
capture_output=True,
32+
text=True,
33+
check=False,
34+
)
35+
assert result.returncode == 0, result.stderr
2136

2237

2338
@pytest.mark.asyncio
24-
async def test_posix_tools_visible_when_flag_enabled(config_manager):
39+
async def test_posix_tools_visible_by_default(config_manager):
40+
# Other tests may have started the singleton already; establish this test
41+
# boundary explicitly rather than depending on suite order.
42+
set_posix_tools_visibility(mcp, False)
2543
baseline = {tool.name for tool in await mcp.list_tools()}
2644
cfg = config_manager.load_config()
27-
cfg.enable_posix_tools = True
28-
config_manager.save_config(cfg)
45+
assert cfg.enable_posix_tools is True
2946

3047
try:
3148
async with lifespan(mcp):
3249
enabled = {tool.name for tool in await mcp.list_tools()}
3350
finally:
3451
# The registration singleton is shared across tests; re-hide so later
35-
# tests see today's default listing (the mark appended last wins).
52+
# tests see the pre-startup listing (the mark appended last wins).
3653
set_posix_tools_visibility(mcp, False)
3754

3855
assert enabled == baseline | POSIX_TOOL_NAMES

‎tests/mcp/test_tool_contracts.py‎

Lines changed: 3 additions & 3 deletions

0 commit comments

Comments
 (0)