blob: 9f0880e870b3338fe34540ad8131267056e94e15 [file] [view]
# 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.