Files
state-hub/mcp_server/server.py
tegwick c3efb099f1 feat(custodian): add ADR-001 compliance validator
Scripts, Makefile target, and MCP tool for checking a repository
against ADR-001 (workplans as repo artefacts, state-hub as cache).

Checks performed:
  File-side: workplans/ dir exists, valid YAML frontmatter (required
  fields, type, status, id format), filename matches id, embedded
  task blocks have id/status/priority.

  State-hub cross-reference: state_hub_workstream_id references
  resolve to real DB records; orphan detection flags active DB
  workstreams with no backing workplan file.

Usage:
  make validate-adr REPO=<path> [DOMAIN=<slug>]
  validate_repo_adr(repo_path, domain_slug?)  # MCP tool

Running against the-custodian itself correctly surfaces the 4
pre-ADR-001 workstreams that still need workplan files written.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:00:09 +01:00

704 lines
24 KiB
Python

"""Custodian State Hub MCP Server (stdio).
Thin HTTP client over the FastAPI service — no direct DB access.
All business logic stays in the API; this layer is stateless.
"""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID
import httpx
from fastmcp import FastMCP
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
mcp = FastMCP(
name="state-hub",
instructions=(
"Custodian State Hub: tracks topics, workstreams, tasks, decisions, and progress events. "
"Start every session with get_state_summary() for orientation. "
"All writes emit a progress_event automatically."
),
)
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _client() -> httpx.Client:
return httpx.Client(base_url=API_BASE, timeout=30.0, follow_redirects=True)
def _get(path: str, params: dict | None = None) -> Any:
if not path.endswith("/"):
path = path + "/"
with _client() as c:
r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
r.raise_for_status()
return r.json()
def _post(path: str, body: dict) -> Any:
if not path.endswith("/"):
path = path + "/"
with _client() as c:
r = c.post(path, json={k: v for k, v in body.items() if v is not None})
r.raise_for_status()
return r.json()
def _patch(path: str, body: dict) -> Any:
if not path.endswith("/"):
path = path + "/"
with _client() as c:
r = c.patch(path, json={k: v for k, v in body.items() if v is not None})
r.raise_for_status()
return r.json()
def _delete(path: str) -> None:
with _client() as c:
r = c.delete(path)
r.raise_for_status()
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
@mcp.resource("state://summary")
def resource_summary() -> str:
"""Full StateSummary JSON — primary orientation resource."""
return json.dumps(_get("/state/summary"), indent=2)
@mcp.resource("state://topics")
def resource_topics() -> str:
"""Active topics list."""
return json.dumps(_get("/topics", {"status": "active"}), indent=2)
@mcp.resource("state://workstreams/{topic_slug}")
def resource_workstreams(topic_slug: str) -> str:
"""Workstreams for a topic (by slug)."""
topics = _get("/topics", {"status": "active"})
match = next((t for t in topics if t["slug"] == topic_slug), None)
if not match:
return json.dumps({"error": f"Topic '{topic_slug}' not found"})
return json.dumps(_get("/workstreams", {"topic_id": match["id"]}), indent=2)
@mcp.resource("state://decisions/blocking")
def resource_blocking_decisions() -> str:
"""All pending/escalated decisions."""
return json.dumps(
_get("/decisions", {"decision_type": "pending", "status": "open"}),
indent=2,
)
@mcp.resource("state://tasks/blocked")
def resource_blocked_tasks() -> str:
"""All tasks with status=blocked."""
return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2)
# ---------------------------------------------------------------------------
# Query tools
# ---------------------------------------------------------------------------
@mcp.tool()
def get_state_summary() -> str:
"""Primary orientation tool. Call at the start of every session.
Returns a full snapshot: topic/workstream/task/decision totals, blocking
decisions, blocked tasks, open workstreams, and the 20 most recent events.
"""
return json.dumps(_get("/state/summary"), indent=2)
@mcp.tool()
def get_topic(slug: str) -> str:
"""Return a topic (with workstreams) by slug, plus its recent progress events."""
topics = _get("/topics")
match = next((t for t in topics if t["slug"] == slug), None)
if not match:
return json.dumps({"error": f"Topic '{slug}' not found"})
topic_detail = _get(f"/topics/{match['id']}")
recent = _get("/progress", {"topic_id": match["id"], "limit": 10})
return json.dumps({"topic": topic_detail, "recent_progress": recent}, indent=2)
@mcp.tool()
def list_blocked_tasks(workstream_id: str | None = None) -> str:
"""List all tasks with status=blocked, optionally filtered by workstream_id."""
return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2)
@mcp.tool()
def list_pending_decisions(topic_id: str | None = None) -> str:
"""List pending decisions sorted by deadline (nulls last).
Optionally filter by topic_id. Escalated decisions are included and
highlighted by their escalation_note.
"""
results = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
return json.dumps(results, indent=2)
@mcp.tool()
def get_recent_progress(limit: int = 20, since: str | None = None) -> str:
"""Retrieve recent progress events to reconstruct session history.
Args:
limit: max events to return (default 20)
since: ISO datetime string — only events after this timestamp
"""
return json.dumps(_get("/progress", {"limit": limit, "since": since}), indent=2)
# ---------------------------------------------------------------------------
# Mutate tools
# ---------------------------------------------------------------------------
@mcp.tool()
def create_workstream(
topic_id: str,
title: str,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
) -> str:
"""Create a new workstream under a topic and emit a progress_event.
Args:
topic_id: UUID of the parent topic
title: workstream title
slug: URL-friendly identifier (auto-generated from title if omitted)
description: optional longer description
owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD)
"""
if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
ws = _post("/workstreams", {
"topic_id": topic_id,
"title": title,
"slug": slug,
"description": description,
"owner": owner,
"due_date": due_date,
"status": "active",
})
_post("/progress", {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {title}",
"author": "custodian",
"detail": {"owner": owner, "slug": slug},
})
return json.dumps(ws, indent=2)
@mcp.tool()
def create_task(
workstream_id: str,
title: str,
priority: str = "medium",
description: str | None = None,
assignee: str | None = None,
due_date: str | None = None,
) -> str:
"""Create a new task and emit a progress_event.
Args:
workstream_id: UUID of the parent workstream
title: task title
priority: low | medium | high | critical
description: optional longer description
assignee: optional assignee name
due_date: optional ISO date string (YYYY-MM-DD)
"""
task = _post("/tasks", {
"workstream_id": workstream_id,
"title": title,
"priority": priority,
"description": description,
"assignee": assignee,
"due_date": due_date,
})
_post("/progress", {
"workstream_id": workstream_id,
"task_id": task["id"],
"event_type": "task_created",
"summary": f"Task created: {title}",
"author": "custodian",
"detail": {"priority": priority, "assignee": assignee},
})
return json.dumps(task, indent=2)
@mcp.tool()
def update_task_status(
task_id: str,
status: str,
blocking_reason: str | None = None,
) -> str:
"""Update a task's status. blocking_reason is required when status='blocked'.
Args:
task_id: UUID of the task
status: todo | in_progress | blocked | done | cancelled
blocking_reason: required when status=blocked
"""
body: dict[str, Any] = {"status": status}
if blocking_reason:
body["blocking_reason"] = blocking_reason
task = _patch(f"/tasks/{task_id}", body)
_post("/progress", {
"task_id": task_id,
"workstream_id": task.get("workstream_id"),
"event_type": "task_status_changed",
"summary": f"Task status → {status}: {task['title']}",
"author": "custodian",
"detail": {"blocking_reason": blocking_reason},
})
return json.dumps(task, indent=2)
@mcp.tool()
def record_decision(
title: str,
decision_type: str = "pending",
topic_id: str | None = None,
workstream_id: str | None = None,
description: str | None = None,
rationale: str | None = None,
decided_by: str | None = None,
deadline: str | None = None,
) -> str:
"""Record a decision (made or pending).
Pending decisions touching financial/legal topics are auto-escalated per
constitution §4.
Args:
title: decision title
decision_type: made | pending
topic_id: optional topic UUID
workstream_id: optional workstream UUID (at least one required)
description: optional context
rationale: reasoning behind the decision
decided_by: person/agent who decided
deadline: ISO datetime string for when decision is needed
"""
decision = _post("/decisions", {
"title": title,
"decision_type": decision_type,
"topic_id": topic_id,
"workstream_id": workstream_id,
"description": description,
"rationale": rationale,
"decided_by": decided_by,
"deadline": deadline,
})
_post("/progress", {
"topic_id": topic_id,
"workstream_id": workstream_id,
"decision_id": decision["id"],
"event_type": "decision_recorded",
"summary": f"Decision recorded ({decision_type}): {title}",
"author": "custodian",
"detail": {"status": decision.get("status"), "escalation_note": decision.get("escalation_note")},
})
return json.dumps(decision, indent=2)
@mcp.tool()
def resolve_decision(
decision_id: str,
rationale: str,
decided_by: str,
) -> str:
"""Mark a decision as resolved.
Args:
decision_id: UUID of the decision
rationale: final reasoning/outcome
decided_by: who resolved it
"""
decision = _patch(f"/decisions/{decision_id}", {
"status": "resolved",
"decision_type": "made",
"rationale": rationale,
"decided_by": decided_by,
"decided_at": datetime.utcnow().isoformat() + "Z",
})
_post("/progress", {
"topic_id": decision.get("topic_id"),
"workstream_id": decision.get("workstream_id"),
"decision_id": decision_id,
"event_type": "decision_resolved",
"summary": f"Decision resolved by {decided_by}: {decision['title']}",
"author": "custodian",
"detail": {"rationale": rationale},
})
return json.dumps(decision, indent=2)
@mcp.tool()
def add_progress_event(
summary: str,
event_type: str = "note",
topic_id: str | None = None,
workstream_id: str | None = None,
task_id: str | None = None,
detail: dict | None = None,
) -> str:
"""Append a progress event to the log.
Args:
summary: human-readable summary of what happened
event_type: free-form label, e.g. note | milestone | blocker | insight
topic_id: optional topic UUID
workstream_id: optional workstream UUID
task_id: optional task UUID
detail: optional structured data (JSONB)
"""
event = _post("/progress", {
"topic_id": topic_id,
"workstream_id": workstream_id,
"task_id": task_id,
"event_type": event_type,
"summary": summary,
"author": "custodian",
"detail": detail,
})
return json.dumps(event, indent=2)
@mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str:
"""Update a workstream's status.
Args:
workstream_id: UUID of the workstream
status: active | blocked | completed | archived
"""
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
_post("/progress", {
"workstream_id": workstream_id,
"topic_id": ws.get("topic_id"),
"event_type": "workstream_status_changed",
"summary": f"Workstream status → {status}: {ws['title']}",
"author": "custodian",
})
return json.dumps(ws, indent=2)
# ---------------------------------------------------------------------------
# Next-steps suggestion tool (S2.3) — sanctioned write use case #2
# ---------------------------------------------------------------------------
@mcp.tool()
def get_next_steps() -> str:
"""Surface contextual next-action suggestions derived from hub state.
Returns suggestions based on:
- Recently resolved decisions → first open task in the same workstream
- Workstreams whose every dependency is now completed → first todo task
Each suggestion includes domain, workstream, task, and a plain-language
message. The hub surfaces *what* and *where* — the domain owns *how*.
This is one of the two sanctioned write-side use cases of the State Hub
(the other is resolve_decision). Suggestions are derived, not persisted.
"""
return json.dumps(_get("/state/next_steps"), indent=2)
# ---------------------------------------------------------------------------
# Dependency graph tools (S1.4)
# ---------------------------------------------------------------------------
@mcp.tool()
def create_dependency(
from_workstream_id: str,
to_workstream_id: str,
description: str | None = None,
) -> str:
"""Record that one workstream depends on another.
Semantics: from_workstream cannot fully proceed until to_workstream reaches
a satisfactory state.
Args:
from_workstream_id: UUID of the workstream that has the dependency
to_workstream_id: UUID of the workstream it depends on
description: optional human-readable explanation of the dependency
"""
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
"to_workstream_id": to_workstream_id,
"description": description,
})
return json.dumps(dep, indent=2)
@mcp.tool()
def list_dependencies(workstream_id: str) -> str:
"""Return all dependency edges touching a workstream (both directions).
The response distinguishes edges where this workstream is the dependent
(depends_on) from edges where it is the blocker (blocks).
Args:
workstream_id: UUID of the workstream to inspect
"""
edges = _get(f"/workstreams/{workstream_id}/dependencies")
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
blocks = [e for e in edges if e["to_workstream_id"] == workstream_id]
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
# ---------------------------------------------------------------------------
# Extension points & technical debt
# ---------------------------------------------------------------------------
@mcp.tool()
def register_extension_point(
domain: str,
title: str,
ep_type: str,
description: str | None = None,
location: str | None = None,
priority: str = "medium",
ep_id: str | None = None,
topic_id: str | None = None,
workstream_id: str | None = None,
) -> str:
"""Register a discovered extension point — optional future functionality not yet committed.
Extension points capture design forks: things the system *could* do that
have been noticed and parked for deliberate later consideration.
Args:
domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities
title: short description of the extension
ep_type: api | schema | mcp | dashboard | architecture | integration | other
description: longer explanation of what the extension would add
location: file:line or module where the extension point was noticed
priority: low | medium | high | critical
ep_id: optional human-readable ID, e.g. EP-CUST-001 (auto-assigned if omitted)
topic_id: UUID of related topic
workstream_id: UUID of related workstream
"""
ep = _post("/extension-points", {
"domain": domain, "title": title, "ep_type": ep_type,
"description": description, "location": location,
"priority": priority, "ep_id": ep_id,
"topic_id": topic_id, "workstream_id": workstream_id,
})
_post("/progress", {
"summary": f"Extension point registered: [{ep.get('ep_id') or ep['id'][:8]}] {title} ({ep_type}, {domain})",
"event_type": "extension_point",
"detail": {"id": ep["id"], "ep_id": ep.get("ep_id"), "ep_type": ep_type, "domain": domain},
})
return json.dumps(ep, indent=2)
@mcp.tool()
def list_extension_points(
domain: str | None = None,
status: str | None = None,
ep_type: str | None = None,
) -> str:
"""List extension points, optionally filtered.
Args:
domain: filter by domain
status: open | in_progress | addressed | deferred | wont_fix
ep_type: api | schema | mcp | dashboard | architecture | integration | other
"""
return json.dumps(_get("/extension-points", {
"domain": domain, "status": status, "ep_type": ep_type,
}), indent=2)
@mcp.tool()
def update_ep_status(ep_uuid: str, status: str) -> str:
"""Update the status of an extension point.
Args:
ep_uuid: UUID of the extension point
status: open | in_progress | addressed | deferred | wont_fix
"""
ep = _patch(f"/extension-points/{ep_uuid}", {"status": status})
_post("/progress", {
"summary": f"Extension point status → {status}: {ep['title']}",
"event_type": "extension_point",
"detail": {"id": ep_uuid, "status": status},
})
return json.dumps(ep, indent=2)
@mcp.tool()
def register_technical_debt(
domain: str,
title: str,
debt_type: str,
description: str | None = None,
location: str | None = None,
severity: str = "medium",
td_id: str | None = None,
topic_id: str | None = None,
workstream_id: str | None = None,
) -> str:
"""Register a technical debt item — a known quality compromise to address later.
Technical debt captures intentional or discovered shortcuts, design
weaknesses, missing tests, and similar issues that reduce codebase health.
Args:
domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities
title: short description of the debt
debt_type: design | implementation | test | docs | dependencies | performance | security | other
description: what the issue is and what the correct fix would be
location: file:line or module where the debt lives
severity: low | medium | high | critical
td_id: optional human-readable ID, e.g. TD-CUST-001
topic_id: UUID of related topic
workstream_id: UUID of related workstream
"""
td = _post("/technical-debt", {
"domain": domain, "title": title, "debt_type": debt_type,
"description": description, "location": location,
"severity": severity, "td_id": td_id,
"topic_id": topic_id, "workstream_id": workstream_id,
})
_post("/progress", {
"summary": f"Technical debt registered: [{td.get('td_id') or td['id'][:8]}] {title} ({debt_type}, {severity}, {domain})",
"event_type": "technical_debt",
"detail": {"id": td["id"], "td_id": td.get("td_id"), "debt_type": debt_type, "severity": severity, "domain": domain},
})
return json.dumps(td, indent=2)
@mcp.tool()
def list_technical_debt(
domain: str | None = None,
status: str | None = None,
debt_type: str | None = None,
severity: str | None = None,
) -> str:
"""List technical debt items, optionally filtered.
Args:
domain: filter by domain
status: open | in_progress | resolved | deferred | wont_fix
debt_type: design | implementation | test | docs | dependencies | performance | security | other
severity: low | medium | high | critical
"""
return json.dumps(_get("/technical-debt", {
"domain": domain, "status": status,
"debt_type": debt_type, "severity": severity,
}), indent=2)
@mcp.tool()
def update_td_status(td_uuid: str, status: str) -> str:
"""Update the status of a technical debt item.
Args:
td_uuid: UUID of the technical debt item
status: open | in_progress | resolved | deferred | wont_fix
"""
td = _patch(f"/technical-debt/{td_uuid}", {"status": status})
_post("/progress", {
"summary": f"Technical debt status → {status}: {td['title']}",
"event_type": "technical_debt",
"detail": {"id": td_uuid, "status": status},
})
return json.dumps(td, indent=2)
# ---------------------------------------------------------------------------
# ADR-001 compliance validation
# ---------------------------------------------------------------------------
@mcp.tool()
def validate_repo_adr(repo_path: str, domain_slug: str | None = None) -> str:
"""Check whether a repository is consistent with ADR-001.
Validates that workplan files exist in workplans/ with correct frontmatter,
that state_hub_workstream_id references resolve to real DB records, and that
no active state-hub workstreams for the domain lack a backing file (orphan
detection — DB-only records are an ADR-001 violation).
Args:
repo_path: Absolute path to the repository root.
domain_slug: Domain slug for orphan detection (e.g. 'custodian').
If omitted, inferred from workplan frontmatter.
"""
import subprocess
script = Path(__file__).parent.parent / "scripts" / "validate_repo_adr.py"
cmd = [sys.executable, str(script), repo_path, "--json",
"--api-base", API_BASE]
if domain_slug:
cmd += ["--domain", domain_slug]
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return f"Validator script error:\n{result.stderr or result.stdout or '(no output)'}"
findings = data.get("findings", [])
summary = data.get("summary", {})
overall = data.get("result", "unknown")
failures = [f for f in findings if f["level"] == "FAIL"]
warnings = [f for f in findings if f["level"] == "WARN"]
lines = [f"ADR-001 Compliance: {repo_path}", ""]
if failures:
lines.append(f"FAILURES ({len(failures)}):")
for f in failures:
loc = f" [{f['file']}]" if f.get("file") else ""
lines.append(f" FAIL {f['check']}{loc}")
lines.append(f" {f['detail']}")
lines.append("")
if warnings:
lines.append(f"WARNINGS ({len(warnings)}):")
for f in warnings:
loc = f" [{f['file']}]" if f.get("file") else ""
lines.append(f" WARN {f['check']}{loc}")
lines.append(f" {f['detail']}")
lines.append("")
lines.append(
f"Summary: {summary.get('pass', 0)} pass | "
f"{summary.get('warn', 0)} warn | "
f"{summary.get('fail', 0)} fail"
)
lines.append(f"Result: {'FAIL' if overall == 'fail' else 'PASS (with warnings)' if overall == 'warn' else 'PASS'}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run(transport="stdio")