Skip to content

Commit f6cb605

Browse files
authored
feat: surface pending signals from upstream artifacts + MCP improvements (#30)
* feat(session_bootstrap): surface pending signals from upstream artifacts - Parse sample_run in RepoSignal; expose failed_runs property on RepoSignals - Show failed runs inline in Pipeline State section with year + run link - Show datasets_in_use when a radar source is YELLOW/RED - Add candidates_by_source in topic_index (grouped by source, same as clean_ready) - Parse column name+role in DICleanDataset; include in workspace_triage for clean_ready datasets - Add tests for all new models and properties Fix: triage.py _build_dataset_catalog_dict was missing the columns field * test(render): align test expectations with new bootstrap structure - test_render_session_bootstrap: check for INFRA section, not per-repo names - test_render_session_bootstrap_github_error: check INTAKE section shows unavailable - test_render_bootstrap_with_discussions: assert '## 🔗 OPEN' and '[Civic Questions]' - test_render_bootstrap_catalog_drift_all_stable: assert SCOUTING with no drift message - test_render_bootstrap_catalog_drift_unavailable: SCOUTING omitted when no signals - test_render_bootstrap_dataset_catalog_section: catalog summary only, dataset names in triage * feat(mcp_server): retry, rate-limit guard, structured logging, JSON errors - _fetch(): add retry with exponential backoff on 5xx/network errors; 4xx errors raise immediately (won't be fixed by retrying) - refresh_context: local rate-limit guard (1 call/min) with retry_after field; distinguish 422 (workflow disabled) from other errors - All tool functions return structured JSON: {ok, tool, path/error, status_code, ts} instead of plain strings - Structured JSON logging via ACB_LOG_LEVEL env var (DEBUG/INFO/WARNING/ERROR) - Update tests to match new JSON error format and rate-limit guard * refactor(render): remove orphan _render_* and _build_* methods ~270 lines of dead code removed. These methods were made obsolete by the refactor that moved triage logic to triage.py and inlined bootstrap rendering. All callers were removed in prior commits. Kept: _fetch_radar_summary, _fetch_portal_scout, _fetch_source_observatory_signals, _fetch_di_pipeline_signals, _fetch_di_clean_catalog (still used by render_topic_index) * fix: remove dead return in render_session_bootstrap; guard basicConfig on pre-configured root logger
1 parent 84637b1 commit f6cb605

7 files changed

Lines changed: 574 additions & 403 deletions

File tree

src/agent_context_builder/mcp_server.py

Lines changed: 172 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
refresh_context — triggers a new CI build (requires GITHUB_TOKEN with workflow scope)
1010
1111
Configuration via environment variables:
12-
ACB_REPO GitHub repo (default: dataciviclab/agent-context-builder)
13-
ACB_BRANCH Branch where artifacts are published (default: context)
14-
GITHUB_TOKEN Required only for the refresh_context tool
12+
ACB_REPO GitHub repo (default: dataciviclab/agent-context-builder)
13+
ACB_BRANCH Branch where artifacts are published (default: context)
14+
GITHUB_TOKEN Required only for the refresh_context tool
15+
ACB_LOG_LEVEL Logging level, default INFO (options: DEBUG, INFO, WARNING, ERROR)
1516
"""
1617

18+
import json
19+
import logging
1720
import os
21+
import time
22+
from datetime import datetime, timezone
1823
from pathlib import Path
1924

2025
import requests
@@ -25,6 +30,33 @@
2530
_RAW_BASE = f"https://raw.githubusercontent.com/{_REPO}/{_BRANCH}"
2631
_API_BASE = f"https://api.github.com/repos/{_REPO}"
2732

33+
# Rate-limit guard: GitHub allows ~2 workflow dispatches per hour per repo/ref
34+
_REFRESH_MIN_INTERVAL = 60 # seconds — local guard before hitting GitHub limit
35+
_last_refresh_attempt: float | None = None
36+
37+
_log = logging.getLogger(__name__)
38+
39+
40+
def _configure_logging() -> None:
41+
"""Configure structured JSON logging from ACB_LOG_LEVEL env var.
42+
43+
Only calls basicConfig if the root logger has no handlers configured.
44+
This avoids overriding logging setup from pytest --log-level or other
45+
environments that pre-configure the root logger.
46+
"""
47+
if logging.root.handlers:
48+
return # already configured — don't override
49+
level_name = os.environ.get("ACB_LOG_LEVEL", "INFO").upper()
50+
level = getattr(logging, level_name, logging.INFO)
51+
logging.basicConfig(
52+
level=level,
53+
format='{"ts":"%(asctime)s","level":"%(levelname)s","name":"%(name)s","msg":"%(message)s"}',
54+
datefmt="%Y-%m-%dT%H:%M:%S",
55+
)
56+
57+
58+
_configure_logging()
59+
2860
mcp = FastMCP(
2961
"dataciviclab-context",
3062
instructions=(
@@ -113,40 +145,94 @@ def _get_env(name: str) -> str | None:
113145
return os.environ.get(name)
114146

115147

116-
def _fetch(path: str) -> str:
117-
"""Fetch a file from the context branch."""
148+
def _tool_error(tool: str, path: str, message: str, status_code: int | None = None) -> str:
149+
"""Return a structured JSON error string for tool failures."""
150+
return json.dumps({
151+
"ok": False,
152+
"tool": tool,
153+
"path": path,
154+
"error": message,
155+
"status_code": status_code,
156+
"ts": datetime.now(timezone.utc).isoformat(),
157+
})
158+
159+
160+
def _fetch(path: str, retries: int = 1, backoff: float = 1.0) -> str:
161+
"""Fetch a file from the context branch with optional retry on transient errors.
162+
163+
Args:
164+
path: Path on the context branch (e.g. "session_bootstrap.md")
165+
retries: Number of retries on 5xx or network errors (default 1, meaning one attempt + up to 1 retry)
166+
backoff: Initial backoff seconds, doubled on each retry (default 1.0)
167+
"""
118168
token = _get_env("GITHUB_TOKEN")
119169
headers = {"Authorization": f"token {token}"} if token else {}
120-
response = requests.get(f"{_RAW_BASE}/{path}", headers=headers, timeout=10)
121-
response.raise_for_status()
122-
return response.text
170+
url = f"{_RAW_BASE}/{path}"
171+
attempt = 0
172+
last_err: Exception | None = None
173+
174+
while attempt <= retries:
175+
try:
176+
response = requests.get(url, headers=headers, timeout=10)
177+
response.raise_for_status()
178+
_log.info("fetch_success path=%s status=%d", path, response.status_code)
179+
return response.text
180+
except requests.HTTPError as e:
181+
# Don't retry on 4xx — they won't become valid by retrying
182+
if e.response is not None and 400 <= e.response.status_code < 500:
183+
_log.warning("fetch_client_error path=%s status=%d", path, e.response.status_code)
184+
raise
185+
last_err = e
186+
except requests.RequestException as e:
187+
last_err = e
188+
189+
attempt += 1
190+
if attempt <= retries:
191+
sleep = backoff * (2 ** (attempt - 1))
192+
_log.warning("fetch_retry path=%s attempt=%d sleep=%.1f error=%s", path, attempt, sleep, last_err)
193+
time.sleep(sleep)
194+
else:
195+
_log.error("fetch_failed path=%s error=%s", path, last_err)
196+
raise last_err
197+
198+
# Should never reach here, but mypy needs it
199+
raise RuntimeError("unreachable")
123200

124201

125202
@mcp.tool()
126203
def session_bootstrap() -> str:
127204
"""Orientamento rapido: repo attivi, PR aperte, discussion, stato locale, topic."""
128205
try:
129206
return _fetch("session_bootstrap.md")
130-
except requests.HTTPError as e:
131-
return f"session_bootstrap: {e}"
207+
except Exception as e:
208+
return _tool_error(
209+
"session_bootstrap", "session_bootstrap.md", str(e),
210+
e.response.status_code if isinstance(e, requests.HTTPError) and e.response else None
211+
)
132212

133213

134214
@mcp.tool()
135215
def workspace_triage() -> str:
136216
"""Triage machine-readable: PR, issue, discussion, stato git per repo, warning."""
137217
try:
138218
return _fetch("workspace_triage.json")
139-
except requests.HTTPError as e:
140-
return f"workspace_triage: {e}"
219+
except Exception as e:
220+
return _tool_error(
221+
"workspace_triage", "workspace_triage.json", str(e),
222+
e.response.status_code if isinstance(e, requests.HTTPError) and e.response else None
223+
)
141224

142225

143226
@mcp.tool()
144227
def topic_index() -> str:
145228
"""Topic index v2 — repos, datasets_by_source, operational_topics."""
146229
try:
147230
return _fetch("topic_index.json")
148-
except requests.HTTPError as e:
149-
return f"topic_index: {e}"
231+
except Exception as e:
232+
return _tool_error(
233+
"topic_index", "topic_index.json", str(e),
234+
e.response.status_code if isinstance(e, requests.HTTPError) and e.response else None
235+
)
150236

151237

152238
@mcp.tool()
@@ -155,30 +241,86 @@ def refresh_context() -> str:
155241
156242
Richiede GITHUB_TOKEN con scope workflow.
157243
Gli artifact aggiornati saranno disponibili entro ~1 minuto.
244+
245+
Rate-limit: GitHub allows ~2 dispatches per hour per repo/ref.
246+
This tool enforces a local guard of one dispatch per minute.
158247
"""
248+
global _last_refresh_attempt
249+
159250
token = _get_env("GITHUB_TOKEN")
160251
if not token:
161-
return (
162-
"Errore: GITHUB_TOKEN non impostato. "
163-
"Serve un token con scope 'workflow' per triggerare il build."
252+
return json.dumps({
253+
"ok": False,
254+
"tool": "refresh_context",
255+
"error": "GITHUB_TOKEN non impostato. Serve un token con scope 'workflow'.",
256+
"ts": datetime.now(timezone.utc).isoformat(),
257+
})
258+
259+
now = time.monotonic()
260+
if _last_refresh_attempt is not None:
261+
elapsed = now - _last_refresh_attempt
262+
if elapsed < _REFRESH_MIN_INTERVAL:
263+
wait = _REFRESH_MIN_INTERVAL - elapsed
264+
return json.dumps({
265+
"ok": False,
266+
"tool": "refresh_context",
267+
"error": f"Troppo presto. Ultimo tentativo {int(elapsed)}s fa. "
268+
f"Aspetta ~{int(wait)}s prima di riprovare.",
269+
"retry_after": int(wait),
270+
"ts": datetime.now(timezone.utc).isoformat(),
271+
})
272+
273+
_last_refresh_attempt = now
274+
try:
275+
response = requests.post(
276+
f"{_API_BASE}/actions/workflows/build-context.yml/dispatches",
277+
json={"ref": "main"},
278+
headers={
279+
"Authorization": f"token {token}",
280+
"Accept": "application/vnd.github+json",
281+
},
282+
timeout=10,
164283
)
165-
166-
response = requests.post(
167-
f"{_API_BASE}/actions/workflows/build-context.yml/dispatches",
168-
json={"ref": "main"},
169-
headers={
170-
"Authorization": f"token {token}",
171-
"Accept": "application/vnd.github+json",
172-
},
173-
timeout=10,
174-
)
175-
if response.status_code == 204:
176-
return "Build triggerato. Gli artifact saranno aggiornati entro ~1 minuto."
177-
return f"Errore: {response.status_code}{response.text}"
284+
if response.status_code == 204:
285+
_log.info("refresh_triggered ref=main")
286+
return json.dumps({
287+
"ok": True,
288+
"tool": "refresh_context",
289+
"message": "Build triggerato. Artifact aggiornati entro ~1 minuto.",
290+
"ts": datetime.now(timezone.utc).isoformat(),
291+
})
292+
elif response.status_code == 422:
293+
# 422 = workflow disabled or ref not allowed — don't retry
294+
_log.error("refresh_rejected status=%d body=%s", response.status_code, response.text)
295+
return json.dumps({
296+
"ok": False,
297+
"tool": "refresh_context",
298+
"error": "Build rifiutato (422). Verifica che il workflow sia abilitato su main.",
299+
"status_code": 422,
300+
"ts": datetime.now(timezone.utc).isoformat(),
301+
})
302+
else:
303+
_log.error("refresh_failed status=%d body=%s", response.status_code, response.text)
304+
return json.dumps({
305+
"ok": False,
306+
"tool": "refresh_context",
307+
"error": f"Errore {response.status_code}: {response.text}",
308+
"status_code": response.status_code,
309+
"ts": datetime.now(timezone.utc).isoformat(),
310+
})
311+
except requests.RequestException as e:
312+
_log.error("refresh_network_error error=%s", e)
313+
return json.dumps({
314+
"ok": False,
315+
"tool": "refresh_context",
316+
"error": f"Errore di rete: {e}",
317+
"ts": datetime.now(timezone.utc).isoformat(),
318+
})
178319

179320

180321
def main() -> None:
181322
"""Entry point per l'MCP server."""
323+
_log.info("starting mcp server repo=%s branch=%s", _REPO, _BRANCH)
182324
mcp.run()
183325

184326

0 commit comments

Comments
 (0)