Skip to content

Commit 5f2f57b

Browse files
authored
fix: load env file for context MCP refresh (#13)
1 parent 5fc1772 commit 5f2f57b

2 files changed

Lines changed: 144 additions & 4 deletions

File tree

src/agent_context_builder/mcp_server.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""
1616

1717
import os
18+
from pathlib import Path
1819

1920
import requests
2021
from mcp.server.fastmcp import FastMCP
@@ -35,9 +36,86 @@
3536
)
3637

3738

39+
_ENV_LOADED = False
40+
41+
42+
def _parse_env_line(line: str) -> tuple[str, str] | None:
43+
stripped = line.strip()
44+
if not stripped or stripped.startswith("#") or "=" not in stripped:
45+
return None
46+
key, value = stripped.split("=", 1)
47+
key = key.strip()
48+
if not key:
49+
return None
50+
value = value.strip().strip('"').strip("'")
51+
return key, value
52+
53+
54+
def _candidate_env_paths() -> list[Path]:
55+
"""Return .env candidates, from explicit config to nearby parent directories."""
56+
explicit = os.environ.get("ACB_ENV_FILE", "").strip()
57+
paths: list[Path] = []
58+
if explicit:
59+
paths.append(Path(explicit).expanduser())
60+
61+
starts = [Path.cwd(), Path(__file__).resolve()]
62+
for start in starts:
63+
current = start if start.is_dir() else start.parent
64+
paths.extend(parent / ".env" for parent in [current] + list(current.parents))
65+
66+
seen: set[Path] = set()
67+
unique: list[Path] = []
68+
for path in paths:
69+
resolved = path.resolve()
70+
if resolved not in seen:
71+
seen.add(resolved)
72+
unique.append(resolved)
73+
return unique
74+
75+
76+
def _load_dotenv_if_present() -> bool:
77+
"""Load local .env files without overriding variables already set by the host.
78+
79+
Lookup order:
80+
1. `ACB_ENV_FILE`, when set.
81+
2. `.env` from the current working directory upward.
82+
3. `.env` from this module directory upward.
83+
84+
Partial `.env` files are allowed: the loader keeps checking later candidates
85+
until at least one missing or blank variable has been filled.
86+
"""
87+
global _ENV_LOADED
88+
if _ENV_LOADED:
89+
return True
90+
91+
loaded_any = False
92+
for env_path in _candidate_env_paths():
93+
if not env_path.exists():
94+
continue
95+
for line in env_path.read_text(encoding="utf-8").splitlines():
96+
parsed = _parse_env_line(line)
97+
if not parsed:
98+
continue
99+
key, value = parsed
100+
if not os.environ.get(key):
101+
os.environ[key] = value
102+
loaded_any = True
103+
if loaded_any:
104+
_ENV_LOADED = True
105+
return loaded_any
106+
107+
108+
def _get_env(name: str) -> str | None:
109+
value = os.environ.get(name)
110+
if value:
111+
return value
112+
_load_dotenv_if_present()
113+
return os.environ.get(name)
114+
115+
38116
def _fetch(path: str) -> str:
39117
"""Fetch a file from the context branch."""
40-
token = os.environ.get("GITHUB_TOKEN")
118+
token = _get_env("GITHUB_TOKEN")
41119
headers = {"Authorization": f"token {token}"} if token else {}
42120
response = requests.get(f"{_RAW_BASE}/{path}", headers=headers, timeout=10)
43121
response.raise_for_status()
@@ -69,7 +147,7 @@ def refresh_context() -> str:
69147
Richiede GITHUB_TOKEN con scope workflow.
70148
Gli artifact aggiornati saranno disponibili entro ~1 minuto.
71149
"""
72-
token = os.environ.get("GITHUB_TOKEN")
150+
token = _get_env("GITHUB_TOKEN")
73151
if not token:
74152
return (
75153
"Errore: GITHUB_TOKEN non impostato. "

tests/test_mcp_server.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,76 @@ def test_topic_index_resource():
5151

5252
def test_refresh_context_no_token(monkeypatch):
5353
"""refresh_context returns error message when GITHUB_TOKEN not set."""
54-
from agent_context_builder.mcp_server import refresh_context
54+
import agent_context_builder.mcp_server as mcp_server
5555

5656
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
57-
result = refresh_context()
57+
monkeypatch.delenv("ACB_ENV_FILE", raising=False)
58+
monkeypatch.setattr(mcp_server, "_ENV_LOADED", True)
59+
result = mcp_server.refresh_context()
5860

5961
assert "GITHUB_TOKEN" in result
6062

6163

64+
def test_refresh_context_loads_token_from_env_file(monkeypatch, tmp_path):
65+
"""refresh_context can use a local .env when the host does not export env vars."""
66+
import agent_context_builder.mcp_server as mcp_server
67+
68+
env_file = tmp_path / ".env"
69+
env_file.write_text("GITHUB_TOKEN=file-token\n", encoding="utf-8")
70+
71+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
72+
monkeypatch.setenv("ACB_ENV_FILE", str(env_file))
73+
monkeypatch.setattr(mcp_server, "_ENV_LOADED", False)
74+
75+
with patch("agent_context_builder.mcp_server.requests.post") as mock_post:
76+
mock_post.return_value = _mock_response("", status=204)
77+
result = mcp_server.refresh_context()
78+
79+
assert "triggerato" in result.lower()
80+
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "token file-token"
81+
82+
83+
def test_refresh_context_loads_token_when_env_is_empty(monkeypatch, tmp_path):
84+
"""A blank inherited value should not block the local .env fallback."""
85+
import agent_context_builder.mcp_server as mcp_server
86+
87+
env_file = tmp_path / ".env"
88+
env_file.write_text("GITHUB_TOKEN=file-token\n", encoding="utf-8")
89+
90+
monkeypatch.setenv("GITHUB_TOKEN", "")
91+
monkeypatch.setenv("ACB_ENV_FILE", str(env_file))
92+
monkeypatch.setattr(mcp_server, "_ENV_LOADED", False)
93+
94+
with patch("agent_context_builder.mcp_server.requests.post") as mock_post:
95+
mock_post.return_value = _mock_response("", status=204)
96+
result = mcp_server.refresh_context()
97+
98+
assert "triggerato" in result.lower()
99+
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "token file-token"
100+
101+
102+
def test_refresh_context_continues_after_partial_env(monkeypatch, tmp_path):
103+
"""A partial explicit .env should not prevent later candidates from filling tokens."""
104+
import agent_context_builder.mcp_server as mcp_server
105+
106+
explicit_env = tmp_path / "partial.env"
107+
explicit_env.write_text("ACB_BRANCH=context\n", encoding="utf-8")
108+
workspace_env = tmp_path / ".env"
109+
workspace_env.write_text("GITHUB_TOKEN=file-token\n", encoding="utf-8")
110+
111+
monkeypatch.chdir(tmp_path)
112+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
113+
monkeypatch.setenv("ACB_ENV_FILE", str(explicit_env))
114+
monkeypatch.setattr(mcp_server, "_ENV_LOADED", False)
115+
116+
with patch("agent_context_builder.mcp_server.requests.post") as mock_post:
117+
mock_post.return_value = _mock_response("", status=204)
118+
result = mcp_server.refresh_context()
119+
120+
assert "triggerato" in result.lower()
121+
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "token file-token"
122+
123+
62124
def test_refresh_context_success(monkeypatch):
63125
"""refresh_context triggers workflow dispatch and reports success."""
64126
from agent_context_builder.mcp_server import refresh_context

0 commit comments

Comments
 (0)