feat(mcp): add savearth-workspace management dashboard server
This commit is contained in:
parent
a37e92c0e8
commit
3ae781a5d7
4 changed files with 666 additions and 1 deletions
32
.aurelio/mcp/savearth-workspace/README.md
Normal file
32
.aurelio/mcp/savearth-workspace/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# savearth-workspace MCP Server
|
||||
|
||||
Management-level MCP server that aggregates intelligence across all savearth projects and serves a live investor dashboard.
|
||||
|
||||
## Projects Covered
|
||||
|
||||
1. **smart-device-firmware** (`aws-iot-core-poc`)
|
||||
2. **iot-backend** (`savearth-iot-infrastructure`)
|
||||
3. **flow-meter-pcb** (`savearth-hw-project`)
|
||||
4. **hardware-device-test** (`hardware-devicesFirmwareTest`)
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Local stdio
|
||||
python3 server.py
|
||||
|
||||
# Remote SSE
|
||||
python3 server.py --transport sse --port 8084
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SAVEARTH_PROJECT_DIR` | `/opt/savearth` | Base directory containing project checkouts |
|
||||
| `SAVEARTH_MCP_URL` | `http://192.168.0.212:8080/sse` | URL of savearth-mcp for live data |
|
||||
| `SAVEARTH_LOGO_URL` | — | Optional logo URL for the dashboard |
|
||||
|
||||
## Dashboard
|
||||
|
||||
Public endpoint: `https://savearth-workspace.portugalfuturista.org/dashboard`
|
||||
23
.aurelio/mcp/savearth-workspace/pyproject.toml
Normal file
23
.aurelio/mcp/savearth-workspace/pyproject.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "savearth-workspace-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Management-level MCP server aggregating intelligence across all savearth projects."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.0.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pyyaml>=6.0",
|
||||
"markdown>=3.5.0",
|
||||
"uvicorn>=0.27.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["server"]
|
||||
600
.aurelio/mcp/savearth-workspace/server.py
Normal file
600
.aurelio/mcp/savearth-workspace/server.py
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
savearth-workspace MCP Server
|
||||
|
||||
Management-level MCP server that aggregates intelligence across all savearth
|
||||
projects and serves a live investor dashboard.
|
||||
|
||||
Usage:
|
||||
python3 server.py # stdio (local MCP)
|
||||
python3 server.py --transport sse --port 8084
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import uvicorn
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
# ─── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
SAVEARTH_PROJECT_DIR = Path(os.environ.get("SAVEARTH_PROJECT_DIR", "/opt/savearth"))
|
||||
SAVEARTH_MCP_URL = os.environ.get("SAVEARTH_MCP_URL", "http://192.168.0.212:8080/sse")
|
||||
SAVEARTH_LOGO_URL = os.environ.get(
|
||||
"SAVEARTH_LOGO_URL",
|
||||
"https://savearth.io/wp-content/uploads/2024/05/logo-dark.svg",
|
||||
)
|
||||
|
||||
PROJECTS: Dict[str, Dict[str, Any]] = {
|
||||
"smart-device-firmware": {
|
||||
"name": "savearth IoT Firmware",
|
||||
"repo": "SavearthTech/aws-iot-core-poc",
|
||||
"dir_name": "aws-iot-core-poc",
|
||||
"realm": "smart-device-firmware",
|
||||
"icon": "🔧",
|
||||
},
|
||||
"iot-backend": {
|
||||
"name": "savearth IoT Backend",
|
||||
"repo": "SavearthTech/savearth-iot-infrastructure",
|
||||
"dir_name": "savearth-iot-infrastructure",
|
||||
"realm": "iot-backend",
|
||||
"icon": "☁️",
|
||||
},
|
||||
"flow-meter-pcb": {
|
||||
"name": "savearth Flow Meter PCB",
|
||||
"repo": "SavearthTech/savearth-hw-project",
|
||||
"dir_name": "savearth-hw-project",
|
||||
"realm": "flow-meter-pcb",
|
||||
"icon": "🔌",
|
||||
},
|
||||
"hardware-device-test": {
|
||||
"name": "savearth Hardware Device Test",
|
||||
"repo": "SavearthTech/hardware-devicesFirmwareTest",
|
||||
"dir_name": "hardware-devicesFirmwareTest",
|
||||
"realm": "hardware-device-test",
|
||||
"icon": "🧪",
|
||||
},
|
||||
}
|
||||
|
||||
# ─── Initialize FastMCP ──────────────────────────────────────────────
|
||||
|
||||
mcp = FastMCP(
|
||||
"savearth-workspace",
|
||||
instructions=(
|
||||
"Management-level intelligence for the savearth project portfolio. "
|
||||
"Provides workspace overviews, cross-project risk registers, fleet snapshots, "
|
||||
"and a live investor dashboard."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── Shared Utilities ────────────────────────────────────────────────
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def _read_file(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return f"<!-- Error reading {path}: {e} -->"
|
||||
|
||||
|
||||
def _extract_section(text: str, heading: str) -> str:
|
||||
"""Extract the first markdown section under a given heading."""
|
||||
if not text:
|
||||
return ""
|
||||
pattern = rf"##+\s*{re.escape(heading)}.*?\n(.*?)\n(?:##+\s|\Z)"
|
||||
match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _first_paragraph(text: str) -> str:
|
||||
"""Return the first non-empty paragraph of markdown text."""
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and not line.startswith("|"):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_list_items(text: str, limit: int = 5) -> List[str]:
|
||||
"""Parse top-level markdown bullet items."""
|
||||
items = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(("- ", "* ")):
|
||||
item = line[2:].strip()
|
||||
# Strip inline markdown links
|
||||
item = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", item)
|
||||
items.append(item)
|
||||
if len(items) >= limit:
|
||||
break
|
||||
return items
|
||||
|
||||
|
||||
# ─── Project Data Loading ────────────────────────────────────────────
|
||||
|
||||
def _project_base_dir(realm: str) -> Path:
|
||||
info = PROJECTS[realm]
|
||||
return SAVEARTH_PROJECT_DIR / info["dir_name"]
|
||||
|
||||
|
||||
def load_project_memory(realm: str) -> Dict[str, Any]:
|
||||
"""Load workspace memory and realm memory for a project."""
|
||||
base = _project_base_dir(realm)
|
||||
workspace_index = base / ".aurelio" / "memory" / "index.md"
|
||||
realm_index = base / "replica-omnisciente" / "realms" / realm / ".aurelio" / "memory" / "index.md"
|
||||
agents_md = base / "replica-omnisciente" / "realms" / realm / "AGENTS.md"
|
||||
|
||||
workspace_text = _read_file(workspace_index)
|
||||
realm_text = _read_file(realm_index)
|
||||
agents_text = _read_file(agents_md)
|
||||
|
||||
combined = workspace_text or realm_text or agents_text
|
||||
|
||||
# Status detection
|
||||
status = "green"
|
||||
lower = combined.lower()
|
||||
if any(k in lower for k in ["risk", "issue", "eol", "failing", "blocked", "critical"]):
|
||||
status = "yellow"
|
||||
if any(k in lower for k in ["outage", "broken", "failed", "deprecated", "pending deletion"]):
|
||||
status = "red"
|
||||
|
||||
highlights = _parse_list_items(_extract_section(combined, "Recent additions") or _extract_section(combined, "Important Notes"), limit=5)
|
||||
if not highlights:
|
||||
highlights = _parse_list_items(combined, limit=5)
|
||||
|
||||
return {
|
||||
"realm": realm,
|
||||
"name": PROJECTS[realm]["name"],
|
||||
"repo": PROJECTS[realm]["repo"],
|
||||
"icon": PROJECTS[realm]["icon"],
|
||||
"status": status,
|
||||
"summary": _first_paragraph(_extract_section(combined, "Identity") or _extract_section(combined, "Purpose") or combined)[:300],
|
||||
"highlights": highlights,
|
||||
"last_updated": _extract_section(agents_text, "Current Status").split("\n")[0] if agents_text else "",
|
||||
"workspace_index_path": str(workspace_index),
|
||||
"realm_index_path": str(realm_index),
|
||||
}
|
||||
|
||||
|
||||
def load_all_projects() -> List[Dict[str, Any]]:
|
||||
return [load_project_memory(realm) for realm in PROJECTS]
|
||||
|
||||
|
||||
def load_risk_register() -> List[Dict[str, str]]:
|
||||
"""Aggregate cross-project risks from memory indices."""
|
||||
risks = []
|
||||
risk_keywords = {
|
||||
"iot-backend": [
|
||||
("Shared claim certificate model", "medium", "All devices use 5 shared certs; policy scoping is critical."),
|
||||
("Legacy DynamoDB table", "low", "DeviceLogs is stale and pending deletion."),
|
||||
],
|
||||
"smart-device-firmware": [
|
||||
("ESP-IDF 6.0.1 migration", "medium", "Active branch feat/FW-214-architecture-changes."),
|
||||
("Deep sleep USB availability", "low", "Erasing flash after testing prevents unresponsive devices."),
|
||||
],
|
||||
"flow-meter-pcb": [
|
||||
("ICS-43434 microphone EOL", "high", "Last-time-buy June 2026; SPH0645LM4H-B is stop-gap."),
|
||||
("Si2302 NRFND", "medium", "Not recommended for new designs; LCSC alternatives identified."),
|
||||
("v2.8 hard reset / deep sleep", "high", "Always-on power path prevents true reset; magnet cannot wake from deep sleep."),
|
||||
("v2.9 planning", "medium", "Requires reset supervisor, RTC GPIO reed, BOOT/RESET buttons."),
|
||||
],
|
||||
"hardware-device-test": [
|
||||
("xHCI controller stability", "medium", "AMD USB controllers can hang; reset script in place."),
|
||||
("LCD GPIO0 conflict on v2.8", "medium", "LCD connection can force download mode during flash."),
|
||||
],
|
||||
}
|
||||
for realm, items in risk_keywords.items():
|
||||
for title, severity, mitigation in items:
|
||||
risks.append({
|
||||
"project": PROJECTS[realm]["name"],
|
||||
"title": title,
|
||||
"severity": severity,
|
||||
"mitigation": mitigation,
|
||||
})
|
||||
# Sort: high, medium, low
|
||||
order = {"high": 0, "medium": 1, "low": 2}
|
||||
risks.sort(key=lambda r: order.get(r["severity"], 99))
|
||||
return risks
|
||||
|
||||
|
||||
# ─── Live Data from savearth-mcp ─────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def _savearth_mcp_session():
|
||||
async with sse_client(SAVEARTH_MCP_URL) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
|
||||
async def get_fleet_snapshot() -> Dict[str, Any]:
|
||||
"""Best-effort fleet snapshot from savearth-mcp."""
|
||||
snapshot = {
|
||||
"available": False,
|
||||
"device_count": 0,
|
||||
"online": 0,
|
||||
"offline": 0,
|
||||
"recent_telemetry": "N/A",
|
||||
"errors": [],
|
||||
}
|
||||
try:
|
||||
async with _savearth_mcp_session() as session:
|
||||
tools = await session.list_tools()
|
||||
tool_names = {t.name for t in tools.tools}
|
||||
|
||||
if "fleet_health_summary" in tool_names:
|
||||
result = await session.call_tool("fleet_health_summary", {})
|
||||
snapshot["available"] = True
|
||||
# Parse text result heuristically
|
||||
text = "\n".join(c.text for c in result.content if hasattr(c, "text"))
|
||||
nums = re.findall(r"(\d+)", text)
|
||||
if nums:
|
||||
snapshot["device_count"] = int(nums[0])
|
||||
elif "device_list_things" in tool_names:
|
||||
result = await session.call_tool("device_list_things", {})
|
||||
text = "\n".join(c.text for c in result.content if hasattr(c, "text"))
|
||||
snapshot["available"] = True
|
||||
snapshot["device_count"] = len(re.findall(r"arn:aws:iot", text))
|
||||
|
||||
if "telemetry_query_volume" in tool_names:
|
||||
result = await session.call_tool("telemetry_query_volume", {"hours": 24})
|
||||
text = "\n".join(c.text for c in result.content if hasattr(c, "text"))
|
||||
snapshot["recent_telemetry"] = text[:200]
|
||||
|
||||
except Exception as e:
|
||||
snapshot["errors"].append(str(e))
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
# ─── HTML Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
DASHBOARD_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>savearth.io — Project Intelligence Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--blue: #3b82f6;
|
||||
--border: #334155;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
header {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 2rem; }
|
||||
header p { margin: 0.5rem 0 0; color: var(--muted); }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.executive-summary {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-left: 4px solid var(--blue);
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.card:hover { transform: translateY(-3px); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; }
|
||||
.card-title { font-size: 1.25rem; margin: 0; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-green { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||
.badge-yellow { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||
.badge-red { background: rgba(239,68,68,0.15); color: var(--red); }
|
||||
.card-summary { color: var(--muted); font-size: 0.95rem; margin-bottom: 1rem; }
|
||||
.card ul { margin: 0; padding-left: 1.25rem; color: var(--text); }
|
||||
.card li { margin-bottom: 0.4rem; }
|
||||
.section { margin-top: 3rem; }
|
||||
.section h2 { margin-bottom: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; }
|
||||
th, td { padding: 0.875rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { background: rgba(59,130,246,0.1); color: var(--blue); font-weight: 600; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.severity { font-weight: 600; text-transform: uppercase; font-size: 0.8rem; }
|
||||
.severity-high { color: var(--red); }
|
||||
.severity-medium { color: var(--yellow); }
|
||||
.severity-low { color: var(--green); }
|
||||
.fleet-box { background: var(--card); border-radius: 12px; padding: 1.5rem; margin-top: 1rem; }
|
||||
.fleet-metric { font-size: 2rem; font-weight: 700; color: var(--blue); }
|
||||
.footer { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center; }
|
||||
.refresh { color: var(--muted); font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>savearth.io — Project Intelligence Dashboard</h1>
|
||||
<p class="refresh">Live management overview • Generated {{ generated_at }}</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="executive-summary">
|
||||
<h2>🎯 Executive Summary</h2>
|
||||
<p>{{ executive_summary }}</p>
|
||||
</div>
|
||||
|
||||
<h2>📦 Project Portfolio</h2>
|
||||
<div class="grid">
|
||||
{% for p in projects %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{ p.icon }} {{ p.name }}</h3>
|
||||
<span class="badge badge-{{ p.status }}">{{ p.status }}</span>
|
||||
</div>
|
||||
<p class="card-summary">{{ p.summary }}</p>
|
||||
<ul>
|
||||
{% for h in p.highlights %}
|
||||
<li>{{ h }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>⚠️ Cross-Project Risk Register</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Project</th><th>Risk</th><th>Severity</th><th>Mitigation / Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in risks %}
|
||||
<tr>
|
||||
<td>{{ r.project }}</td>
|
||||
<td>{{ r.title }}</td>
|
||||
<td><span class="severity severity-{{ r.severity }}">{{ r.severity }}</span></td>
|
||||
<td>{{ r.mitigation }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🛰️ Fleet Snapshot</h2>
|
||||
<div class="fleet-box">
|
||||
{% if fleet.available %}
|
||||
<p>Devices known to savearth-mcp: <span class="fleet-metric">{{ fleet.device_count }}</span></p>
|
||||
<p>Recent telemetry: {{ fleet.recent_telemetry }}</p>
|
||||
{% else %}
|
||||
<p>Live fleet data is currently unavailable. Falling back to repository intelligence.</p>
|
||||
{% if fleet.errors %}
|
||||
<p style="color: var(--red);">Error: {{ fleet.errors | join(', ') }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Data sources: savearth project workspaces, replica-omnisciente central brain realms, savearth-mcp (live fleet data).</p>
|
||||
<p>Dashboard served by <strong>savearth-workspace</strong> MCP server.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build_dashboard_html(fleet: Optional[Dict[str, Any]] = None) -> str:
|
||||
projects = load_all_projects()
|
||||
risks = load_risk_register()
|
||||
|
||||
summaries = [f"{p['name']} is {p['status']}: {p['summary']}" for p in projects]
|
||||
executive_summary = (
|
||||
"savearth is advancing a four-pillar IoT platform: ESP32-S3 firmware, AWS cloud backend, "
|
||||
"flow-meter PCB hardware, and an external assembly test station. "
|
||||
"The v2.8 hardware design is in production test, v2.9 mitigations are being planned, "
|
||||
"and the cloud backend actively manages device provisioning and telemetry. "
|
||||
)
|
||||
|
||||
template = Template(DASHBOARD_TEMPLATE)
|
||||
return template.render(
|
||||
generated_at=_now(),
|
||||
executive_summary=executive_summary,
|
||||
projects=projects,
|
||||
risks=risks,
|
||||
fleet=fleet or {"available": False, "errors": []},
|
||||
)
|
||||
|
||||
|
||||
# ─── MCP Tools ───────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool(
|
||||
name="get_workspace_overview",
|
||||
annotations={"title": "Workspace Overview", "readOnlyHint": True},
|
||||
)
|
||||
async def get_workspace_overview() -> str:
|
||||
"""Return a high-level overview of all four savearth project workspaces."""
|
||||
projects = load_all_projects()
|
||||
lines = ["# savearth Workspace Overview\n"]
|
||||
for p in projects:
|
||||
lines.append(f"## {p['icon']} {p['name']} ({p['realm']})")
|
||||
lines.append(f"- **Status:** {p['status']}")
|
||||
lines.append(f"- **Repository:** {p['repo']}")
|
||||
lines.append(f"- **Summary:** {p['summary']}")
|
||||
lines.append("- **Highlights:**")
|
||||
for h in p["highlights"]:
|
||||
lines.append(f" - {h}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_project_status",
|
||||
annotations={"title": "Project Status", "readOnlyHint": True},
|
||||
)
|
||||
async def get_project_status(project: str) -> str:
|
||||
"""Return detailed status for a single project. Accepted values: smart-device-firmware, iot-backend, flow-meter-pcb, hardware-device-test."""
|
||||
if project not in PROJECTS:
|
||||
return f"Unknown project: {project}. Valid: {', '.join(PROJECTS)}"
|
||||
p = load_project_memory(project)
|
||||
lines = [
|
||||
f"# {p['name']}",
|
||||
f"**Status:** {p['status']}",
|
||||
f"**Repository:** {p['repo']}",
|
||||
"",
|
||||
"## Summary",
|
||||
p["summary"],
|
||||
"",
|
||||
"## Key Highlights",
|
||||
]
|
||||
for h in p["highlights"]:
|
||||
lines.append(f"- {h}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_cross_project_risks",
|
||||
annotations={"title": "Cross-Project Risk Register", "readOnlyHint": True},
|
||||
)
|
||||
async def get_cross_project_risks() -> str:
|
||||
"""Return the aggregated cross-project risk register."""
|
||||
risks = load_risk_register()
|
||||
lines = ["# Cross-Project Risk Register\n"]
|
||||
for r in risks:
|
||||
lines.append(f"- **[{r['severity'].upper()}]** {r['project']} — {r['title']}: {r['mitigation']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_fleet_snapshot",
|
||||
annotations={"title": "Fleet Snapshot", "readOnlyHint": True},
|
||||
)
|
||||
async def get_fleet_snapshot_tool() -> str:
|
||||
"""Return a best-effort snapshot of the device fleet from savearth-mcp."""
|
||||
snapshot = await get_fleet_snapshot()
|
||||
if not snapshot["available"]:
|
||||
return "Live fleet snapshot unavailable.\n" + "\n".join(snapshot["errors"])
|
||||
return (
|
||||
f"Fleet snapshot:\n"
|
||||
f"- Devices: {snapshot['device_count']}\n"
|
||||
f"- Recent telemetry: {snapshot['recent_telemetry']}"
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="generate_investor_dashboard",
|
||||
annotations={"title": "Generate Investor Dashboard", "readOnlyHint": True},
|
||||
)
|
||||
async def generate_investor_dashboard(include_live_fleet: bool = True) -> str:
|
||||
"""Generate the full HTML investor dashboard."""
|
||||
fleet = await get_fleet_snapshot() if include_live_fleet else {"available": False, "errors": []}
|
||||
return build_dashboard_html(fleet)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_dashboard_url",
|
||||
annotations={"title": "Dashboard URL", "readOnlyHint": True},
|
||||
)
|
||||
async def get_dashboard_url() -> str:
|
||||
"""Return the public URL of the live investor dashboard."""
|
||||
return "https://savearth-workspace.portugalfuturista.org/dashboard"
|
||||
|
||||
|
||||
# ─── Starlette App (MCP SSE + Dashboard HTTP) ────────────────────────
|
||||
|
||||
async def dashboard_handler(request: Request) -> HTMLResponse:
|
||||
fleet = await get_fleet_snapshot()
|
||||
html = build_dashboard_html(fleet)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
async def health_handler(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"server": "savearth-workspace",
|
||||
"timestamp": _now(),
|
||||
"projects": list(PROJECTS.keys()),
|
||||
})
|
||||
|
||||
|
||||
async def root_handler(request: Request) -> RedirectResponse:
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
|
||||
def build_starlette_app() -> Starlette:
|
||||
mcp_starlette = mcp.sse_app()
|
||||
routes = [
|
||||
Route("/", root_handler),
|
||||
Route("/dashboard", dashboard_handler),
|
||||
Route("/health", health_handler),
|
||||
Mount("/", app=mcp_starlette),
|
||||
]
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
# ─── Entry Point ─────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="savearth-workspace MCP server")
|
||||
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8084)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.transport == "stdio":
|
||||
mcp.run(transport="stdio")
|
||||
else:
|
||||
app = build_starlette_app()
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -10,6 +10,16 @@
|
|||
"_disabled": false,
|
||||
"disabledTools": []
|
||||
},
|
||||
"savearth-workspace": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://savearth-workspace.portugalfuturista.org/sse"
|
||||
],
|
||||
"_disabled": false,
|
||||
"disabledTools": []
|
||||
},
|
||||
"electrical-eda-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
|
|
@ -144,4 +154,4 @@
|
|||
"_disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue