state-hub/mcp_server/codex_server.py
tegwick ef541f58cf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 53s
STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
  agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
  leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
  metered, deprecated no-op), delivery receipts on the scoped unread inbox,
  POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
  archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
  hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
  dashboard standing-notices panel.
- T06: 17 new tests; full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
2026-09-22 00:26:33 +02:00

137 lines
4 KiB
Python

"""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
from urllib.parse import quote
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, reader: str | None = None) -> str:
"""Mark one coordination message as read.
Pass ``reader`` (your repository slug) so a broadcast is marked read for
you only; without it a broadcast stays visible and the call is deprecated.
"""
path = f"/messages/{message_id}/read"
if reader:
path += f"?reader={quote(reader, safe='')}"
return _json(_request("PATCH", path, {}))
@mcp.tool()
def acknowledge_notice(message_id: str, agent: str) -> str:
"""Acknowledge a standing notice after acting on it (clears it for ``agent``)."""
return _json(_request("POST", f"/messages/{message_id}/ack", {"agent": agent}))
@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")