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 variableGERRIT_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:

[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:

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:

"""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 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 / methodDescription
mcpThe 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_pathPath to the shared server.log; append diagnostics here.
plugin_registryA 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.

{
  "default_gerrit_base_url": "https://gerrit.example.com/",
  "gerrit_hosts": [ ... ],
  "extensions": {
    "my_extension": {
      "some_option": true
    }
  }
}
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:

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.