Files
cleveragents-core/tools/_mcp_common.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

173 lines
6.9 KiB
Python

"""Shared helpers for the project's MCP servers.
Each ``tools/mcp_*_server.py`` is a per-server process (per the design
in ``docs/development/...`` — graphify, ci, forgejo, git, handoff,
block_store), but they all share:
1. **Forgejo runtime context.** The ``_claim_runtime`` helpers expect
a ``RuntimeContext`` protocol object with ``token``,
``request_timeout_s``, ``api_retries``, ``claim_ttl_seconds``,
``owner``, ``repo``. Three MCPs (``ci``, ``forgejo``, ``git``) all
need to build one from env. Factored out so a PAT rotation or
default-tweak lands in one place.
2. **Identity selection (HAL9000 vs HAL9001).** Most write operations
use the worker identity (``FORGEJO_PAT`` — HAL9000), but the
reviewer's formal review submissions and umbrella-PR approvals use
the reviewer identity (``FORGEJO_REVIEWER_PAT`` — HAL9001). Passing
``reviewer=True`` to :class:`ForgejoCfg` produces a context with
the HAL9001 token while leaving owner/repo/timeouts alone.
3. **Sibling-module loader bootstrap.** Every MCP server inserts
``tools/`` into ``sys.path`` and imports ``load_sibling`` to load
non-package siblings (``_claim_runtime``, ``_review_fetch``, etc.).
:func:`bootstrap_loader` does the path insert + import in one call.
4. **Error envelope shape.** Tools return ``{"error": str, **fields}``
so the agent's parser branches once on a single key. Centralised
here as :func:`error_envelope`.
5. **Process entrypoint.** Every server's ``main()`` is the same
try/run/KeyboardInterrupt/Exception wrapper. :func:`make_main`
builds one bound to the server name for stderr labelling.
Kept deliberately small. If something only one MCP needs, it lives in
that MCP's file — not here.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Any, Callable
# ─── Runtime tuning ────────────────────────────────────────────────
# Per-call HTTP timeout for Forgejo API requests issued from the MCP
# servers. The default matches what the dispatchers use for similar
# read paths; bump via env if a slow Forgejo causes spurious tool
# errors during a long worker cycle.
_FORGEJO_TIMEOUT_S = int(os.environ.get("MCP_FORGEJO_TIMEOUT_S", "15"))
_FORGEJO_RETRIES = int(os.environ.get("MCP_FORGEJO_RETRIES", "3"))
class ForgejoCfg:
"""Minimal RuntimeContext satisfying ``_claim_runtime``'s
protocol, plus the ``owner`` / ``repo`` fields the ``_review_*``
helpers read directly.
Constructed per-call (cheap — just env reads) so a PAT rotation
on the dispatcher side is picked up without restarting the MCP
server. The ``reviewer`` flag selects HAL9001's PAT instead of
HAL9000's — used by tools that act on behalf of the umbrella
approver identity (notably the reviewer worker's review-submit
path).
"""
def __init__(self, *, reviewer: bool = False) -> None:
if reviewer:
self.token = os.environ.get("FORGEJO_REVIEWER_PAT", "")
else:
self.token = os.environ.get("FORGEJO_PAT", "")
self.request_timeout_s = _FORGEJO_TIMEOUT_S
self.api_retries = _FORGEJO_RETRIES
# Unused by reads but required by the RuntimeContext protocol.
# Defaults match the implementer dispatcher's own claim_ttl.
self.claim_ttl_seconds = int(os.environ.get("MCP_CLAIM_TTL_S", "7200"))
# ``_review_*`` helpers read these directly off cfg rather
# than the module-level fallbacks in ``_claim_runtime``.
self.owner = os.environ.get("FORGEJO_OWNER", "cleveragents")
self.repo = os.environ.get("FORGEJO_REPO", "cleveragents-core")
def require_token(cfg: ForgejoCfg, identity_label: str) -> str | None:
"""Return ``None`` if ``cfg.token`` is set; otherwise an
error-message string the MCP tool can return verbatim to the
agent. Centralised so every tool prints the same actionable
message ("set X env var") instead of opaque HTTP-401 errors."""
if cfg.token:
return None
env_name = "FORGEJO_REVIEWER_PAT" if identity_label == "reviewer" else "FORGEJO_PAT"
return (
f"{env_name} not set in MCP environment "
f"(identity={identity_label}). The MCP server inherits env from "
"the OpenCode process that spawned it — restart OpenCode after "
"sourcing tools/launch_fork.sh."
)
# ─── Sibling-module loader ──────────────────────────────────────────
def bootstrap_loader() -> Callable[[str, str], ModuleType]:
"""Insert ``tools/`` into ``sys.path`` and return ``load_sibling``.
Every MCP server starts with the same 3-line dance:
sys.path.insert(0, str(Path(__file__).parent))
from _loader import load_sibling
Wrapped here so a server's prelude becomes one expression::
load_sibling = bootstrap_loader()
_claim_runtime = load_sibling("_claim_runtime", "_claim_runtime.py")
"""
tools_dir = str(Path(__file__).resolve().parent)
if tools_dir not in sys.path:
sys.path.insert(0, tools_dir)
from _loader import load_sibling # noqa: E402 — deliberately late
return load_sibling
# ─── Error envelope ────────────────────────────────────────────────
def error_envelope(error: str, **fields: Any) -> dict[str, Any]:
"""Uniform error envelope shape used by every MCP tool.
Tools return ``{"error": str, **fields}`` so the agent's parser
branches once on the ``error`` key. Tools that return collections
on success include the empty collection in ``fields`` so the
agent's parsing code stays the same on error and success.
"""
return {"error": error, **fields}
# ─── Process entrypoint ────────────────────────────────────────────
def make_main(server: Any, server_name: str) -> Callable[[], int]:
"""Build a ``main()`` for an MCP server bound to ``server``.
Returns a zero-arg function suitable for ``sys.exit(main())``.
Same shape every MCP used to inline: KeyboardInterrupt → 0,
any other Exception → log to stderr + return 1.
``server_name`` appears in the stderr fatal line; OpenCode logs
that to its server output so operators can tell which MCP died.
"""
def main() -> int:
try:
server.run()
except KeyboardInterrupt:
return 0
except Exception as exc: # noqa: BLE001 — top-of-process catch
print(f"{server_name}: fatal: {exc!r}", file=sys.stderr)
return 1
return 0
return main
__all__ = (
"ForgejoCfg",
"bootstrap_loader",
"error_envelope",
"make_main",
"require_token",
)