Add code-owners plugin as an extension

Adds a new sibling package `gerrit_mcp_server_code_owners` wrapping the
Gerrit code-owners plugin REST API. `register()` wires up three tools,
all gated via `@requires_plugin("code-owners")`:

- `get_code_owner_status`: per-file ownership status for a change
  (APPROVED/PENDING/INSUFFICIENT_REVIEWERS), with pagination via
  `limit` and `start`.
- `get_code_owners_for_path`: suggested owners for a specific path in a
  change revision, filtered and ranked by the plugin (current reviewers
  ranked higher, change owner and service users excluded).
- `check_code_owner`: checks code ownership for a path in a branch,
  returning ownership kind (global/default/fallback) and optional
  change-level permissions when `change_id` is supplied.

All tools return typed TypedDicts so the MCP SDK auto-generates an
output_schema and emits both structured and unstructured content. Error
paths raise exceptions rather than returning text error payloads.

Includes 23 unit tests and updates docs/available_tools.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Change-Id: Ibf1ec8a491c18a39f20c0f294abc818b9ad055bf
diff --git a/docs/available_tools.md b/docs/available_tools.md
index dc42429..53fcdd2 100644
--- a/docs/available_tools.md
+++ b/docs/available_tools.md
@@ -81,3 +81,26 @@
 - **get_dependents**: Returns changes that explicitly declare `Depends-on:` on
   the given change, via the `independson:<change_id>` query operator registered
   by the plugin.
+
+### Code-owners plugin (`gerrit_mcp_server_code_owners`)
+
+Requires the
+[`code-owners`](https://gerrit.googlesource.com/plugins/code-owners/) plugin on
+the target Gerrit host. All tools auto-route to a configured host that has the
+plugin installed.
+
+- **get_code_owner_status**: Returns the per-file code owner approval status for
+  a change. Each file entry carries a `status` of `APPROVED`, `PENDING`, or
+  `INSUFFICIENT_REVIEWERS` for both its old and new paths (relevant for
+  renames). Supports `limit` and `start` for pagination when a change touches
+  many files.
+- **get_code_owners_for_path**: Returns the suggested code owners for a specific
+  path in a change revision. The plugin filters out the change owner and service
+  users, and ranks current reviewers higher. Pass `revision_id` to target a
+  specific patch set (defaults to `current`) and `limit` to cap the result
+  count.
+- **check_code_owner**: Checks whether a specific user (by email) is a code
+  owner for a path in a branch. Returns detailed ownership information including
+  whether the user is a global, default, or fallback code owner, and optionally
+  their change-level permissions (read ref, see change, approve) when
+  `change_id` is supplied.
diff --git a/gerrit_mcp_server_code_owners/__init__.py b/gerrit_mcp_server_code_owners/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gerrit_mcp_server_code_owners/__init__.py
diff --git a/gerrit_mcp_server_code_owners/extension.py b/gerrit_mcp_server_code_owners/extension.py
new file mode 100644
index 0000000..2b9c9fc
--- /dev/null
+++ b/gerrit_mcp_server_code_owners/extension.py
@@ -0,0 +1,259 @@
+"""gerrit-mcp-server extension for the Gerrit code-owners plugin.
+
+Exposes three MCP tools:
+  - get_code_owner_status: per-file code owner approval status for a change
+  - get_code_owners_for_path: code owners for a specific path in a change revision
+  - check_code_owner: check whether a user is a code owner for a path in a branch
+
+All tools require the 'code-owners' Gerrit plugin via @requires_plugin.
+"""
+
+import json
+from typing import Any, Dict, List, Optional, TypedDict
+from urllib.parse import quote
+
+from gerrit_mcp_server.extensions import ExtensionContext, requires_plugin
+
+
+class _AccountInfo(TypedDict):
+    account_id: int
+    name: Optional[str]
+    email: Optional[str]
+    username: Optional[str]
+
+
+class _PathCodeOwnerStatusInfo(TypedDict):
+    path: str
+    status: str
+    reasons: Optional[List[str]]
+
+
+class _FileCodeOwnerStatusInfo(TypedDict):
+    change_type: Optional[str]
+    old_path_status: Optional[_PathCodeOwnerStatusInfo]
+    new_path_status: Optional[_PathCodeOwnerStatusInfo]
+
+
+class _CodeOwnerStatusResult(TypedDict):
+    change_id: str
+    patch_set_number: int
+    file_code_owner_statuses: List[_FileCodeOwnerStatusInfo]
+    more: Optional[bool]
+    accounts: Optional[Dict[str, _AccountInfo]]
+
+
+class _CodeOwnerInfo(TypedDict):
+    account: _AccountInfo
+    scorings: Optional[Dict[str, int]]
+
+
+class _CodeOwnersForPathResult(TypedDict):
+    change_id: str
+    revision_id: str
+    path: str
+    code_owners: List[_CodeOwnerInfo]
+    owned_by_all_users: Optional[bool]
+
+
+class _CheckCodeOwnerResult(TypedDict):
+    project: str
+    branch: str
+    path: str
+    email: str
+    is_code_owner: bool
+    is_resolvable: bool
+    can_read_ref: Optional[bool]
+    can_see_change: Optional[bool]
+    can_approve_change: Optional[bool]
+    is_fallback_code_owner: Optional[bool]
+    is_default_code_owner: Optional[bool]
+    is_global_code_owner: Optional[bool]
+    is_owned_by_all_users: Optional[bool]
+    annotation: Optional[List[str]]
+
+
+def _parse_path_status(
+    raw: Optional[Dict[str, Any]],
+) -> Optional[_PathCodeOwnerStatusInfo]:
+    if raw is None:
+        return None
+    return {
+        "path": raw.get("path", ""),
+        "status": raw.get("status", ""),
+        "reasons": raw.get("reasons") or None,
+    }
+
+
+def register(ctx: ExtensionContext) -> None:
+    """Register code-owners MCP tools."""
+
+    @ctx.mcp.tool()
+    @requires_plugin("code-owners", ctx.plugin_registry)
+    async def get_code_owner_status(
+        change_id: str,
+        limit: Optional[int] = None,
+        start: Optional[int] = None,
+        gerrit_base_url: Optional[str] = None,
+    ) -> _CodeOwnerStatusResult:
+        """Return code owner approval status for each file in a change.
+
+        The status field for each path is one of:
+          APPROVED               - a code owner approved, or an override is present
+          PENDING                - a code owner is a reviewer but has not approved yet
+          INSUFFICIENT_REVIEWERS - no code owner has been added as a reviewer
+
+        Pass limit and start for pagination when a change touches many files.
+        """
+        base_url = ctx.get_base_url(gerrit_base_url)
+        url = f"{base_url}/changes/{quote(str(change_id))}/code_owners.status"
+        params = []
+        if limit is not None:
+            params.append(f"n={limit}")
+        if start is not None:
+            params.append(f"S={start}")
+        if params:
+            url += "?" + "&".join(params)
+
+        result_str = await ctx.run_curl([url], base_url)
+        try:
+            data = json.loads(result_str)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
+
+        file_statuses: List[_FileCodeOwnerStatusInfo] = []
+        for raw_file in data.get("file_code_owner_statuses", []):
+            entry: _FileCodeOwnerStatusInfo = {
+                "change_type": raw_file.get("change_type") or None,
+                "old_path_status": _parse_path_status(raw_file.get("old_path_status")),
+                "new_path_status": _parse_path_status(raw_file.get("new_path_status")),
+            }
+            file_statuses.append(entry)
+
+        accounts: Optional[Dict[str, _AccountInfo]] = None
+        raw_accounts = data.get("accounts")
+        if raw_accounts:
+            accounts = {}
+            for account_id, raw_account in raw_accounts.items():
+                accounts[account_id] = {
+                    "account_id": raw_account.get("_account_id", 0),
+                    "name": raw_account.get("name") or None,
+                    "email": raw_account.get("email") or None,
+                    "username": raw_account.get("username") or None,
+                }
+
+        return {
+            "change_id": change_id,
+            "patch_set_number": data.get("patch_set_number", 0),
+            "file_code_owner_statuses": file_statuses,
+            "more": data.get("more") or None,
+            "accounts": accounts,
+        }
+
+    @ctx.mcp.tool()
+    @requires_plugin("code-owners", ctx.plugin_registry)
+    async def get_code_owners_for_path(
+        change_id: str,
+        path: str,
+        revision_id: str = "current",
+        limit: Optional[int] = None,
+        gerrit_base_url: Optional[str] = None,
+    ) -> _CodeOwnersForPathResult:
+        """Return suggested code owners for a specific path in a change revision.
+
+        The plugin filters out the change owner and service users, and ranks
+        reviewers higher. Use this to find who should review a particular file.
+
+        Pass revision_id to target a specific patch set (defaults to 'current').
+        Pass limit to cap how many owners are returned.
+        """
+        base_url = ctx.get_base_url(gerrit_base_url)
+        encoded_path = quote(path.lstrip("/"), safe="/")
+        url = (
+            f"{base_url}/changes/{quote(str(change_id))}"
+            f"/revisions/{quote(str(revision_id))}"
+            f"/code_owners/{encoded_path}"
+        )
+        if limit is not None:
+            url += f"?n={limit}"
+
+        result_str = await ctx.run_curl([url], base_url)
+        try:
+            data = json.loads(result_str)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
+
+        owners: List[_CodeOwnerInfo] = []
+        for raw_owner in data.get("code_owners", []):
+            raw_account = raw_owner.get("account", {})
+            account: _AccountInfo = {
+                "account_id": raw_account.get("_account_id", 0),
+                "name": raw_account.get("name") or None,
+                "email": raw_account.get("email") or None,
+                "username": raw_account.get("username") or None,
+            }
+            owner: _CodeOwnerInfo = {
+                "account": account,
+                "scorings": raw_owner.get("scorings") or None,
+            }
+            owners.append(owner)
+
+        return {
+            "change_id": change_id,
+            "revision_id": revision_id,
+            "path": path,
+            "code_owners": owners,
+            "owned_by_all_users": data.get("owned_by_all_users") or None,
+        }
+
+    @ctx.mcp.tool()
+    @requires_plugin("code-owners", ctx.plugin_registry)
+    async def check_code_owner(
+        project: str,
+        branch: str,
+        path: str,
+        email: str,
+        change_id: Optional[str] = None,
+        gerrit_base_url: Optional[str] = None,
+    ) -> _CheckCodeOwnerResult:
+        """Check whether a user (by email) is a code owner for a path in a branch.
+
+        Returns detailed ownership information including whether the user can
+        read the ref, see and approve the change (if change_id is provided),
+        and which kind of code owner they are (global, default, fallback).
+
+        Pass change_id to also check change-level permissions.
+        """
+        base_url = ctx.get_base_url(gerrit_base_url)
+        encoded_project = quote(project, safe="")
+        encoded_branch = quote(branch, safe="")
+        url = (
+            f"{base_url}/projects/{encoded_project}"
+            f"/branches/{encoded_branch}"
+            f"/code_owners.check"
+            f"?email={quote(email)}&path={quote(path)}"
+        )
+        if change_id is not None:
+            url += f"&change={quote(str(change_id))}"
+
+        result_str = await ctx.run_curl([url], base_url)
+        try:
+            data = json.loads(result_str)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
+
+        return {
+            "project": project,
+            "branch": branch,
+            "path": path,
+            "email": email,
+            "is_code_owner": data.get("is_code_owner", False),
+            "is_resolvable": data.get("is_resolvable", False),
+            "can_read_ref": data.get("can_read_ref"),
+            "can_see_change": data.get("can_see_change"),
+            "can_approve_change": data.get("can_approve_change"),
+            "is_fallback_code_owner": data.get("is_fallback_code_owner"),
+            "is_default_code_owner": data.get("is_default_code_owner"),
+            "is_global_code_owner": data.get("is_global_code_owner"),
+            "is_owned_by_all_users": data.get("is_owned_by_all_users"),
+            "annotation": data.get("annotation") or None,
+        }
diff --git a/gerrit_mcp_server_code_owners/pyproject.toml b/gerrit_mcp_server_code_owners/pyproject.toml
new file mode 100644
index 0000000..eaf5562
--- /dev/null
+++ b/gerrit_mcp_server_code_owners/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "gerrit-mcp-server-code-owners"
+version = "0.1.0"
+description = "gerrit-mcp-server extension for the Gerrit code-owners plugin"
+requires-python = ">=3.12"
+dependencies = ["gerrit-mcp-server"]
+
+[project.entry-points."gerrit_mcp_server.extensions"]
+gerrit_mcp_server_code_owners = "gerrit_mcp_server_code_owners.extension:register"
+
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+packages = ["gerrit_mcp_server_code_owners"]
+package-dir = {"gerrit_mcp_server_code_owners" = "."}
diff --git a/pyproject.toml b/pyproject.toml
index c22546f..a6fabac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,8 @@
     "uvicorn",
     "websockets",
     "gerrit-mcp-server-task",
-    "gerrit-mcp-server-depends-on"
+    "gerrit-mcp-server-depends-on",
+    "gerrit-mcp-server-code-owners"
 ]
 
 [project.optional-dependencies]
@@ -41,12 +42,13 @@
 build-backend = "setuptools.build_meta"
 
 [tool.uv.workspace]
-members = ["gerrit_mcp_server_task", "gerrit_mcp_server_depends_on"]
+members = ["gerrit_mcp_server_task", "gerrit_mcp_server_depends_on", "gerrit_mcp_server_code_owners"]
 
 [tool.uv.sources]
 gerrit-mcp-server = { workspace = true }
 gerrit-mcp-server-task = { workspace = true }
 gerrit-mcp-server-depends-on = { workspace = true }
+gerrit-mcp-server-code-owners = { workspace = true }
 
 [tool.setuptools]
 packages = ["gerrit_mcp_server"]
diff --git a/tests/integration/test_build_and_run.py b/tests/integration/test_build_and_run.py
index e36f558..cad340a 100644
--- a/tests/integration/test_build_and_run.py
+++ b/tests/integration/test_build_and_run.py
@@ -40,6 +40,7 @@
             "gerrit_mcp_server",
             "gerrit_mcp_server_task",
             "gerrit_mcp_server_depends_on",
+            "gerrit_mcp_server_code_owners",
         ]
 
         for file_name in self.files_to_copy:
diff --git a/tests/unit/test_code_owners_extension.py b/tests/unit/test_code_owners_extension.py
new file mode 100644
index 0000000..0f5d941
--- /dev/null
+++ b/tests/unit/test_code_owners_extension.py
@@ -0,0 +1,449 @@
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+from urllib.parse import quote
+
+import pytest
+
+from gerrit_mcp_server_code_owners.extension import register
+
+# ---------------------------------------------------------------------------
+# Test fixtures and helpers
+# ---------------------------------------------------------------------------
+
+BASE_URL = "https://gerrit.example.com/a"
+
+
+@pytest.fixture
+def ctx(tmp_path: Path):
+    mock_ctx = MagicMock()
+    mock_ctx.get_base_url = MagicMock(return_value=BASE_URL)
+    mock_ctx.normalize_url = lambda u: u
+    mock_ctx.run_curl = AsyncMock()
+    mock_ctx.load_config = MagicMock(return_value={})
+    mock_ctx.log_path = tmp_path / "server.log"
+    mock_ctx.plugin_registry = MagicMock()
+    mock_ctx.plugin_registry.host_has_plugin = AsyncMock(return_value=True)
+    mock_ctx.extension_config = MagicMock(return_value={})
+    return mock_ctx
+
+
+def _capture_tools(ctx) -> dict:
+    """Replace ctx.mcp.tool with a capturing decorator; return registered fns."""
+    registered = {}
+
+    def capture_decorator():
+        def decorator(fn):
+            registered[fn.__name__] = fn
+            return fn
+
+        return decorator
+
+    ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
+    register(ctx)
+    return registered
+
+
+# ---------------------------------------------------------------------------
+# register()
+# ---------------------------------------------------------------------------
+
+
+class TestRegister:
+    def test_registers_three_tools(self, ctx):
+        register(ctx)
+        assert ctx.mcp.tool.call_count == 3
+
+
+# ---------------------------------------------------------------------------
+# get_code_owner_status
+# ---------------------------------------------------------------------------
+
+
+class TestGetCodeOwnerStatus:
+    def _make_status_response(self, patch_set=1, files=None, more=False):
+        data = {
+            "patch_set_number": patch_set,
+            "file_code_owner_statuses": files or [],
+        }
+        if more:
+            data["more"] = True
+        return json.dumps(data)
+
+    @pytest.mark.asyncio
+    async def test_approved_file(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_status_response(
+                patch_set=2,
+                files=[
+                    {
+                        "change_type": "MODIFIED",
+                        "new_path_status": {
+                            "path": "src/foo.py",
+                            "status": "APPROVED",
+                        },
+                    }
+                ],
+            )
+        )
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        data = await fn(change_id="100", gerrit_base_url=BASE_URL)
+        assert data["change_id"] == "100"
+        assert data["patch_set_number"] == 2
+        assert len(data["file_code_owner_statuses"]) == 1
+        status = data["file_code_owner_statuses"][0]
+        assert status["change_type"] == "MODIFIED"
+        assert status["new_path_status"]["path"] == "src/foo.py"
+        assert status["new_path_status"]["status"] == "APPROVED"
+
+    @pytest.mark.asyncio
+    async def test_pending_file_with_reasons(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_status_response(
+                files=[
+                    {
+                        "new_path_status": {
+                            "path": "src/bar.py",
+                            "status": "PENDING",
+                            "reasons": ["<GERRIT_ACCOUNT_1> is a reviewer"],
+                        },
+                    }
+                ],
+            )
+        )
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        data = await fn(change_id="200", gerrit_base_url=BASE_URL)
+        path_status = data["file_code_owner_statuses"][0]["new_path_status"]
+        assert path_status["status"] == "PENDING"
+        assert len(path_status["reasons"]) == 1
+
+    @pytest.mark.asyncio
+    async def test_renamed_file_has_old_and_new_path(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_status_response(
+                files=[
+                    {
+                        "change_type": "RENAMED",
+                        "old_path_status": {
+                            "path": "old/path.py",
+                            "status": "APPROVED",
+                        },
+                        "new_path_status": {
+                            "path": "new/path.py",
+                            "status": "APPROVED",
+                        },
+                    }
+                ],
+            )
+        )
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        data = await fn(change_id="300", gerrit_base_url=BASE_URL)
+        entry = data["file_code_owner_statuses"][0]
+        assert entry["old_path_status"]["path"] == "old/path.py"
+        assert entry["new_path_status"]["path"] == "new/path.py"
+
+    @pytest.mark.asyncio
+    async def test_more_flag_included_when_true(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_status_response(more=True))
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        data = await fn(change_id="400", gerrit_base_url=BASE_URL)
+        assert data.get("more") is True
+
+    @pytest.mark.asyncio
+    async def test_more_flag_absent_when_false(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_status_response(more=False))
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        data = await fn(change_id="400", gerrit_base_url=BASE_URL)
+        assert data.get("more") is None
+
+    @pytest.mark.asyncio
+    async def test_limit_and_start_in_url(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_status_response())
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        await fn(change_id="500", limit=10, start=5, gerrit_base_url=BASE_URL)
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert "n=10" in called_url
+        assert "S=5" in called_url
+
+    @pytest.mark.asyncio
+    async def test_invalid_json_raises(self, ctx):
+        ctx.run_curl = AsyncMock(return_value="not json")
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        with pytest.raises(ValueError, match="Could not parse"):
+            await fn(change_id="999", gerrit_base_url=BASE_URL)
+
+    @pytest.mark.asyncio
+    async def test_accounts_populated_when_present(self, ctx):
+        data = {
+            "patch_set_number": 1,
+            "file_code_owner_statuses": [
+                {
+                    "new_path_status": {
+                        "path": "src/foo.py",
+                        "status": "PENDING",
+                        "reasons": ["<GERRIT_ACCOUNT_42> is a reviewer"],
+                    }
+                }
+            ],
+            "accounts": {
+                "42": {
+                    "_account_id": 42,
+                    "name": "Alice",
+                    "email": "alice@example.com",
+                    "username": "alice",
+                }
+            },
+        }
+        ctx.run_curl = AsyncMock(return_value=json.dumps(data))
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        result = await fn(change_id="600", gerrit_base_url=BASE_URL)
+        assert result["accounts"] is not None
+        assert "42" in result["accounts"]
+        assert result["accounts"]["42"]["account_id"] == 42
+        assert result["accounts"]["42"]["name"] == "Alice"
+
+    @pytest.mark.asyncio
+    async def test_accounts_absent_when_not_in_response(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_status_response())
+        fn = _capture_tools(ctx)["get_code_owner_status"]
+        result = await fn(change_id="700", gerrit_base_url=BASE_URL)
+        assert result["accounts"] is None
+
+
+# ---------------------------------------------------------------------------
+# get_code_owners_for_path
+# ---------------------------------------------------------------------------
+
+
+class TestGetCodeOwnersForPath:
+    def _make_owners_response(self, owners=None, owned_by_all=False):
+        data = {"code_owners": owners or []}
+        if owned_by_all:
+            data["owned_by_all_users"] = True
+        return json.dumps(data)
+
+    @pytest.mark.asyncio
+    async def test_returns_owners(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_owners_response(
+                owners=[
+                    {
+                        "account": {
+                            "_account_id": 42,
+                            "name": "Alice",
+                            "email": "alice@example.com",
+                        },
+                        "scorings": {"IS_REVIEWER": 1},
+                    }
+                ]
+            )
+        )
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        data = await fn(
+            change_id="100",
+            path="src/foo.py",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data["change_id"] == "100"
+        assert data["path"] == "src/foo.py"
+        assert data["revision_id"] == "current"
+        assert len(data["code_owners"]) == 1
+        owner = data["code_owners"][0]
+        assert owner["account"]["account_id"] == 42
+        assert owner["account"]["name"] == "Alice"
+        assert owner["scorings"] == {"IS_REVIEWER": 1}
+
+    @pytest.mark.asyncio
+    async def test_owned_by_all_users(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_owners_response(owned_by_all=True)
+        )
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        data = await fn(
+            change_id="100",
+            path="OWNERS",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data.get("owned_by_all_users") is True
+
+    @pytest.mark.asyncio
+    async def test_owned_by_all_users_absent_when_false(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        data = await fn(
+            change_id="100",
+            path="src/foo.py",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data.get("owned_by_all_users") is None
+
+    @pytest.mark.asyncio
+    async def test_url_contains_path(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        await fn(
+            change_id="100",
+            path="src/my file.py",
+            revision_id="abc123",
+            gerrit_base_url=BASE_URL,
+        )
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert "abc123" in called_url
+        assert "src/my%20file.py" in called_url
+
+    @pytest.mark.asyncio
+    async def test_leading_slash_stripped_from_path(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        await fn(
+            change_id="100",
+            path="/src/foo.py",
+            gerrit_base_url=BASE_URL,
+        )
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert "//src" not in called_url
+
+    @pytest.mark.asyncio
+    async def test_limit_in_url(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_owners_response())
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        await fn(
+            change_id="100",
+            path="src/foo.py",
+            limit=5,
+            gerrit_base_url=BASE_URL,
+        )
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert "n=5" in called_url
+
+    @pytest.mark.asyncio
+    async def test_invalid_json_raises(self, ctx):
+        ctx.run_curl = AsyncMock(return_value="not json")
+        fn = _capture_tools(ctx)["get_code_owners_for_path"]
+        with pytest.raises(ValueError, match="Could not parse"):
+            await fn(change_id="100", path="src/foo.py", gerrit_base_url=BASE_URL)
+
+
+# ---------------------------------------------------------------------------
+# check_code_owner
+# ---------------------------------------------------------------------------
+
+
+class TestCheckCodeOwner:
+    def _make_check_response(self, is_code_owner=True, is_resolvable=True, **kwargs):
+        data = {
+            "is_code_owner": is_code_owner,
+            "is_resolvable": is_resolvable,
+        }
+        data.update(kwargs)
+        return json.dumps(data)
+
+    @pytest.mark.asyncio
+    async def test_is_code_owner(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_check_response(
+                is_code_owner=True,
+                is_resolvable=True,
+                can_read_ref=True,
+                can_approve_change=True,
+            )
+        )
+        fn = _capture_tools(ctx)["check_code_owner"]
+        data = await fn(
+            project="my/project",
+            branch="main",
+            path="src/foo.py",
+            email="alice@example.com",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data["project"] == "my/project"
+        assert data["branch"] == "main"
+        assert data["path"] == "src/foo.py"
+        assert data["email"] == "alice@example.com"
+        assert data["is_code_owner"] is True
+        assert data["is_resolvable"] is True
+        assert data["can_read_ref"] is True
+        assert data["can_approve_change"] is True
+
+    @pytest.mark.asyncio
+    async def test_not_code_owner(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_check_response(
+                is_code_owner=False,
+                is_resolvable=True,
+            )
+        )
+        fn = _capture_tools(ctx)["check_code_owner"]
+        data = await fn(
+            project="my/project",
+            branch="main",
+            path="src/bar.py",
+            email="bob@example.com",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data["is_code_owner"] is False
+
+    @pytest.mark.asyncio
+    async def test_fallback_owner_fields(self, ctx):
+        ctx.run_curl = AsyncMock(
+            return_value=self._make_check_response(
+                is_fallback_code_owner=True,
+                is_global_code_owner=False,
+                is_default_code_owner=False,
+                annotation=["FALLBACK_CODE_OWNER"],
+            )
+        )
+        fn = _capture_tools(ctx)["check_code_owner"]
+        data = await fn(
+            project="my/project",
+            branch="main",
+            path="src/baz.py",
+            email="charlie@example.com",
+            gerrit_base_url=BASE_URL,
+        )
+        assert data["is_fallback_code_owner"] is True
+        assert data["annotation"] == ["FALLBACK_CODE_OWNER"]
+
+    @pytest.mark.asyncio
+    async def test_url_contains_email_and_path(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_check_response())
+        fn = _capture_tools(ctx)["check_code_owner"]
+        await fn(
+            project="my/project",
+            branch="main",
+            path="src/foo.py",
+            email="alice@example.com",
+            gerrit_base_url=BASE_URL,
+        )
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert quote("alice@example.com") in called_url
+        assert quote("src/foo.py") in called_url
+        assert "code_owners.check" in called_url
+
+    @pytest.mark.asyncio
+    async def test_change_id_appended_to_url(self, ctx):
+        ctx.run_curl = AsyncMock(return_value=self._make_check_response())
+        fn = _capture_tools(ctx)["check_code_owner"]
+        await fn(
+            project="my/project",
+            branch="main",
+            path="src/foo.py",
+            email="alice@example.com",
+            change_id="123",
+            gerrit_base_url=BASE_URL,
+        )
+        called_url = ctx.run_curl.call_args[0][0][0]
+        assert "change=123" in called_url
+
+    @pytest.mark.asyncio
+    async def test_invalid_json_raises(self, ctx):
+        ctx.run_curl = AsyncMock(return_value="not json")
+        fn = _capture_tools(ctx)["check_code_owner"]
+        with pytest.raises(ValueError, match="Could not parse"):
+            await fn(
+                project="p",
+                branch="b",
+                path="f",
+                email="e@x.com",
+                gerrit_base_url=BASE_URL,
+            )
diff --git a/uv.lock b/uv.lock
index d6ae7b6..e6efd0c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -11,6 +11,7 @@
 [manifest]
 members = [
     "gerrit-mcp-server",
+    "gerrit-mcp-server-code-owners",
     "gerrit-mcp-server-depends-on",
     "gerrit-mcp-server-task",
 ]
@@ -191,6 +192,7 @@
 version = "1.0.0"
 source = { editable = "." }
 dependencies = [
+    { name = "gerrit-mcp-server-code-owners" },
     { name = "gerrit-mcp-server-depends-on" },
     { name = "gerrit-mcp-server-task" },
     { name = "mcp" },
@@ -212,6 +214,7 @@
 
 [package.metadata]
 requires-dist = [
+    { name = "gerrit-mcp-server-code-owners", editable = "gerrit_mcp_server_code_owners" },
     { name = "gerrit-mcp-server-depends-on", editable = "gerrit_mcp_server_depends_on" },
     { name = "gerrit-mcp-server-task", editable = "gerrit_mcp_server_task" },
     { name = "mcp", specifier = "<2" },
@@ -229,6 +232,17 @@
 provides-extras = ["dev"]
 
 [[package]]
+name = "gerrit-mcp-server-code-owners"
+version = "0.1.0"
+source = { editable = "gerrit_mcp_server_code_owners" }
+dependencies = [
+    { name = "gerrit-mcp-server" },
+]
+
+[package.metadata]
+requires-dist = [{ name = "gerrit-mcp-server", editable = "." }]
+
+[[package]]
 name = "gerrit-mcp-server-depends-on"
 version = "0.1.0"
 source = { editable = "gerrit_mcp_server_depends_on" }