Skip to content

Commit f85d9ce

Browse files
committed
fix(mcp): pin cwd to project root so .tenets.yml is reliably loaded
An MCP stdio server's spawn cwd is not guaranteed to be the project root, so .tenets.yml (discovered relative to cwd) could be silently skipped. Honor CLAUDE_PROJECT_DIR (set by Claude Code) and a generic TENETS_PROJECT_ROOT override by chdir-ing to it at startup, before the config is built.
1 parent fc3a572 commit f85d9ce

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

‎tenets/mcp/server.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,6 +1649,27 @@ def _configure_mcp_logging(transport: str) -> None:
16491649
logging.getLogger(logger_name).setLevel(logging.WARNING)
16501650

16511651

1652+
def _chdir_to_project_root() -> None:
1653+
"""Pin the working directory to the project root for config discovery.
1654+
1655+
``.tenets.yml`` is discovered relative to the process cwd, but an MCP stdio
1656+
server's spawn cwd is not guaranteed to be the project root. Claude Code sets
1657+
``CLAUDE_PROJECT_DIR`` in the spawned server's environment to the project root;
1658+
honor that (and a generic ``TENETS_PROJECT_ROOT`` override) so the project's
1659+
``.tenets.yml`` is reliably loaded regardless of where the server was launched.
1660+
"""
1661+
import os
1662+
1663+
for var in ("TENETS_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"):
1664+
root = os.environ.get(var)
1665+
if root and os.path.isdir(root):
1666+
try:
1667+
os.chdir(root)
1668+
except OSError:
1669+
pass
1670+
return
1671+
1672+
16521673
def main() -> None:
16531674
"""CLI entry point for tenets-mcp server.
16541675
@@ -1698,6 +1719,9 @@ def main() -> None:
16981719
# Configure logging BEFORE importing anything else
16991720
_configure_mcp_logging(args.transport)
17001721

1722+
# Pin cwd to the project root so .tenets.yml is discovered reliably
1723+
_chdir_to_project_root()
1724+
17011725
try:
17021726
server = create_server()
17031727
server.run(

‎tests/mcp/test_project_root.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""tenets-mcp pins cwd to the project root so .tenets.yml is discovered."""
2+
import os
3+
4+
from tenets.mcp.server import _chdir_to_project_root
5+
6+
7+
def _real(p):
8+
return os.path.realpath(str(p))
9+
10+
11+
def test_chdir_honors_tenets_project_root(tmp_path, monkeypatch):
12+
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
13+
monkeypatch.setenv("TENETS_PROJECT_ROOT", str(tmp_path))
14+
monkeypatch.chdir(tmp_path.parent)
15+
_chdir_to_project_root()
16+
assert _real(os.getcwd()) == _real(tmp_path)
17+
18+
19+
def test_chdir_honors_claude_project_dir(tmp_path, monkeypatch):
20+
monkeypatch.delenv("TENETS_PROJECT_ROOT", raising=False)
21+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
22+
monkeypatch.chdir(tmp_path.parent)
23+
_chdir_to_project_root()
24+
assert _real(os.getcwd()) == _real(tmp_path)
25+
26+
27+
def test_tenets_project_root_takes_precedence(tmp_path, monkeypatch):
28+
a = tmp_path / "a"
29+
a.mkdir()
30+
b = tmp_path / "b"
31+
b.mkdir()
32+
monkeypatch.setenv("TENETS_PROJECT_ROOT", str(a))
33+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(b))
34+
monkeypatch.chdir(tmp_path)
35+
_chdir_to_project_root()
36+
assert _real(os.getcwd()) == _real(a)
37+
38+
39+
def test_chdir_noop_when_unset(tmp_path, monkeypatch):
40+
monkeypatch.delenv("TENETS_PROJECT_ROOT", raising=False)
41+
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
42+
monkeypatch.chdir(tmp_path)
43+
_chdir_to_project_root()
44+
assert _real(os.getcwd()) == _real(tmp_path)
45+
46+
47+
def test_chdir_ignores_invalid_dir(tmp_path, monkeypatch):
48+
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
49+
monkeypatch.setenv("TENETS_PROJECT_ROOT", str(tmp_path / "does-not-exist"))
50+
monkeypatch.chdir(tmp_path)
51+
_chdir_to_project_root()
52+
assert _real(os.getcwd()) == _real(tmp_path)

0 commit comments

Comments
 (0)