Prepare State Hub retirement baseline
This commit is contained in:
parent
2217bdd9f5
commit
5927591be8
46 changed files with 32583 additions and 62 deletions
123
mcp_server/codex_server.py
Normal file
123
mcp_server/codex_server.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Small State Hub MCP surface for Codex repository sessions.
|
||||
|
||||
The full dev-hub MCP server intentionally exposes administrative and catalog
|
||||
operations. Codex repository work needs a much smaller coordination surface;
|
||||
keeping it separate reduces tool discovery cost and makes the contract clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcp_server.server import get_domain_summary as _full_get_domain_summary
|
||||
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
mcp = FastMCP(
|
||||
name="dev-hub-codex",
|
||||
instructions=(
|
||||
"Slim State Hub coordination surface for Codex repository sessions. "
|
||||
"Start with get_domain_summary, check the repository inbox, and record "
|
||||
"progress when work closes. Workplan files remain the source of truth."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _request(method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
||||
with httpx.Client(
|
||||
base_url=API_BASE,
|
||||
timeout=15.0,
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = client.request(method, path, json=body)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_domain_summary(domain_slug: str) -> str:
|
||||
"""Return actionable State Hub orientation scoped to one domain."""
|
||||
return _full_get_domain_summary(domain_slug)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_messages(to_agent: str, unread_only: bool = True) -> str:
|
||||
"""Get coordination messages for one repository agent."""
|
||||
suffix = "true" if unread_only else "false"
|
||||
return _json(_request("GET", f"/messages/?to_agent={to_agent}&unread_only={suffix}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mark_message_read(message_id: str) -> str:
|
||||
"""Mark one coordination message as read."""
|
||||
return _json(_request("PATCH", f"/messages/{message_id}/read", {}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_progress_event(
|
||||
summary: str,
|
||||
author: str = "codex",
|
||||
workplan_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record a State Hub progress note for completed or significant work."""
|
||||
body: dict[str, Any] = {
|
||||
"summary": summary,
|
||||
"event_type": "note",
|
||||
"author": author,
|
||||
}
|
||||
if workplan_id:
|
||||
body["workplan_id"] = workplan_id
|
||||
if task_id:
|
||||
body["task_id"] = task_id
|
||||
return _json(_request("POST", "/progress/", body))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def record_decision(
|
||||
title: str,
|
||||
description: str,
|
||||
topic_id: str,
|
||||
proposed_by: str = "codex",
|
||||
workplan_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record a pending decision linked to a topic and optionally a workplan."""
|
||||
body: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"topic_id": topic_id,
|
||||
"decision_type": "pending",
|
||||
"status": "open",
|
||||
"proposed_by": proposed_by,
|
||||
}
|
||||
if workplan_id:
|
||||
body["workplan_id"] = workplan_id
|
||||
return _json(_request("POST", "/decisions/", body))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_task_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
blocking_reason: str | None = None,
|
||||
) -> str:
|
||||
"""Update a task to wait, todo, progress, done, or cancel."""
|
||||
body: dict[str, Any] = {"status": status}
|
||||
if blocking_reason is not None:
|
||||
body["blocking_reason"] = blocking_reason
|
||||
return _json(_request("PATCH", f"/tasks/{task_id}", body))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
|
|
@ -57,7 +57,12 @@ HubCoreMCPServer(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return httpx.Client(base_url=API_BASE, timeout=30.0, follow_redirects=True)
|
||||
return httpx.Client(
|
||||
base_url=API_BASE,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
)
|
||||
|
||||
|
||||
def _get(path: str, params: dict | None = None) -> Any:
|
||||
|
|
@ -268,6 +273,11 @@ def get_domain_summary(domain_slug: str) -> str:
|
|||
blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
|
||||
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
|
||||
repos = _get("/repos", {"domain": domain_slug})
|
||||
active_goals = _get("/repo-goals", {"status": "active"})
|
||||
goals_by_repo: dict[str, list[dict]] = {}
|
||||
for goal in active_goals:
|
||||
repo_key = str(goal.get("repo_id") or goal.get("repo_slug") or "")
|
||||
goals_by_repo.setdefault(repo_key, []).append(goal)
|
||||
|
||||
# ── Goal guidance ──────────────────────────────────────────────────────────
|
||||
# Fetch active repo goals per repo, then cross-reference with workstreams.
|
||||
|
|
@ -289,12 +299,12 @@ def get_domain_summary(domain_slug: str) -> str:
|
|||
for repo in repos:
|
||||
repo_slug = repo["slug"]
|
||||
repo_id = repo["id"]
|
||||
active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
|
||||
if not active_goals:
|
||||
repo_goals = goals_by_repo.get(str(repo_id), goals_by_repo.get(repo_slug, []))
|
||||
if not repo_goals:
|
||||
continue
|
||||
active_goal_ids = {g["id"] for g in active_goals}
|
||||
active_goal_ids = {g["id"] for g in repo_goals}
|
||||
|
||||
for goal in active_goals:
|
||||
for goal in repo_goals:
|
||||
linked = ws_by_repo_goal.get(goal["id"], [])
|
||||
if not linked:
|
||||
needs_workplan.append({
|
||||
|
|
@ -324,7 +334,7 @@ def get_domain_summary(domain_slug: str) -> str:
|
|||
"recent_workplan_title": recent_ws["title"],
|
||||
"recent_workstream_id": recent_ws["id"],
|
||||
"recent_workstream_title": recent_ws["title"],
|
||||
"active_goal_titles": [g["title"] for g in active_goals],
|
||||
"active_goal_titles": [g["title"] for g in repo_goals],
|
||||
"message": (
|
||||
f"Workplan '{recent_ws['title']}' is not linked to the current "
|
||||
f"repo goal(s) for {repo_slug}. "
|
||||
|
|
@ -353,31 +363,6 @@ def get_domain_summary(domain_slug: str) -> str:
|
|||
if goal_guidance:
|
||||
result["goal_guidance"] = goal_guidance
|
||||
|
||||
inbox_hygiene: dict[str, Any] = {}
|
||||
try:
|
||||
from scripts.consistency_check import collect_inbox_hygiene, STALE_UNREAD_DAYS
|
||||
except ImportError:
|
||||
collect_inbox_hygiene = None # type: ignore[assignment]
|
||||
STALE_UNREAD_DAYS = 3
|
||||
if collect_inbox_hygiene is not None:
|
||||
for repo in repos:
|
||||
repo_slug = repo["slug"]
|
||||
hygiene = collect_inbox_hygiene(API_BASE, repo_slug)
|
||||
if (
|
||||
hygiene["stale_unread_count"]
|
||||
or hygiene["missing_thread"]
|
||||
or hygiene["work_requests_unpromoted"]
|
||||
):
|
||||
inbox_hygiene[repo_slug] = {
|
||||
"stale_unread_count": hygiene["stale_unread_count"],
|
||||
"stale_unread_days": STALE_UNREAD_DAYS,
|
||||
"stale_unread": hygiene["stale_unread"][:5],
|
||||
"missing_thread_count": len(hygiene["missing_thread"]),
|
||||
"work_requests_unpromoted": hygiene["work_requests_unpromoted"][:3],
|
||||
}
|
||||
if inbox_hygiene:
|
||||
result["inbox_hygiene"] = inbox_hygiene
|
||||
|
||||
# Compact capabilities list (type + title + repo_slug only, capped at 20)
|
||||
caps_raw = _get("/capability-catalog/", {"domain": domain_slug, "status": "active"})
|
||||
if isinstance(caps_raw, list):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue