0bc734c020
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>
173 lines
6.9 KiB
Python
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",
|
|
)
|