| # Extensions |
| |
| The Gerrit MCP server can be extended with additional MCP tools without |
| modifying the core package. An **extension** is a Python module that exposes a |
| top-level `register(ctx)` function; the server discovers it at startup, calls |
| `register(ctx)`, and the tools it registers become visible from the first |
| request. |
| |
| This is the supported way to ship tools that wrap a specific Gerrit plugin (or |
| any other host-specific capability) while keeping the core server generic. |
| |
| > [!NOTE] The bundled `gerrit_mcp_server_task/` package is a complete, shipping |
| > example of everything described here — a sibling workspace package whose |
| > `register()` wires up the `get_task_tree` and `get_actionable_tasks` tools via |
| > `@requires_plugin("task")`, with `TypedDict` return types and an entry point. |
| > Read it alongside this document. |
| |
| ## How extensions are discovered |
| |
| At startup, `cli_main` builds an `ExtensionContext` and calls |
| `ExtensionLoader(context).load()`, which discovers modules from two sources and |
| loads each exactly once: |
| |
| 1. **Entry points** — any installed distribution that advertises the |
| `gerrit_mcp_server.extensions` entry-point group. |
| 2. **Environment variable** — `GERRIT_MCP_EXTENSIONS`, a comma-separated list of |
| importable module names. |
| |
| Both sources are combined and de-duplicated; a module discovered more than once |
| is loaded a single time and a `WARNING` is logged. |
| |
| > [!IMPORTANT] An extension that fails to import, lacks a callable `register`, |
| > or raises from `register()` is logged to `server.log` (and stderr) and |
| > skipped. One broken extension never aborts server startup. |
| |
| ### Registering via an entry point |
| |
| In the extension distribution's `pyproject.toml`: |
| |
| ```toml |
| [project.entry-points."gerrit_mcp_server.extensions"] |
| my_extension = "my_extension" |
| ``` |
| |
| The entry-point *value* is the module that defines `register(ctx)`. The name on |
| the left is cosmetic. |
| |
| ### Registering via the environment variable |
| |
| For local development or modules not packaged as a distribution: |
| |
| ```bash |
| export GERRIT_MCP_EXTENSIONS="my_extension,another.ext.module" |
| uv run gerrit-mcp-server |
| ``` |
| |
| ## Writing an extension |
| |
| > [!NOTE] If your harness supports skills (tested with Claude Code), the |
| > `writing-extensions` skill (`.claude/skills/writing-extensions/SKILL.md`) |
| > walks through authoring a complete extension package step by step — layout, |
| > the `register(ctx)` contract, structured return types, `requires_plugin`, the |
| > `pyproject.toml` entry point, testing, and a pre-ship checklist. This document |
| > is the reference; the skill is the guided walkthrough. |
| |
| A minimal extension defines `register(ctx)` and registers tools on the FastMCP |
| instance exposed by the context: |
| |
| ```python |
| """my_extension.py""" |
| |
| def register(ctx): |
| @ctx.mcp.tool() |
| async def my_tool(change_id: str, gerrit_base_url: str | None = None) -> dict: |
| """Return some detail about a change.""" |
| base_url = ctx.get_base_url(gerrit_base_url) |
| body = await ctx.run_curl([f"{base_url}/changes/{change_id}/detail"], base_url) |
| return {"change_id": change_id, "raw": body} |
| ``` |
| |
| Tools you register follow the same conventions as the core tools — in |
| particular, prefer a typed `TypedDict` return value and raise exceptions on |
| error rather than returning text content blocks. See **Tool Design** in |
| [`AGENTS.md`](../AGENTS.md) for the structured-output contract. |
| |
| ### The `ExtensionContext` |
| |
| `register(ctx)` receives an `ExtensionContext`, the only stable public surface |
| extensions should depend on. Everything else in the `gerrit_mcp_server` package |
| is internal and may change. |
| |
| | Attribute / method | Description | |
| | -------------------------- | ------------------------------------------------------------------------------------------ | |
| | `mcp` | The FastMCP server. Register tools with `@ctx.mcp.tool()`. | |
| | `get_base_url(url=None)` | Resolve a Gerrit base URL — applies defaults/env, normalization, and the `/a` auth prefix. | |
| | `normalize_url(url)` | Normalize a Gerrit URL (internal→external, `/a` prefix when needed). | |
| | `run_curl(args, base_url)` | Run an authenticated `curl` against a base URL and return the response body. Awaitable. | |
| | `load_config()` | Return the full parsed `gerrit_config.json`. | |
| | `extension_config(name)` | Return `config["extensions"][name]` (or `{}` if absent) — your namespaced config block. | |
| | `log_path` | Path to the shared `server.log`; append diagnostics here. | |
| | `plugin_registry` | A `PluginRegistry` for plugin-backed tools (see below). | |
| |
| ### Namespaced configuration |
| |
| Extensions should read their settings from a namespaced block under the |
| top-level `extensions` key in `gerrit_config.json`, accessed via |
| `ctx.extension_config("<name>")`. Namespacing keeps the core config shape stable |
| and avoids collisions between extensions. |
| |
| ```json |
| { |
| "default_gerrit_base_url": "https://gerrit.example.com/", |
| "gerrit_hosts": [ ... ], |
| "extensions": { |
| "my_extension": { |
| "some_option": true |
| } |
| } |
| } |
| ``` |
| |
| ```python |
| def register(ctx): |
| options = ctx.extension_config("my_extension") |
| enabled = options.get("some_option", False) |
| ... |
| ``` |
| |
| ## Plugin-backed tools and `requires_plugin` |
| |
| Many extensions wrap a specific Gerrit plugin's REST endpoints. Because a plugin |
| may be installed on some configured hosts but not others, the framework provides |
| `PluginRegistry` and the `requires_plugin` decorator to route a tool to a host |
| that actually has the plugin. |
| |
| `PluginRegistry` queries `/plugins/?all` lazily, per host, caches the result |
| with a TTL (one hour by default), and filters out disabled plugins. |
| |
| Decorate a tool with `requires_plugin(plugin_name, ctx.plugin_registry)`. The |
| wrapped coroutine must accept a `gerrit_base_url` keyword argument: |
| |
| ```python |
| from gerrit_mcp_server.extensions import requires_plugin |
| |
| |
| def register(ctx): |
| @ctx.mcp.tool() |
| @requires_plugin("task", ctx.plugin_registry) |
| async def get_task_tree(change_id: str, gerrit_base_url: str | None = None) -> dict: |
| """Return the task tree for a change (requires the `task` plugin).""" |
| base_url = ctx.get_base_url(gerrit_base_url) |
| body = await ctx.run_curl([f"{base_url}/a/changes/{change_id}/...", base_url]) |
| return {"change_id": change_id, "raw": body} |
| ``` |
| |
| Routing behavior: |
| |
| - **No `gerrit_base_url` supplied** → the first configured host that has the |
| plugin is used automatically. |
| - **`gerrit_base_url` supplied but the host lacks the plugin** → the cache is |
| refreshed once; if the plugin is still absent, a `RuntimeError` is raised |
| naming the hosts that do have it. |
| - **No configured host has the plugin** → a `RuntimeError` is raised. |
| |
| > [!NOTE] `requires_plugin` raises a `RuntimeError` (rather than returning a |
| > content-block list) on routing failure. This keeps it compatible with tools |
| > that declare a structured `TypedDict` return type: the MCP SDK validates a |
| > tool's return value against its auto-generated output schema, so a |
| > content-block list would fail validation. The raised error propagates to the |
| > caller as a normal tool error. |
| |
| ## Lifecycle |
| |
| `ExtensionLoader.load()` is the registration hook. A complementary |
| `ExtensionLoader.unload()` hook exists as a placeholder for shutdown cleanup; |
| the contract is that an extension needing cleanup defines an `unregister(ctx)` |
| function, but a full implementation is not yet wired up. |