Add get_git_parent_changes tool with structured output

Adds a new MCP tool that queries immediate git-parent changes of a given
change using Gerrit's `parentof:` query operator. Useful when navigating
a stack: given a change ID, it surfaces which open Gerrit changes have a
commit that is a direct parent of the queried change. Only immediate
parents are returned; grandparents and higher ancestors are excluded by
design.

Returns a typed _GitParentChangesResult so the MCP SDK auto-generates an
output_schema and produces both structured and unstructured text
content. Error paths raise exceptions rather than returning text error
content blocks. This follows the structured-output pattern documented
in AGENTS.md and the writing-extensions skill.

Includes tests/unit/test_get_git_parent_changes.py covering success, WIP
flag, multiple parents, empty result, JSON decode error, and curl
exception cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Change-Id: I61e8703d6dfb2f5aee2135b3a2d6c0a3bea7bf19
diff --git a/docs/available_tools.md b/docs/available_tools.md
index 5d10e6a..a92c8ed 100644
--- a/docs/available_tools.md
+++ b/docs/available_tools.md
@@ -37,6 +37,10 @@
   revision — commit SHA, parent SHA(s), author, committer, subject, and the
   verbatim message — so an agent can retrieve parent SHAs (e.g. to check whether
   each parent is merged via `query_changes("commit:<sha>")`).
+- **get_git_parent_changes**: Returns the immediate git-parent changes of a CL,
+  using the `parentof:` query operator to find changes whose commit is a direct
+  parent of the given change's commit. Only immediate parents are returned, not
+  grandparents or higher ancestors.
 - **suggest_reviewers**: Suggests reviewers for a change based on a query.
 - **abandon_change**: Abandons a change.
 - **get_most_recent_cl**: Gets the most recent CL for a user.
diff --git a/docs/use_cases.md b/docs/use_cases.md
index b8d6ff1..bbfeb18 100644
--- a/docs/use_cases.md
+++ b/docs/use_cases.md
@@ -25,6 +25,7 @@
 |                      | "What other changes would be submitted with CL 67890?"                                          | `changes_submitted_together`        |
 |                      | "Show me the relation chain for CL 67890 — are its ancestors merged?"                           | `get_related_changes`               |
 |                      | "Get the parent commit SHAs for CL 67890."                                                      | `get_revision_commit`               |
+|                      | "Which open changes are immediate git-parents of CL 67890?"                                     | `get_git_parent_changes`            |
 |                      | "Create a new change in project 'test-project', branch 'dev', with subject 'Test new feature'." | `create_change`                     |
 
 ## Data Analysis Use Cases
diff --git a/gerrit_mcp_server/main.py b/gerrit_mcp_server/main.py
index aba1c40..2673f04 100644
--- a/gerrit_mcp_server/main.py
+++ b/gerrit_mcp_server/main.py
@@ -1086,6 +1086,76 @@
         ]
 
 
+class _ParentChange(TypedDict):
+    change_number: int
+    subject: str
+    work_in_progress: bool
+
+
+class _GitParentChangesResult(TypedDict):
+    change_id: str
+    parent_changes: List[_ParentChange]
+    note: Optional[str]
+
+
+@mcp.tool()
+async def get_git_parent_changes(
+    change_id: str,
+    gerrit_base_url: Optional[str] = None,
+) -> _GitParentChangesResult:
+    """
+    Returns the immediate git-parent changes of a given CL.
+
+    Uses the parentof: query operator to find changes whose commit is a direct
+    parent of the given change's commit. Returns only immediate parents, not
+    grandparents or higher ancestors.
+
+    change_id can be a numeric change number or a Change-Id (I... hash from the
+    commit footer).
+    """
+    config = load_gerrit_config()
+    gerrit_hosts = config.get("gerrit_hosts", [])
+    base_url = _normalize_gerrit_url(
+        _get_gerrit_base_url(gerrit_base_url), gerrit_hosts
+    )
+    url = f"{base_url}/changes/?q={quote(f'parentof:{change_id}')}"
+
+    try:
+        result_json_str = await run_curl([url], base_url)
+        changes = json.loads(result_json_str)
+    except json.JSONDecodeError as e:
+        raise ValueError(
+            "Failed to parse JSON response from Gerrit."
+            f" Raw response: '{result_json_str}'"
+        ) from e
+    except Exception as e:
+        raise RuntimeError(
+            f"Error fetching git-parent changes for {change_id}: {e}"
+        ) from e
+
+    parent_changes: List[_ParentChange] = [
+        {
+            "change_number": change["_number"],
+            "subject": change["subject"],
+            "work_in_progress": change.get("work_in_progress", False),
+        }
+        for change in changes
+    ]
+
+    result: _GitParentChangesResult = {
+        "change_id": change_id,
+        "parent_changes": parent_changes,
+        "note": None,
+    }
+    if not parent_changes:
+        result["note"] = (
+            "No git-parent changes found."
+            " The parent commit is not an open Gerrit change."
+        )
+
+    return result
+
+
 @mcp.tool()
 async def changes_submitted_together(
     change_id: str,
diff --git a/tests/unit/test_get_git_parent_changes.py b/tests/unit/test_get_git_parent_changes.py
new file mode 100644
index 0000000..b52ab49
--- /dev/null
+++ b/tests/unit/test_get_git_parent_changes.py
@@ -0,0 +1,114 @@
+import asyncio
+import json
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from gerrit_mcp_server import main
+
+GERRIT_BASE_URL = "https://gerrit.example.com"
+
+CHANGE_PARENT = {
+    "_number": 1001,
+    "subject": "parent: some work",
+    "work_in_progress": False,
+}
+
+CHANGE_PARENT_WIP = {
+    "_number": 1002,
+    "subject": "parent: draft work",
+    "work_in_progress": True,
+}
+
+
+class TestGetGitParentChanges(unittest.TestCase):
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_returns_structured_result(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.return_value = json.dumps([CHANGE_PARENT])
+
+            result = await main.get_git_parent_changes(
+                "2000", gerrit_base_url=GERRIT_BASE_URL
+            )
+
+            self.assertEqual(result["change_id"], "2000")
+            self.assertEqual(len(result["parent_changes"]), 1)
+            self.assertEqual(result["parent_changes"][0]["change_number"], 1001)
+            self.assertEqual(
+                result["parent_changes"][0]["subject"], "parent: some work"
+            )
+            self.assertFalse(result["parent_changes"][0]["work_in_progress"])
+
+        asyncio.run(run_test())
+
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_wip_flag_preserved(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.return_value = json.dumps([CHANGE_PARENT_WIP])
+
+            result = await main.get_git_parent_changes(
+                "2000", gerrit_base_url=GERRIT_BASE_URL
+            )
+
+            self.assertTrue(result["parent_changes"][0]["work_in_progress"])
+
+        asyncio.run(run_test())
+
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_multiple_parents(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.return_value = json.dumps([CHANGE_PARENT, CHANGE_PARENT_WIP])
+
+            result = await main.get_git_parent_changes(
+                "2000", gerrit_base_url=GERRIT_BASE_URL
+            )
+
+            self.assertEqual(len(result["parent_changes"]), 2)
+
+        asyncio.run(run_test())
+
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_empty_returns_structured_response(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.return_value = json.dumps([])
+
+            result = await main.get_git_parent_changes(
+                "2000", gerrit_base_url=GERRIT_BASE_URL
+            )
+
+            self.assertEqual(result["change_id"], "2000")
+            self.assertEqual(result["parent_changes"], [])
+            self.assertIn("note", result)
+
+        asyncio.run(run_test())
+
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_json_decode_error_raises(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.return_value = "not valid json"
+
+            with self.assertRaises(Exception) as ctx:
+                await main.get_git_parent_changes(
+                    "2000", gerrit_base_url=GERRIT_BASE_URL
+                )
+
+            self.assertIn("Failed to parse", str(ctx.exception))
+
+        asyncio.run(run_test())
+
+    @patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock)
+    def test_curl_exception_raises(self, mock_run_curl):
+        async def run_test():
+            mock_run_curl.side_effect = Exception("connection refused")
+
+            with self.assertRaises(Exception) as ctx:
+                await main.get_git_parent_changes(
+                    "2000", gerrit_base_url=GERRIT_BASE_URL
+                )
+
+            self.assertIn("connection refused", str(ctx.exception))
+
+        asyncio.run(run_test())
+
+
+if __name__ == "__main__":
+    unittest.main()