replica-omnisciente/.aurelio/mcp/savearth-mcp/server.py
Fábio Cunha 4fd171b0a4 chore(conscience): upgrade to v0.10.0 and align submodule paths
- Add savearth-workspace MCP server deployment to conscience state.
- Refactor .agent/ references to replica-omnisciente/ in operational
  scripts (setup.sh, savearth-mcp/server.py) and docs (READMEs,
  DIRECTORY_GUIDE.md, upgrade-conscience workflow).
- Document remaining legacy misspellings and stale .agent/ doc refs
  for follow-up cleanup.
- Record conscience upgrade report in data/conscience/ and
  .aurelio/memory/conscience_upgrade_report.md.

[skip ci]
2026-07-04 23:01:09 +01:00

1890 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
savearth Unified MCP Server
Consolidates device management, telemetry analysis, and infrastructure
monitoring into a single MCP server. Supports stdio (local) and SSE
(remote) transports.
Usage:
# Local (stdio — default)
python3 server.py
# Remote (SSE over HTTP)
python3 server.py --transport sse --port 8080
# With auth for SSE
MCP_AUTH_TOKEN=secret python3 server.py --transport sse
"""
import asyncio
import glob as globmod
import json
import os
import re
import shlex
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, field_validator, ConfigDict
from mcp.server.fastmcp import FastMCP, Context
from mcp.server.fastmcp.server import TransportSecuritySettings
# ─── Configuration ───────────────────────────────────────────────────
_THIS_DIR = Path(__file__).resolve().parent
_DEFAULT_PROJECT_DIR = str(_THIS_DIR.parent.parent)
PROJECT_DIR = os.environ.get("SAVEARTH_PROJECT_DIR", _DEFAULT_PROJECT_DIR)
LOGS_DIR = os.path.join(PROJECT_DIR, "logs")
ANALYZER_SCRIPT = os.path.join(
PROJECT_DIR, "replica-omnisciente/realms/smart-device-firmware/.aurelio/skills/firmware-log-analyzer/scripts/analyze_log.sh"
)
# InfluxDB — reads from env, falling back to known defaults
INFLUXDB_URL = os.environ.get("INFLUXDB_URL", "http://172.31.21.178:8086")
INFLUXDB_TOKEN = os.environ.get("INFLUXDB_TOKEN", "fAclpE9qRE9r9XfRkFf9")
INFLUXDB_ORG = os.environ.get("INFLUXDB_ORG", "Savearth")
INFLUXDB_BUCKET = os.environ.get("INFLUXDB_BUCKET", "savearth-iot")
# AWS
AWS_REGION = os.environ.get("AWS_REGION", "eu-north-1")
DYNAMODB_TABLE = os.environ.get("DYNAMODB_TABLE", "DeviceLogs-v3")
# Known fleet
DEFAULT_DEVICES = [
"dc:b4:d9:01:58:38", "dc:b4:d9:00:f6:90", "dc:b4:d9:00:f6:d4",
"10:b4:1d:e1:b5:24", "dc:b4:d9:01:57:a4", "dc:b4:d9:01:57:e0",
"dc:b4:d9:00:f6:a8", "dc:b4:d9:00:f6:f4", "dc:b4:d9:00:f6:e4",
"dc:b4:d9:00:f6:44", "dc:b4:d9:01:58:3c", "dc:b4:d9:00:f6:5c",
"dc:b4:d9:00:f6:c4", "dc:b4:d9:00:f6:94", "dc:b4:d9:01:58:48",
"dc:b4:d9:00:f6:ec", "dc:b4:d9:01:58:34", "dc:b4:d9:01:58:28",
"dc:b4:d9:01:57:c4", "10:b4:1d:e1:b5:1c", "10:b4:1d:e1:ac:84",
"dc:b4:d9:00:f6:a0", "dc:b4:d9:01:58:20", "dc:b4:d9:00:f6:54",
"10:b4:1d:e1:b4:e4", "dc:b4:d9:00:f6:a4", "10:b4:1d:e1:b5:08",
"dc:b4:d9:00:f6:28", "10:b4:1d:e1:ac:f4", "dc:b4:d9:00:f6:cc",
"10:b4:1d:e1:b5:50", "dc:b4:d9:01:57:64", "dc:b4:d9:00:f6:4c",
"10:b4:1d:e1:ad:30", "dc:b4:d9:01:57:cc", "10:b4:1d:e1:ac:8c",
"10:b4:1d:e1:ac:a8", "10:b4:1d:e1:b5:64", "dc:b4:d9:01:58:40",
"dc:b4:d9:01:57:54",
]
# ─── Initialize MCP Server ──────────────────────────────────────────
mcp = FastMCP(
"savearth-mcp",
# Disable DNS rebinding protection — server runs on a private LAN
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
# ─── Enums & Input Models ───────────────────────────────────────────
class ResponseFormat(str, Enum):
MARKDOWN = "markdown"
JSON = "json"
# ─── Shared Utilities ────────────────────────────────────────────────
async def _run_command(
cmd: List[str],
cwd: Optional[str] = None,
timeout: int = 300,
) -> Dict[str, Any]:
"""Run a shell command and return structured result."""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd or PROJECT_DIR,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
return {
"returncode": proc.returncode,
"stdout": stdout.decode("utf-8", errors="replace"),
"stderr": stderr.decode("utf-8", errors="replace"),
"success": proc.returncode == 0,
}
except asyncio.TimeoutError:
proc.kill()
return {
"returncode": -1,
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"success": False,
}
except Exception as e:
return {
"returncode": -1,
"stdout": "",
"stderr": str(e),
"success": False,
}
def _human_size(nbytes: int) -> str:
for unit in ["B", "KB", "MB", "GB"]:
if abs(nbytes) < 1024:
return f"{nbytes:.1f} {unit}"
nbytes /= 1024.0
return f"{nbytes:.1f} TB"
def _extract_warnings(stderr: str) -> str:
warnings = [l for l in stderr.split("\n") if "warning:" in l.lower()]
if not warnings:
return "No warnings"
return "\n".join(warnings[:20])
def _flux_query(query: str, timeout: int = 15) -> str:
"""Execute a Flux query against InfluxDB. Returns CSV text."""
url = f"{INFLUXDB_URL}/api/v2/query?org={INFLUXDB_ORG}"
headers = {
"Authorization": f"Token {INFLUXDB_TOKEN}",
"Content-Type": "application/vnd.flux",
"Accept": "application/csv",
}
req = urllib.request.Request(url, data=query.encode(), headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode()
except urllib.error.URLError as e:
raise ConnectionError(
f"InfluxDB unreachable at {INFLUXDB_URL}: {e}\n"
"If running locally, start an SSM tunnel first."
)
# ═════════════════════════════════════════════════════════════════════
# DEVICE TOOLS — Build, Flash, Monitor, Analyze
# ═════════════════════════════════════════════════════════════════════
@mcp.tool(
name="device_build_firmware",
annotations={
"title": "Build ESP32-S3 Firmware",
"readOnlyHint": False,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_build_firmware(
target: Optional[str] = None,
verbose: bool = False,
sdkconfig_defaults: Optional[str] = None,
ctx: Context = None,
) -> str:
"""Build the ESP32-S3 firmware using idf.py.
Args:
target: Build target — 'app' (default), 'test_<component>' for unit tests, or 'clean'.
verbose: Enable verbose build output.
sdkconfig_defaults: Optional sdkconfig defaults files. Multiple files can be specified
separated by semicolons (e.g., 'sdkconfig.defaults;sdkconfig.ci.release').
When provided, causes a reconfigure and full rebuild.
"""
if ctx:
await ctx.report_progress(0.1, "Starting build...")
cmd = ["idf.py"]
if target == "clean":
cmd.append("fullclean")
elif target and target.startswith("test_"):
cmd.extend(["-T", target, "build"])
else:
cmd.append("build")
if sdkconfig_defaults and sdkconfig_defaults.strip():
cmd.extend(["-D", f"SDKCONFIG_DEFAULTS={sdkconfig_defaults}"])
if verbose:
cmd.append("-v")
result = await _run_command(cmd, cwd=PROJECT_DIR, timeout=600)
if ctx:
await ctx.report_progress(1.0, "Build complete")
if result["success"]:
output = result["stdout"]
size_line = ""
for line in output.split("\n"):
if "Binary size" in line or "Total sizes" in line:
size_line = line.strip()
break
return (
f"## ✅ Build Successful\n\n"
f"```\n{size_line or 'Build completed.'}\n```\n\n"
f"**Warnings:**\n```\n"
f"{_extract_warnings(result['stderr'])}\n```"
)
else:
errors = result["stderr"][-2000:]
return (
f"## ❌ Build Failed\n\n"
f"**Exit code:** {result['returncode']}\n\n"
f"**Errors:**\n```\n{errors}\n```"
)
@mcp.tool(
name="device_flash",
annotations={
"title": "Flash Firmware to Device",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": False,
},
)
async def device_flash(
port: str = "/dev/ttyUSB0",
baud: int = 460800,
ctx: Context = None,
) -> str:
"""Flash compiled firmware to a connected ESP32-S3 device.
WARNING: This overwrites the device's current firmware.
Args:
port: Serial port (e.g., /dev/ttyUSB0, /dev/ttyACM0).
baud: Baud rate for flashing (96002000000).
"""
if ctx:
await ctx.report_progress(0.1, f"Flashing to {port}...")
cmd = ["idf.py", "-p", port, "-b", str(baud), "flash"]
result = await _run_command(cmd, cwd=PROJECT_DIR, timeout=120)
if result["success"]:
return (
f"## ✅ Flash Successful\n\n"
f"- **Port:** {port}\n"
f"- **Baud:** {baud}\n\n"
f"Device ready. Use `device_monitor` to view serial output."
)
else:
return (
f"## ❌ Flash Failed\n\n"
f"**Port:** {port}\n\n"
f"**Error:**\n```\n{result['stderr'][-1000:]}\n```\n\n"
f"**Troubleshooting:**\n"
f"- Check USB cable is connected\n"
f"- Try holding BOOT button while pressing RESET\n"
f"- Check permissions: `sudo chmod 666 {port}`"
)
@mcp.tool(
name="device_monitor",
annotations={
"title": "Capture Serial Monitor Output",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": False,
"openWorldHint": False,
},
)
async def device_monitor(
port: str = "/dev/ttyUSB0",
duration_seconds: int = 30,
save_to_file: bool = True,
ctx: Context = None,
) -> str:
"""Capture serial output from a connected ESP32-S3 device for N seconds.
Args:
port: Serial port for the device.
duration_seconds: Capture duration (5600 seconds).
save_to_file: Save captured output to logs/ directory.
"""
duration_seconds = max(5, min(600, duration_seconds))
if ctx:
await ctx.report_progress(0.1, f"Capturing {duration_seconds}s from {port}...")
cmd = [
"timeout", str(duration_seconds),
"idf.py", "-p", port, "monitor", "--no-reset",
]
result = await _run_command(cmd, cwd=PROJECT_DIR, timeout=duration_seconds + 10)
output = result["stdout"]
lines = output.count("\n")
if save_to_file:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"capture_{timestamp}.log"
filepath = os.path.join(LOGS_DIR, filename)
os.makedirs(LOGS_DIR, exist_ok=True)
with open(filepath, "w") as f:
f.write(output)
return (
f"## 📡 Serial Capture Complete\n\n"
f"- **Duration:** {duration_seconds}s\n"
f"- **Lines captured:** {lines}\n"
f"- **Saved to:** `{filepath}`\n\n"
f"Use `device_analyze_log` with this file to check for bugs.\n\n"
f"**Last 20 lines:**\n```\n"
f"{chr(10).join(output.split(chr(10))[-20:])}\n```"
)
else:
return (
f"## 📡 Serial Capture ({duration_seconds}s, {lines} lines)\n\n"
f"```\n{output[-3000:]}\n```"
)
@mcp.tool(
name="device_list_logs",
annotations={
"title": "List Log Files",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_list_logs(
pattern: str = "*",
response_format: str = "markdown",
) -> str:
"""List available log and analysis files in the logs/ directory.
Args:
pattern: Glob pattern to filter (e.g., '*.log', 'Firmware-*').
response_format: Output format — 'markdown' or 'json'.
"""
pattern_path = os.path.join(LOGS_DIR, pattern)
files = sorted(globmod.glob(pattern_path))
if not files:
return f"No files matching `{pattern}` in `{LOGS_DIR}`"
file_infos = []
for f in files:
if os.path.isfile(f):
stat = os.stat(f)
file_infos.append({
"name": os.path.basename(f),
"path": f,
"size_bytes": stat.st_size,
"size_human": _human_size(stat.st_size),
})
if response_format == "json":
return json.dumps(file_infos, indent=2, default=str)
lines = [f"## 📂 Log Files ({len(file_infos)} files)\n"]
for fi in file_infos:
lines.append(f"- **{fi['name']}** — {fi['size_human']}")
return "\n".join(lines)
@mcp.tool(
name="device_analyze_log",
annotations={
"title": "Analyze Firmware Log for Known Bugs",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_analyze_log(
log_file: str,
ctx: Context = None,
) -> str:
"""Run the firmware-log-analyzer on a log file. Checks for all known bug patterns.
Args:
log_file: Path to the log file (relative to logs/ or absolute).
"""
if not os.path.isabs(log_file):
log_file = os.path.join(LOGS_DIR, log_file)
if not os.path.isfile(log_file):
return f"Error: File not found: `{log_file}`"
if ctx:
await ctx.report_progress(0.2, f"Analyzing {os.path.basename(log_file)}...")
if os.path.isfile(ANALYZER_SCRIPT):
result = await _run_command(
["bash", ANALYZER_SCRIPT, log_file], timeout=60
)
output = result["stdout"] if result["stdout"] else result["stderr"]
else:
output = await _inline_analysis(log_file)
return f"## 🔍 Log Analysis: `{os.path.basename(log_file)}`\n\n```\n{output}\n```"
async def _inline_analysis(log_file: str) -> str:
"""Fallback inline analysis if analyze_log.sh is not available."""
patterns = {
"BUG-01 Config mode stuck": r"Config Mode Run: enabled=1|Config mode is ENABLED",
"BUG-03 SoftAP active": r"wifi:mode : sta \+ softAP",
"BUG-08 data_storage race": r"Failed to unlink|Has open FD",
"BUG-10 TLS force-kill": r"Force-killing stuck TLS",
"BUG-15 Beacon timeout": r"bcn_timeout",
"BUG-09 MQTT disconnect": r"MQTTRecvFailed",
"BUG-06 DNS before DHCP": r"getaddrinfo.*returns 202",
}
results = []
for name, pattern in patterns.items():
proc = await asyncio.create_subprocess_exec(
"grep", "-ciE", pattern, log_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
count = int(stdout.decode().strip() or "0")
if count > 0:
results.append(f" {name}: {count} occurrences")
if results:
return f"FOUND {len(results)} bug pattern(s):\n" + "\n".join(results)
return "HEALTHY: No known bugs detected."
@mcp.tool(
name="device_check_ota",
annotations={
"title": "Check OTA Firmware Versions on S3",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def device_check_ota() -> str:
"""Check the S3 firmware bucket for available OTA versions."""
script = os.path.join(PROJECT_DIR, "scripts/check_firmware.py")
if os.path.isfile(script):
result = await _run_command(
["python3", script], cwd=PROJECT_DIR, timeout=30
)
if result["success"]:
return f"## 🔄 OTA Firmware Status\n\n```\n{result['stdout']}\n```"
else:
return (
f"## ❌ OTA Check Failed\n\n```\n{result['stderr']}\n```\n\n"
"Ensure AWS credentials are configured."
)
else:
return "Error: `scripts/check_firmware.py` not found."
@mcp.tool(
name="device_sdkconfig_get",
annotations={
"title": "Read sdkconfig Values",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_sdkconfig_get(
key: Optional[str] = None,
response_format: str = "markdown",
) -> str:
"""Read configuration values from sdkconfig.
Args:
key: Config key to search (e.g., 'CONFIG_FREERTOS'). If empty, returns summary.
response_format: Output format — 'markdown' or 'json'.
"""
sdkconfig_path = os.path.join(PROJECT_DIR, "sdkconfig")
if not os.path.isfile(sdkconfig_path):
return "Error: `sdkconfig` not found. Run `device_build_firmware` first."
with open(sdkconfig_path, "r") as f:
lines = f.readlines()
if key:
matched = [
l.strip()
for l in lines
if key.upper() in l.upper() and not l.startswith("#")
]
if not matched:
return f"No config matching `{key}` found."
if response_format == "json":
configs = {}
for m in matched:
if "=" in m:
k, v = m.split("=", 1)
configs[k] = v
return json.dumps(configs, indent=2)
return f"## sdkconfig: `{key}`\n\n```\n" + "\n".join(matched) + "\n```"
else:
non_comment = [l.strip() for l in lines if l.strip() and not l.startswith("#")]
return (
f"## sdkconfig ({len(non_comment)} entries)\n\n"
"Use `key` parameter to filter. Example: `CONFIG_MQTT`"
)
@mcp.tool(
name="device_sdkconfig_set",
annotations={
"title": "Set sdkconfig Value",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_sdkconfig_set(
key: str,
value: str,
ctx: Context = None,
) -> str:
"""Set a configuration value in sdkconfig.defaults.
Modifies the defaults file (not sdkconfig directly) so the change
persists across clean builds. Requires a rebuild to take effect.
WARNING: Changing build config can affect firmware behavior.
Args:
key: Config key (e.g., 'CONFIG_MQTT_KEEPALIVE').
value: Config value to set.
"""
defaults_path = os.path.join(PROJECT_DIR, "sdkconfig.defaults")
if not os.path.isfile(defaults_path):
return "Error: `sdkconfig.defaults` not found."
with open(defaults_path, "r") as f:
content = f.read()
new_line = f"{key}={value}"
if key in content:
lines = content.split("\n")
updated = []
for line in lines:
if line.startswith(key + "="):
if ctx:
await ctx.log_info(f"Replacing: {line}{new_line}")
updated.append(new_line)
else:
updated.append(line)
content = "\n".join(updated)
else:
content += f"\n{new_line}\n"
if ctx:
await ctx.log_info(f"Appending: {new_line}")
with open(defaults_path, "w") as f:
f.write(content)
return (
f"## ✅ sdkconfig Updated\n\n"
f"- **Key:** `{key}`\n"
f"- **Value:** `{value}`\n"
f"- **File:** `sdkconfig.defaults`\n\n"
f"> Run `device_build_firmware` to apply changes."
)
@mcp.tool(
name="device_list_serial_ports",
annotations={
"title": "List ESP Serial Ports",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_list_serial_ports() -> str:
"""List available serial ports for ESP devices.
Uses pyserial for auto-detection with fallback to common device paths.
"""
# Try pyserial first
result = await _run_command(
["python3", "-m", "serial.tools.list_ports", "-v"], timeout=10
)
if result["success"] and result["stdout"].strip():
ports = result["stdout"].strip()
count = len([l for l in ports.split("\n") if l.strip() and not l.startswith(" ")])
return (
f"## 🔌 Serial Ports ({count} found)\n\n"
f"```\n{ports}\n```"
)
# Fallback: glob common ESP device paths
patterns = ["/dev/ttyACM*", "/dev/ttyUSB*"]
found = []
for pat in patterns:
found.extend(sorted(globmod.glob(pat)))
if found:
lines = [f"## 🔌 Serial Ports ({len(found)} found)\n"]
for port in found:
lines.append(f"- `{port}`")
lines.append("\n> *Detected via glob — install `pyserial` for detailed info.*")
return "\n".join(lines)
return (
"## 🔌 No Serial Ports Found\n\n"
"No ESP devices detected. Check:\n"
"- USB cable is connected\n"
"- Device is powered on\n"
"- User has permission (`sudo chmod 666 /dev/ttyACM0`)"
)
# Known ESP32 target chips
_VALID_TARGETS = [
"esp32", "esp32s2", "esp32s3", "esp32c2", "esp32c3",
"esp32c5", "esp32c6", "esp32h2", "esp32p4",
]
@mcp.tool(
name="device_set_target",
annotations={
"title": "Set ESP Target Chip",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_set_target(
target: str,
ctx: Context = None,
) -> str:
"""Set the target chip for the ESP-IDF project. Equivalent to `idf.py set-target`.
WARNING: This triggers a full clean rebuild.
Args:
target: Lowercase target name (e.g., 'esp32', 'esp32s3', 'esp32c3', 'esp32c6').
"""
target = target.lower().strip()
if target not in _VALID_TARGETS:
return (
f"## ❌ Invalid Target\n\n"
f"`{target}` is not a recognized ESP target.\n\n"
f"**Valid targets:** {', '.join(f'`{t}`' for t in _VALID_TARGETS)}"
)
if ctx:
await ctx.report_progress(0.1, f"Setting target to {target}...")
cmd = ["idf.py", "set-target", target]
result = await _run_command(cmd, cwd=PROJECT_DIR, timeout=120)
if result["success"]:
return (
f"## ✅ Target Set: `{target}`\n\n"
f"The project is now configured for **{target.upper()}**.\n\n"
f"> Run `device_build_firmware` to build for this target."
)
else:
return (
f"## ❌ Set Target Failed\n\n"
f"**Target:** {target}\n"
f"**Exit code:** {result['returncode']}\n\n"
f"**Error:**\n```\n{result['stderr'][-1500:]}\n```"
)
@mcp.tool(
name="device_run_pytest",
annotations={
"title": "Run Pytest Tests",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def device_run_pytest(
test_path: str = ".",
pytest_args: str = "",
ctx: Context = None,
) -> str:
"""Run pytest tests for the project. Supports pytest-embedded for ESP-IDF testing.
Uses pytest-embedded (https://docs.espressif.com/projects/pytest-embedded),
a pytest plugin for embedded testing on ESP32 targets.
Args:
test_path: Path to test file or directory, relative to project root
(default: '.', runs all tests). Examples: 'test/host', 'test/apps'.
pytest_args: Additional pytest arguments. Common options:
-v: Verbose output
-k EXPRESSION: Run tests matching expression
-m MARKER: Run tests with specific marker
--target TARGET: ESP target (esp32, esp32s3, etc.)
--sdkconfig NAME: sdkconfig config name
--junitxml=FILE: Generate JUnit XML report
"""
start_time = time.time()
if ctx:
await ctx.report_progress(0.1, f"Running pytest on {test_path}...")
# Build the command — use shell for argument splitting
pytest_cmd = f"pytest {shlex.quote(test_path)}"
if pytest_args:
pytest_cmd += f" {pytest_args}"
cmd = ["bash", "-c", pytest_cmd]
result = await _run_command(cmd, cwd=PROJECT_DIR, timeout=600)
elapsed = time.time() - start_time
elapsed_min = int(elapsed // 60)
elapsed_sec = elapsed % 60
timing = f"{elapsed_min}m {elapsed_sec:.1f}s" if elapsed_min else f"{elapsed_sec:.1f}s"
if ctx:
await ctx.report_progress(1.0, "Tests complete")
output = result["stdout"]
# Extract summary line (e.g., "===== 8 passed in 0.02s =====")
summary_line = ""
for line in reversed(output.split("\n")):
if "passed" in line or "failed" in line or "error" in line:
summary_line = line.strip()
break
if result["success"]:
return (
f"## ✅ Tests Passed\n\n"
f"- **Path:** `{test_path}`\n"
f"- **Duration:** {timing}\n"
f"- **Summary:** `{summary_line}`\n\n"
f"**Output (last 3000 chars):**\n```\n{output[-3000:]}\n```"
)
else:
stderr_snippet = result["stderr"][-1000:] if result["stderr"] else ""
return (
f"## ❌ Tests Failed\n\n"
f"- **Path:** `{test_path}`\n"
f"- **Duration:** {timing}\n"
f"- **Exit code:** {result['returncode']}\n"
f"- **Summary:** `{summary_line}`\n\n"
f"**Output (last 3000 chars):**\n```\n{output[-3000:]}\n```"
+ (f"\n\n**Stderr:**\n```\n{stderr_snippet}\n```" if stderr_snippet else "")
)
# ═════════════════════════════════════════════════════════════════════
# TELEMETRY TOOLS — InfluxDB Queries
# ═════════════════════════════════════════════════════════════════════
@mcp.tool(
name="telemetry_query",
annotations={
"title": "Execute Raw Flux Query",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_query(query: str) -> str:
"""Execute a raw Flux query against the InfluxDB database.
Args:
query: Flux query string.
"""
try:
csv_data = _flux_query(query)
return f"## 📊 Query Results\n\n```csv\n{csv_data}\n```"
except ConnectionError as e:
return f"## ❌ Query Failed\n\n**Error:**\n```\n{e}\n```"
except Exception as e:
return f"## ❌ Query Failed\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="telemetry_device_status",
annotations={
"title": "Get Device Telemetry Status",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_device_status(
device_id: str,
hours: int = 24,
) -> str:
"""Get the latest telemetry status for a specific device.
Args:
device_id: Device ID / Client ID (MAC address).
hours: Lookback window in hours (default 24).
"""
query = f"""
from(bucket: "{INFLUXDB_BUCKET}")
|> range(start: -{hours}h)
|> filter(fn: (r) => r["_measurement"] == "telemetry")
|> filter(fn: (r) => r["client_id"] == "{device_id}")
|> last()
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
"""
try:
csv_data = _flux_query(query)
return f"## 📱 Device Status: {device_id}\n\n```csv\n{csv_data}\n```"
except ConnectionError as e:
return f"## ❌ Failed to get status\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="telemetry_volume_history",
annotations={
"title": "Get Water Volume History",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_volume_history(
device_id: str,
hours: int = 24,
) -> str:
"""Get water volume consumption history for a device (hourly aggregation).
Args:
device_id: Device ID.
hours: Lookback hours (default 24).
"""
query = f"""
from(bucket: "{INFLUXDB_BUCKET}")
|> range(start: -{hours}h)
|> filter(fn: (r) => r["_measurement"] == "telemetry")
|> filter(fn: (r) => r["client_id"] == "{device_id}")
|> filter(fn: (r) => r["_field"] == "total_liters")
|> aggregateWindow(every: 1h, fn: max, createEmpty: false)
|> yield(name: "hourly_max")
"""
try:
csv_data = _flux_query(query)
return f"## 💧 Volume History: {device_id}\n\n```csv\n{csv_data}\n```"
except ConnectionError as e:
return f"## ❌ Failed to get volume history\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="telemetry_connection_health",
annotations={
"title": "Analyze Connection Reliability",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_connection_health(
device_id: Optional[str] = None,
hours: int = 24,
) -> str:
"""Analyze MQTT connection drops and reliability.
Args:
device_id: Filter by device ID (optional — omit for fleet-wide).
hours: Lookback hours (default 24).
"""
device_filter = ""
if device_id:
device_filter = f'\n |> filter(fn: (r) => r["client_id"] == "{device_id}")'
query = f"""
from(bucket: "{INFLUXDB_BUCKET}")
|> range(start: -{hours}h)
|> filter(fn: (r) => r["_measurement"] == "mqtt_events"){device_filter}
|> filter(fn: (r) => r["_field"] == "event_type")
|> group(columns: ["client_id", "_value"])
|> count()
|> group(columns: ["client_id"])
|> pivot(rowKey:["client_id"], columnKey: ["_value"], valueColumn: "_field")
"""
try:
csv_data = _flux_query(query)
return f"## 🔌 Connection Health\n\n```csv\n{csv_data}\n```"
except ConnectionError as e:
return f"## ❌ Failed to analyze health\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="telemetry_fleet_overview",
annotations={
"title": "Fleet Overview — Active Devices",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_fleet_overview(hours: int = 24) -> str:
"""List all devices that reported telemetry recently with their last-seen time.
Args:
hours: Lookback hours (default 24).
"""
query = f"""
from(bucket: "{INFLUXDB_BUCKET}")
|> range(start: -{hours}h)
|> filter(fn: (r) => r["_measurement"] == "telemetry")
|> group(columns: ["client_id"])
|> last()
|> keep(columns: ["client_id", "_time"])
|> sort(columns: ["_time"], desc: true)
"""
try:
csv_data = _flux_query(query)
return (
f"## 🌍 Fleet Overview (last {hours}h)\n\n"
f"```csv\n{csv_data}\n```\n\n"
f"**Known fleet size:** {len(DEFAULT_DEVICES)} devices"
)
except ConnectionError as e:
return f"## ❌ Failed to get fleet overview\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="telemetry_shower_sessions",
annotations={
"title": "Analyze Shower Sessions",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def telemetry_shower_sessions(
device_id: str,
days: int = 7,
) -> str:
"""Analyze shower sessions — daily volume totals and session detection.
Args:
device_id: Device ID.
days: Lookback days (default 7).
"""
query = f"""
from(bucket: "{INFLUXDB_BUCKET}")
|> range(start: -{days}d)
|> filter(fn: (r) => r["_measurement"] == "telemetry")
|> filter(fn: (r) => r["client_id"] == "{device_id}")
|> filter(fn: (r) => r["_field"] == "total_liters")
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|> difference()
|> yield(name: "daily_usage")
"""
try:
csv_data = _flux_query(query)
return (
f"## 🚿 Shower Sessions: {device_id} (last {days} days)\n\n"
f"```csv\n{csv_data}\n```"
)
except ConnectionError as e:
return f"## ❌ Failed to analyze sessions\n\n**Error:**\n```\n{e}\n```"
# ═════════════════════════════════════════════════════════════════════
# INFRASTRUCTURE TOOLS — DynamoDB, Diagnostics, Fleet Health
# ═════════════════════════════════════════════════════════════════════
@mcp.tool(
name="infra_dynamo_logs",
annotations={
"title": "Browse Device Logs in DynamoDB",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def infra_dynamo_logs(
device_id: str,
minutes: int = 30,
level_filter: Optional[str] = None,
keyword: Optional[str] = None,
) -> str:
"""Browse device logs from DynamoDB (DeviceLogs-v3).
Args:
device_id: Device ID (MAC address).
minutes: Lookback window in minutes (default 30).
level_filter: Filter by log level — 'E' (error), 'W' (warning), 'I' (info).
keyword: Filter log messages containing this keyword (case-insensitive).
"""
try:
import boto3
from boto3.dynamodb.conditions import Key
except ImportError:
return "Error: `boto3` not installed."
try:
dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
table = dynamodb.Table(DYNAMODB_TABLE)
now = datetime.now(timezone.utc)
start_ms = int((now - timedelta(minutes=minutes)).timestamp() * 1000)
kce = Key("device_id").eq(device_id) & Key("timestamp").gt(
Decimal(str(start_ms))
)
resp = table.query(KeyConditionExpression=kce, ScanIndexForward=True)
items = resp.get("Items", [])
while "LastEvaluatedKey" in resp:
resp = table.query(
KeyConditionExpression=kce,
ScanIndexForward=True,
ExclusiveStartKey=resp["LastEvaluatedKey"],
)
items.extend(resp.get("Items", []))
# Apply filters
if level_filter:
items = [i for i in items if i.get("level", "") == level_filter.upper()]
if keyword:
kw = keyword.lower()
items = [
i for i in items
if kw in str(i.get("msg", "")).lower()
or kw in str(i.get("tag", "")).lower()
]
if not items:
return (
f"## 📋 No Logs Found\n\n"
f"**Device:** {device_id}\n"
f"**Window:** last {minutes}m\n"
f"**Filters:** level={level_filter or 'any'}, keyword={keyword or 'none'}"
)
# Format output
error_count = sum(1 for i in items if i.get("level") == "E")
warn_count = sum(1 for i in items if i.get("level") == "W")
# Show last 50 entries
display = items[-50:]
lines = []
for item in display:
ts = int(item.get("timestamp", 0))
ts_str = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
"%H:%M:%S"
)
level = item.get("level", "?")
tag = item.get("tag", "")
msg = str(item.get("msg", ""))[:150]
lines.append(f"{ts_str} [{level}] {tag}: {msg}")
return (
f"## 📋 Device Logs: {device_id}\n\n"
f"- **Total:** {len(items)} entries (showing last {len(display)})\n"
f"- **Errors:** {error_count} | **Warnings:** {warn_count}\n"
f"- **Window:** last {minutes}m\n\n"
f"```\n" + "\n".join(lines) + "\n```"
)
except Exception as e:
return f"## ❌ DynamoDB Query Failed\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="infra_fsm_transitions",
annotations={
"title": "Analyze FSM State Transitions",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def infra_fsm_transitions(
device_id: str,
minutes: int = 60,
) -> str:
"""Parse FSM state transitions from DynamoDB logs. Detects stuck states.
Args:
device_id: Device ID (MAC address).
minutes: Lookback window in minutes (default 60).
"""
try:
import boto3
from boto3.dynamodb.conditions import Key
except ImportError:
return "Error: `boto3` not installed."
try:
dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
table = dynamodb.Table(DYNAMODB_TABLE)
now = datetime.now(timezone.utc)
start_ms = int((now - timedelta(minutes=minutes)).timestamp() * 1000)
kce = Key("device_id").eq(device_id) & Key("timestamp").gt(
Decimal(str(start_ms))
)
resp = table.query(KeyConditionExpression=kce, ScanIndexForward=True)
items = resp.get("Items", [])
while "LastEvaluatedKey" in resp:
resp = table.query(
KeyConditionExpression=kce,
ScanIndexForward=True,
ExclusiveStartKey=resp["LastEvaluatedKey"],
)
items.extend(resp.get("Items", []))
transition_pattern = re.compile(
r"STATE TRANSITION.*?(\w+)\s*→\s*(\w+)|"
r"(\w+)\s*->\s*(\w+)"
)
transitions = []
for item in items:
msg = str(item.get("msg", ""))
ts = int(item.get("timestamp", 0))
match = transition_pattern.search(msg)
if match:
from_state = match.group(1) or match.group(3)
to_state = match.group(2) or match.group(4)
ts_str = datetime.fromtimestamp(
ts / 1000, tz=timezone.utc
).strftime("%H:%M:%S")
transitions.append(f"{ts_str} {from_state}{to_state}")
if not transitions:
return (
f"## 🔄 FSM Transitions: {device_id}\n\n"
f"No state transitions found in last {minutes}m.\n"
f"Device may be offline or stuck."
)
# Check for stuck state
issues = []
last_line = transitions[-1]
if "SLEEP" in last_line.split("")[-1].strip():
issues.append(
"⚠️ Device last transitioned to SLEEP — may be stuck "
"(known FSM bug, see AGENTS.md)"
)
issues_str = "\n".join(f"- {i}" for i in issues) if issues else "None"
return (
f"## 🔄 FSM Transitions: {device_id} (last {minutes}m)\n\n"
f"```\n" + "\n".join(transitions) + "\n```\n\n"
f"**Issues:** {issues_str}"
)
except Exception as e:
return f"## ❌ FSM Analysis Failed\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="infra_fleet_health",
annotations={
"title": "Fleet Health Check",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def infra_fleet_health(minutes: int = 30) -> str:
"""Run a fleet-wide health check. Checks DynamoDB log recency for all known devices.
Args:
minutes: Lookback window in minutes (default 30).
"""
try:
import boto3
from boto3.dynamodb.conditions import Key
except ImportError:
return "Error: `boto3` not installed."
try:
dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
table = dynamodb.Table(DYNAMODB_TABLE)
now = datetime.now(timezone.utc)
start_ms = int((now - timedelta(minutes=minutes)).timestamp() * 1000)
healthy = []
stale = []
silent = []
for device_id in DEFAULT_DEVICES:
kce = Key("device_id").eq(device_id) & Key("timestamp").gt(
Decimal(str(start_ms))
)
resp = table.query(
KeyConditionExpression=kce,
ScanIndexForward=False,
Limit=1,
)
items = resp.get("Items", [])
if items:
latest_ts = int(items[0].get("timestamp", 0))
delta = now - datetime.fromtimestamp(
latest_ts / 1000, tz=timezone.utc
)
age_min = delta.total_seconds() / 60
if age_min < minutes:
healthy.append(f"{device_id} (last log {age_min:.0f}m ago)")
else:
stale.append(f"⚠️ {device_id} (last log {age_min:.0f}m ago)")
else:
silent.append(f"{device_id}")
total = len(DEFAULT_DEVICES)
return (
f"## 🌍 Fleet Health ({len(healthy)}/{total} active)\n\n"
f"### Active ({len(healthy)})\n"
+ "\n".join(f"- {h}" for h in healthy[:20])
+ ("\n- _...and more_" if len(healthy) > 20 else "")
+ f"\n\n### Stale ({len(stale)})\n"
+ ("\n".join(f"- {s}" for s in stale) if stale else "- None")
+ f"\n\n### Silent — no logs in {minutes}m ({len(silent)})\n"
+ ("\n".join(f"- {s}" for s in silent) if silent else "- None")
)
except Exception as e:
return f"## ❌ Fleet Health Check Failed\n\n**Error:**\n```\n{e}\n```"
@mcp.tool(
name="infra_iot_things",
annotations={
"title": "List IoT Things and Principals",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def infra_iot_things(device_id: Optional[str] = None) -> str:
"""List IoT things registered in AWS, or get details for a specific device.
Args:
device_id: Specific device to inspect (optional — omit for full list).
"""
try:
import boto3
except ImportError:
return "Error: `boto3` not installed."
try:
iot = boto3.client("iot", region_name=AWS_REGION)
if device_id:
# Get thing details
try:
thing = iot.describe_thing(thingName=device_id)
except iot.exceptions.ResourceNotFoundException:
return f"## ❌ Thing `{device_id}` not found in IoT Core."
principals = iot.list_thing_principals(thingName=device_id)
cert_arns = principals.get("principals", [])
return (
f"## 🔧 IoT Thing: {device_id}\n\n"
f"- **Thing Name:** {thing.get('thingName')}\n"
f"- **Thing ID:** {thing.get('thingId')}\n"
f"- **Version:** {thing.get('version')}\n"
f"- **Certificates attached:** {len(cert_arns)}\n\n"
f"**Certificate ARNs:**\n"
+ "\n".join(f"- `{a.split('/')[-1][:12]}...`" for a in cert_arns)
)
else:
# List all things
things = []
params = {"maxResults": 100}
while True:
resp = iot.list_things(**params)
things.extend(resp.get("things", []))
if "nextToken" in resp:
params["nextToken"] = resp["nextToken"]
else:
break
lines = [f"## 🔧 IoT Things ({len(things)})"]
for t in sorted(things, key=lambda x: x.get("thingName", "")):
name = t.get("thingName", "?")
lines.append(f"- {name}")
return "\n".join(lines)
except Exception as e:
return f"## ❌ IoT Query Failed\n\n**Error:**\n```\n{e}\n```"
# ═════════════════════════════════════════════════════════════════════
# CI TOOLS — GitHub Actions Workflow Analysis
# ═════════════════════════════════════════════════════════════════════
# Error patterns for build failure analysis
_CI_ERROR_PATTERNS = [
{
"id": "missing_header",
"pattern": r"fatal error:\s+(\S+\.h):\s+No such file or directory",
"category": "build",
"description": "Missing header file",
"suggestion": "Add the directory containing {match} to INCLUDE_DIRS in CMakeLists.txt",
},
{
"id": "undefined_reference",
"pattern": r"undefined reference to `(\w+)'",
"category": "linker",
"description": "Undefined symbol (linker error)",
"suggestion": "Add the component containing {match} to REQUIRES in CMakeLists.txt",
},
{
"id": "test_failure",
"pattern": r"(\S+):(\d+):(\w+):FAIL:\s*(.*)",
"category": "test",
"description": "Unity test assertion failure",
"suggestion": "Test {match} failed",
},
{
"id": "coverage_threshold",
"pattern": r"Line coverage ([\d.]+)% is below ([\d.]+)% threshold",
"category": "coverage",
"description": "Coverage below threshold",
"suggestion": "Add more tests to increase line coverage from {match}",
},
{
"id": "compiler_error",
"pattern": r"(\S+\.[ch]):(\d+):\d+:\s+error:\s+(.*)",
"category": "build",
"description": "Compiler error",
"suggestion": "Fix {match}",
},
{
"id": "ninja_failed",
"pattern": r"ninja failed with exit code (\d+)",
"category": "build",
"description": "Build system failure",
"suggestion": "Check the compiler errors above for the root cause",
},
]
async def _gh_api(endpoint: str, jq_filter: str = ".") -> dict | list | str:
"""Call GitHub API via gh CLI."""
result = await _run_command(
["gh", "api", endpoint, "--jq", jq_filter], timeout=30
)
if not result["success"]:
raise RuntimeError(f"gh api failed: {result['stderr']}")
return result["stdout"]
async def _gh_run_logs(run_id: str, failed_only: bool = False) -> str:
"""Fetch run logs via gh CLI."""
args = ["gh", "run", "view", str(run_id)]
args.append("--log-failed" if failed_only else "--log")
result = await _run_command(args, timeout=120)
if not result["success"]:
if "no failed steps" in result["stderr"].lower():
return ""
raise RuntimeError(f"gh run view failed: {result['stderr']}")
return result["stdout"]
@mcp.tool(
name="ci_list_runs",
annotations={
"title": "List Recent CI Runs",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def ci_list_runs(
branch: Optional[str] = None,
limit: int = 10,
response_format: str = "markdown",
) -> str:
"""List recent GitHub Actions workflow runs with status and conclusion.
Args:
branch: Filter by branch name (optional).
limit: Number of runs to return (default 10, max 30).
response_format: Output format — 'markdown' or 'json'.
"""
limit = min(limit, 30)
endpoint = f"repos/SavearthTech/smart-device-firmware/actions/runs?per_page={limit}"
if branch:
endpoint += f"&branch={branch}"
jq = '.workflow_runs[] | {id: .id, title: .display_title, status: .status, conclusion: .conclusion, branch: .head_branch, event: .event, created: .created_at, url: .html_url}'
raw = await _gh_api(endpoint, jq)
if not raw:
return "No workflow runs found."
runs = []
for line in raw.strip().split("\n"):
if line.strip():
try:
runs.append(json.loads(line))
except json.JSONDecodeError:
continue
if response_format == "json":
return json.dumps(runs, indent=2)
lines = [f"## 🔄 CI Runs" + (f" (branch: `{branch}`)" if branch else "")]
lines.append("")
lines.append("| Status | ID | Branch | Title | Created |")
lines.append("|--------|-----|--------|-------|---------|")
for run in runs:
conclusion = run.get("conclusion") or run.get("status", "?")
icon = {"success": "", "failure": "", "in_progress": "🔄",
"queued": "", "cancelled": "🚫"}.get(conclusion, "")
branch_str = run.get("branch", "?")
title = run.get("title", "?")[:50]
created = run.get("created", "?")[:19]
run_id = run.get("id", "?")
lines.append(f"| {icon} {conclusion} | `{run_id}` | `{branch_str}` | {title} | {created} |")
return "\n".join(lines)
@mcp.tool(
name="ci_run_status",
annotations={
"title": "CI Run Status Details",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def ci_run_status(
run_id: str,
response_format: str = "markdown",
) -> str:
"""Get detailed status of a CI workflow run including all jobs and steps.
Args:
run_id: Workflow run ID (numeric).
response_format: Output format — 'markdown' or 'json'.
"""
# Fetch run metadata
run_raw = await _gh_api(
f"repos/SavearthTech/smart-device-firmware/actions/runs/{run_id}",
'{id: .id, title: .display_title, status: .status, conclusion: .conclusion, branch: .head_branch, event: .event, created: .created_at, url: .html_url}'
)
run_data = json.loads(run_raw)
# Fetch jobs with steps
jobs_raw = await _gh_api(
f"repos/SavearthTech/smart-device-firmware/actions/runs/{run_id}/jobs",
'.jobs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, steps: [.steps[] | {name: .name, number: .number, conclusion: .conclusion}]}'
)
jobs = []
for line in jobs_raw.strip().split("\n"):
if line.strip():
try:
jobs.append(json.loads(line))
except json.JSONDecodeError:
continue
summary = {
"run": run_data,
"jobs": jobs,
"failed_jobs": [j["name"] for j in jobs if j.get("conclusion") == "failure"],
"failed_steps": [
{"job": j["name"], "step": s["name"]}
for j in jobs
for s in j.get("steps", [])
if s.get("conclusion") == "failure"
],
}
if response_format == "json":
return json.dumps(summary, indent=2)
conclusion = run_data.get("conclusion") or run_data.get("status", "?")
icon = {"success": "", "failure": "", "in_progress": "🔄"}.get(conclusion, "")
lines = [
f"## {icon} Run `{run_id}`",
f"",
f"- **Title:** {run_data.get('title', '?')}",
f"- **Status:** {conclusion}",
f"- **Branch:** `{run_data.get('branch', '?')}`",
f"- **URL:** {run_data.get('url', '?')}",
f"",
]
for job in jobs:
j_conclusion = job.get("conclusion") or job.get("status", "?")
j_icon = {"success": "", "failure": "", "skipped": "⏭️",
"in_progress": "🔄"}.get(j_conclusion, "")
lines.append(f"### {j_icon} {job['name']}")
for step in job.get("steps", []):
s_conclusion = step.get("conclusion") or ""
s_icon = {"success": "", "failure": "", "skipped": ""}.get(s_conclusion, "")
lines.append(f"- {s_icon} {step['name']}")
lines.append("")
if summary["failed_steps"]:
lines.append("### ⚠️ Failed Steps")
for fs in summary["failed_steps"]:
lines.append(f"- **{fs['job']}** → {fs['step']}")
return "\n".join(lines)
@mcp.tool(
name="ci_job_logs",
annotations={
"title": "Fetch CI Job Logs",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def ci_job_logs(
run_id: str,
job_name: Optional[str] = None,
failed_only: bool = True,
tail: int = 100,
) -> str:
"""Fetch full logs for a workflow run or specific job.
Args:
run_id: Workflow run ID.
job_name: Filter by job name substring (optional — omit for all jobs).
failed_only: If true, only fetch logs from failed steps (default: true).
tail: Number of lines to return from the end (default: 100, max: 500).
"""
tail = min(tail, 500)
logs = await _gh_run_logs(run_id, failed_only=failed_only)
if not logs:
return f"## ✅ No {'failed ' if failed_only else ''}logs for run `{run_id}`."
# Filter by job name if specified
if job_name:
filtered = []
for line in logs.split("\n"):
if job_name.lower() in line.lower():
filtered.append(line)
logs = "\n".join(filtered)
# Strip ANSI codes for clean output
logs = re.sub(r"\033\[[0-9;]*[mK]", "", logs)
# Tail
log_lines = logs.strip().split("\n")
total = len(log_lines)
if total > tail:
log_lines = log_lines[-tail:]
truncation_msg = f"\n> Showing last {tail} of {total} lines. Use `tail` parameter for more.\n\n"
else:
truncation_msg = ""
return (
f"## 📋 CI Logs — Run `{run_id}`"
+ (f" (job: `{job_name}`)" if job_name else "")
+ f"\n\n{truncation_msg}"
+ f"```\n" + "\n".join(log_lines) + "\n```"
)
@mcp.tool(
name="ci_analyze_failure",
annotations={
"title": "Analyze CI Failure",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def ci_analyze_failure(
run_id: str,
response_format: str = "markdown",
) -> str:
"""Analyze a failed CI run: extract errors, identify patterns, and suggest fixes.
Detects: missing headers, linker errors, test failures, coverage threshold
violations, and compiler errors. For missing headers, searches the workspace
to locate the file and suggest the correct include path.
Args:
run_id: Workflow run ID.
response_format: Output format — 'markdown' or 'json'.
"""
# Get failed logs
logs = await _gh_run_logs(run_id, failed_only=True)
if not logs:
return f"## ✅ No failures in run `{run_id}`."
# Strip ANSI codes
clean_logs = re.sub(r"\033\[[0-9;]*[mK]", "", logs)
log_lines = clean_logs.split("\n")
errors = []
for i, line in enumerate(log_lines):
for pat in _CI_ERROR_PATTERNS:
m = re.search(pat["pattern"], line)
if m:
# Context
ctx_before = log_lines[max(0, i - 2):i]
ctx_after = log_lines[i + 1:min(len(log_lines), i + 3)]
# Job/step from tab-delimited prefix
parts = line.split("\t", 2)
job_name = parts[0].strip() if len(parts) >= 1 else ""
step_name = parts[1].strip() if len(parts) >= 2 else ""
match_str = m.group(1) if m.lastindex and m.lastindex >= 1 else m.group(0)
suggestion = pat["suggestion"].format(match=match_str)
errors.append({
"pattern_id": pat["id"],
"category": pat["category"],
"description": pat["description"],
"matched_text": m.group(0),
"match_key": match_str,
"suggestion": suggestion,
"job": job_name,
"step": step_name,
"line": i + 1,
"context_before": ctx_before,
"context_after": ctx_after,
})
# Deduplicate
seen = set()
unique = []
for err in errors:
key = (err["pattern_id"], err["matched_text"])
if key not in seen:
seen.add(key)
unique.append(err)
# For missing headers, locate them in workspace
header_locations = {}
for err in unique:
if err["pattern_id"] == "missing_header":
header = re.search(r"fatal error:\s+(\S+\.h)", err["matched_text"])
if header:
h_name = header.group(1)
result = await _run_command(
["find", PROJECT_DIR, "-name", h_name,
"-not", "-path", "*/build/*", "-not", "-path", "*/.git/*"],
timeout=10,
)
if result["success"] and result["stdout"].strip():
locations = result["stdout"].strip().split("\n")
header_locations[h_name] = locations
result_data = {
"run_id": run_id,
"total_matches": len(errors),
"unique_errors": len(unique),
"errors": unique,
"header_locations": header_locations,
"categories": {
cat: sum(1 for e in unique if e["category"] == cat)
for cat in set(e["category"] for e in unique)
},
}
if response_format == "json":
return json.dumps(result_data, indent=2)
if not unique:
return (
f"## ⚠️ Run `{run_id}` failed but no recognized error patterns found.\n\n"
f"Use `ci_job_logs` to view the raw failed logs."
)
lines = [
f"## 🔍 CI Failure Analysis — Run `{run_id}`",
f"",
f"Found **{len(unique)}** unique error(s)",
f"",
]
for idx, err in enumerate(unique, 1):
lines.append(f"### Error #{idx}: {err['description']}")
if err.get("job"):
lines.append(f"- **Job:** {err['job']}")
if err.get("step"):
lines.append(f"- **Step:** {err['step']}")
lines.append(f"- **Match:** `{err['matched_text']}`")
lines.append(f"- **Fix:** {err['suggestion']}")
# Show context
if err.get("context_before") or err.get("context_after"):
lines.append(f"")
lines.append("```")
for ctx in err["context_before"][-2:]:
ctx_text = ctx.split("\t")[-1] if "\t" in ctx else ctx
lines.append(ctx_text.rstrip())
lines.append(f">>> {err['matched_text']}")
for ctx in err["context_after"][:2]:
ctx_text = ctx.split("\t")[-1] if "\t" in ctx else ctx
lines.append(ctx_text.rstrip())
lines.append("```")
lines.append("")
# Missing header locations
if header_locations:
lines.append("### 📍 Header Locations Found")
for h_name, locs in header_locations.items():
for loc in locs[:3]:
rel = os.path.relpath(loc, PROJECT_DIR)
parent = os.path.dirname(rel)
lines.append(f"- `{h_name}` → `{rel}`")
lines.append(f" - Add to INCLUDE_DIRS: `\"../../../{parent}\"`")
lines.append("")
return "\n".join(lines)
@mcp.tool()
async def memory_search(
query: str,
response_format: ResponseFormat = ResponseFormat.MARKDOWN,
) -> str:
"""
Search the persistent Hybrid Memory Engine (replica-omnisciente/.aurelio/memory/) for knowledge graph entities.
Args:
query: The topic or keywords to search for.
response_format: Output format — 'markdown' or 'json'.
"""
memory_dir = Path(PROJECT_DIR) / "replica-omnisciente" / ".aurelio" / "memory"
if not memory_dir.exists():
return f"Error: Memory engine directory not found at {memory_dir}"
results = []
terms = [t.lower() for t in query.split() if t.strip()]
if not terms:
return "Error: Empty query."
for md_file in memory_dir.rglob("*.md"):
try:
content = md_file.read_text(encoding="utf-8")
lower_content = content.lower()
score = 0
for term in terms:
# Basic term frequency scoring
score += lower_content.count(term)
if score > 0:
rel_path = md_file.relative_to(memory_dir)
snippet = ""
for line in content.split('\n'):
if any(term in line.lower() for term in terms):
snippet = line.strip()[:150]
break
results.append({
"file": str(rel_path),
"score": score,
"snippet": snippet
})
except Exception:
pass
results.sort(key=lambda x: x["score"], reverse=True)
if response_format == ResponseFormat.JSON:
return json.dumps({"query": query, "results": results[:10]}, indent=2)
if not results:
return f"No results found in memory graph for query: '{query}'"
lines = [f"## 🧠 Memory Search Results for '{query}'\n"]
for i, res in enumerate(results[:10], 1):
lines.append(f"{i}. **{res['file']}** (Score: {res['score']})")
lines.append(f" > {res['snippet']}...")
lines.append("")
return "\n".join(lines)
# ═════════════════════════════════════════════════════════════════════
# ENTRY POINT — supports stdio and SSE transports
# ═════════════════════════════════════════════════════════════════════
def main():
import argparse
parser = argparse.ArgumentParser(description="savearth Unified MCP Server")
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="Transport mode (default: stdio)",
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("MCP_PORT", "8080")),
help="HTTP port for SSE transport (default: 8080)",
)
parser.add_argument(
"--host",
default=os.environ.get("MCP_HOST", "0.0.0.0"),
help="Bind host for SSE transport (default: 0.0.0.0)",
)
args = parser.parse_args()
# FastMCP v1.27+: host/port are constructor settings, not run() kwargs.
# Update the settings object before calling run().
if args.transport in ("sse", "streamable-http"):
mcp.settings.host = args.host
mcp.settings.port = args.port
mcp.run(transport=args.transport)
if __name__ == "__main__":
main()