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 whoseregister()wires up theget_task_treeandget_actionable_taskstools via@requires_plugin("task"), withTypedDictreturn types and an entry point. Read it alongside this document.
At startup, cli_main builds an ExtensionContext and calls ExtensionLoader(context).load(), which discovers modules from two sources and loads each exactly once:
gerrit_mcp_server.extensions entry-point group.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 fromregister()is logged toserver.log(and stderr) and skipped. One broken extension never aborts server startup.
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.
For local development or modules not packaged as a distribution:
export GERRIT_MCP_EXTENSIONS="my_extension,another.ext.module" uv run gerrit-mcp-server
[!NOTE] If your harness supports skills (tested with Claude Code), the
writing-extensionsskill (.claude/skills/writing-extensions/SKILL.md) walks through authoring a complete extension package step by step — layout, theregister(ctx)contract, structured return types,requires_plugin, thepyproject.tomlentry 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.
ExtensionContextregister(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). |
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) ...
requires_pluginMany 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:
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.RuntimeError is raised.[!NOTE]
requires_pluginraises aRuntimeError(rather than returning a content-block list) on routing failure. This keeps it compatible with tools that declare a structuredTypedDictreturn 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.
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.