blob: 61451d127b147c71c88b917c9e62138764b2512d [file]
"""Extension hook for registering additional MCP tools.
Each extension is a Python module with a top-level ``register(ctx)``.
Discovered via the ``gerrit_mcp_server.extensions`` entry-point group or
the ``GERRIT_MCP_EXTENSIONS`` env var (comma-separated module names).
``ctx`` is an :class:`ExtensionContext` exposing the stable public
surface; everything else in this package is internal. Failures in an
extension are logged and skipped, never aborting startup.
"""
from __future__ import annotations
import importlib
import os
import sys
from dataclasses import dataclass
from importlib.metadata import entry_points
from pathlib import Path
from typing import Any, Awaitable, Callable, Final
from mcp.server.fastmcp import FastMCP
from gerrit_mcp_server.plugin_registry import PluginRegistry, requires_plugin
__all__ = [
"ExtensionContext",
"ExtensionLoader",
"PluginRegistry",
"requires_plugin",
]
ENTRY_POINT_GROUP: Final[str] = "gerrit_mcp_server.extensions"
ENV_VAR: Final[str] = "GERRIT_MCP_EXTENSIONS"
@dataclass
class ExtensionContext:
"""Stable public API passed to each extension's ``register()``."""
mcp: FastMCP
"""FastMCP server. Register tools via ``@ctx.mcp.tool()``."""
get_base_url: Callable[[str | None], str]
"""Resolve a Gerrit base URL (defaults, env, normalization applied)."""
normalize_url: Callable[[str], str]
"""Normalize a Gerrit URL (internal->external, /a prefix when needed)."""
run_curl: Callable[[list[str], str], Awaitable[str]]
"""Authenticated curl against a base URL; returns the response body."""
load_config: Callable[[], dict[str, Any]]
"""Return the full parsed ``gerrit_config.json``."""
log_path: Path
"""Shared ``server.log`` path; extensions append here."""
plugin_registry: Any = None
"""Optional :class:`~gerrit_mcp_server.plugin_registry.PluginRegistry`."""
def extension_config(self, name: str) -> dict[str, Any]:
"""Return ``config['extensions'][name]`` (``{}`` if absent).
Extensions should namespace their config under this key so the
core config shape stays stable.
"""
config = self.load_config() or {}
return (config.get("extensions") or {}).get(name) or {}
class ExtensionLoader:
"""Discover, register, and (eventually) tear down extensions.
Holds the :class:`ExtensionContext` so the discovery, registration,
and logging steps share it without passing it around explicitly.
"""
def __init__(self, context: ExtensionContext) -> None:
self._context = context
def load(self) -> list[str]:
"""Discover and register every extension; return the successful names.
Import or ``register()`` failures are logged to the context's
``log_path`` and stderr, then skipped.
"""
raw_modules = (
self._discover_entry_point_modules() + self._discover_env_modules()
)
seen: dict[str, int] = {}
for module_name in raw_modules:
seen[module_name] = seen.get(module_name, 0) + 1
for module_name, count in seen.items():
if count > 1:
self._log(
module_name, f"WARNING: discovered {count} times; loading once"
)
modules = list(dict.fromkeys(raw_modules))
registered: list[str] = []
for module_name in modules:
try:
module = importlib.import_module(module_name)
except Exception as exc:
self._log(module_name, f"ERROR: import failed: {exc}")
continue
register = getattr(module, "register", None)
if not callable(register):
self._log(module_name, "ERROR: missing register(ctx) function")
continue
try:
register(self._context)
except Exception as exc:
self._log(module_name, f"ERROR: register() raised: {exc}")
continue
registered.append(module_name)
self._log(module_name, "registered")
return registered
def unload(self) -> None:
"""Lifecycle hook placeholder for server shutdown.
Extensions that need cleanup should implement an ``unregister(ctx)``
function. This stub establishes the contract; a full implementation
requires tracking which modules were registered (see :meth:`load`).
"""
pass
def _discover_entry_point_modules(self) -> list[str]:
"""Discover entry-point modules, honoring the never-abort-startup contract.
A single malformed entry point in the environment can make
``entry_points()`` raise; log and fall back to no entry-point
modules rather than aborting startup.
"""
try:
return [
entry_point.module
for entry_point in entry_points(group=ENTRY_POINT_GROUP)
]
except Exception as exc:
self._log("<entry-point-discovery>", f"ERROR: discovery failed: {exc}")
return []
def _discover_env_modules(self) -> list[str]:
raw = os.environ.get(ENV_VAR, "").strip()
if not raw:
return []
return [module.strip() for module in raw.split(",") if module.strip()]
def _log(self, module_name: str, message: str) -> None:
line = f"[gerrit-mcp-server] extension {module_name}: {message}\n"
try:
with open(self._context.log_path, "a") as log_file:
log_file.write(line)
except Exception:
pass
print(line.rstrip(), file=sys.stderr)