Repository: sooperset/mcp-atlassian

Issue:
[Feature]: Update parentId of the page
### Is your feature request related to a problem? Please describe.

Hi team, 
Could you please add functionality to allow updating the parentId of a page?

### Describe the solution you'd like

The confluence_update_page function can be extended to support updating the parentId of a page by using Atlassian's PUT /pages/{id} endpoint. 



### Describe alternatives you've considered

_No response_

### Use Case

_No response_

### Additional Context

_No response_

Hints from issue comments:
@sooperset: Thanks for the suggestion, @sedabyndr! Adding the ability to update a page's parent ID is a useful feature for managing Confluence content structure. This looks like a feasible enhancement, and we'd definitely welcome a Pull Request for it.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5aeda5..d8e4573 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 # Changelog
 
 ## [Unreleased]
+- Added `jira_update_sprint`
 
 ## [0.7.1] - 2025-04-18
 
diff --git a/README.md b/README.md
index 3de332e..92f23d7 100644
--- a/README.md
+++ b/README.md
@@ -389,6 +389,7 @@ uvx mcp-atlassian --transport sse --port 9000 \
 ||`jira_get_board_issues`|
 ||`jira_get_sprints_from_board`|
 ||`jira_get_sprint_issues`|
+||`jira_update_sprint`|
 ||`jira_create_issue_link`|
 ||`jira_remove_issue_link`|
 
diff --git a/src/mcp_atlassian/confluence/pages.py b/src/mcp_atlassian/confluence/pages.py
index fccddac..d1697e3 100644
--- a/src/mcp_atlassian/confluence/pages.py
+++ b/src/mcp_atlassian/confluence/pages.py
@@ -316,6 +316,7 @@ class PagesMixin(ConfluenceClient):
         is_minor_edit: bool = False,
         version_comment: str = "",
         is_markdown: bool = True,
+        parent_id: str | None = None,
     ) -> ConfluencePage:
         """
         Update an existing page in Confluence.
@@ -327,6 +328,7 @@ class PagesMixin(ConfluenceClient):
             is_minor_edit: Whether this is a minor edit (keyword-only)
             version_comment: Optional comment for this version (keyword-only)
             is_markdown: Whether the body content is in markdown format (default: True, keyword-only)
+            parent_id: Optional new parent page ID (keyword-only)
 
         Returns:
             ConfluencePage model containing the updated page's data
@@ -342,21 +344,22 @@ class PagesMixin(ConfluenceClient):
                 else body
             )
 
-            # We'll let the underlying Confluence API handle this operation completely
-            # as it has internal logic for versioning and updating
             logger.debug(f"Updating page {page_id} with title '{title}'")
 
-            # Simply pass through all parameters, making sure to match parameter names
-            response = self.confluence.update_page(
-                page_id=page_id,
-                title=title,
-                body=storage_body,
-                type="page",
-                representation="storage",
-                minor_edit=is_minor_edit,  # This matches the parameter name in the API
-                version_comment=version_comment,
-                always_update=True,  # Force update to avoid content comparison issues
-            )
+            update_kwargs = {
+                "page_id": page_id,
+                "title": title,
+                "body": storage_body,
+                "type": "page",
+                "representation": "storage",
+                "minor_edit": is_minor_edit,
+                "version_comment": version_comment,
+                "always_update": True,
+            }
+            if parent_id:
+                update_kwargs["parent_id"] = parent_id
+
+            response = self.confluence.update_page(**update_kwargs)
 
             # After update, refresh the page data
             return self.get_page_content(page_id)
diff --git a/src/mcp_atlassian/jira/sprints.py b/src/mcp_atlassian/jira/sprints.py
index b590593..2a9c19d 100644
--- a/src/mcp_atlassian/jira/sprints.py
+++ b/src/mcp_atlassian/jira/sprints.py
@@ -68,3 +68,56 @@ class SprintsMixin(JiraClient):
             limit=limit,
         )
         return [JiraSprint.from_api_response(sprint) for sprint in sprints]
+
+    def update_sprint(
+        self,
+        sprint_id: str,
+        sprint_name: str | None,
+        state: str | None,
+        start_date: str | None,
+        end_date: str | None,
+        goal: str | None,
+    ) -> JiraSprint | None:
+        """
+        Update a sprint.
+
+        Args:
+            sprint_id: Sprint ID
+            sprint_name: New name for the sprint (optional)
+            state: New state for the sprint (future|active|closed - optional)
+            start_date: New start date for the sprint (optional)
+            end_date: New end date for the sprint (optional)
+            goal: New goal for the sprint (optional)
+
+        Returns:
+            Updated sprint
+        """
+        data = {}
+        if sprint_name:
+            data["name"] = sprint_name
+        if state and state not in ["future", "active", "closed"]:
+            logger.warning("Invalid state. Valid states are: future, active, closed.")
+            return None
+        elif state:
+            data["state"] = state
+        if start_date:
+            data["startDate"] = start_date
+        if end_date:
+            data["endDate"] = end_date
+        if goal:
+            data["goal"] = goal
+        if not sprint_id:
+            logger.warning("Sprint ID is required.")
+            return None
+        try:
+            updated_sprint = self.jira.update_partially_sprint(
+                sprint_id=sprint_id,
+                data=data,
+            )
+            return JiraSprint.from_api_response(updated_sprint)
+        except requests.HTTPError as e:
+            logger.error(f"Error updating sprint: {str(e.response.content)}")
+            return None
+        except Exception as e:
+            logger.error(f"Error updating sprint: {str(e)}")
+            return None
diff --git a/src/mcp_atlassian/server.py b/src/mcp_atlassian/server.py
index 8d258f4..6b0f0fc 100644
--- a/src/mcp_atlassian/server.py
+++ b/src/mcp_atlassian/server.py
@@ -520,6 +520,10 @@ async def list_tools() -> list[Tool]:
                                     "description": "Optional comment for this version",
                                     "default": "",
                                 },
+                                "parent_id": {
+                                    "type": "string",
+                                    "description": "Optional the new parent page ID",
+                                },
                             },
                             "required": ["page_id", "title", "content"],
                         },
@@ -889,6 +893,40 @@ async def list_tools() -> list[Tool]:
                         "required": ["sprint_id"],
                     },
                 ),
+                Tool(
+                    name="jira_update_sprint",
+                    description="Update jira sprint",
+                    inputSchema={
+                        "type": "object",
+                        "properties": {
+                            "sprint_id": {
+                                "type": "string",
+                                "description": "The id of sprint (e.g., '10001')",
+                            },
+                            "sprint_name": {
+                                "type": "string",
+                                "description": "Optional: New name for the sprint",
+                            },
+                            "state": {
+                                "type": "string",
+                                "description": "Optional: New state for the sprint (future|active|closed)",
+                            },
+                            "start_date": {
+                                "type": "string",
+                                "description": "Optional: New start date for the sprint",
+                            },
+                            "end_date": {
+                                "type": "string",
+                                "description": "Optional: New end date for the sprint",
+                            },
+                            "goal": {
+                                "type": "string",
+                                "description": "Optional: New goal for the sprint",
+                            },
+                        },
+                        "required": ["sprint_id"],
+                    },
+                ),
             ]
         )
 
@@ -1450,6 +1488,7 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
             content = arguments.get("content")
             is_minor_edit = arguments.get("is_minor_edit", False)
             version_comment = arguments.get("version_comment", "")
+            parent_id = arguments.get("parent_id")
 
             if not page_id or not title or not content:
                 raise ValueError(
@@ -1464,6 +1503,7 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
                 is_minor_edit=is_minor_edit,
                 version_comment=version_comment,
                 is_markdown=True,
+                parent_id=parent_id,
             )
 
             # Format results
@@ -1857,6 +1897,48 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
                 )
             ]
 
+        elif name == "jira_update_sprint" and ctx and ctx.jira:
+            if not ctx or not ctx.jira:
+                raise ValueError("Jira is not configured.")
+
+            sprint_id = arguments.get("sprint_id")
+            sprint_name = arguments.get("sprint_name")
+            goal = arguments.get("goal")
+            start_date = arguments.get("start_date")
+            end_date = arguments.get("end_date")
+            state = arguments.get("state")
+
+            sprint = ctx.jira.update_sprint(
+                sprint_id=sprint_id,
+                sprint_name=sprint_name,
+                goal=goal,
+                start_date=start_date,
+                end_date=end_date,
+                state=state,
+            )
+
+            if sprint is None:
+                # Handle the error case, e.g., return an error message
+                error_payload = {
+                    "error": f"Failed to update sprint {sprint_id}. Check logs for details."
+                }
+                return [
+                    TextContent(
+                        type="text",
+                        text=json.dumps(error_payload, indent=2, ensure_ascii=False),
+                    )
+                ]
+
+            # If sprint is not None, proceed as before
+            return [
+                TextContent(
+                    type="text",
+                    text=json.dumps(
+                        sprint.to_simplified_dict(), indent=2, ensure_ascii=False
+                    ),
+                )
+            ]
+
         elif name == "jira_create_issue":
             if not ctx or not ctx.jira:
                 raise ValueError("Jira is not configured.")


Test patch:
diff --git a/tests/unit/confluence/test_pages.py b/tests/unit/confluence/test_pages.py
index 2548b09..de8c4cc 100644
--- a/tests/unit/confluence/test_pages.py
+++ b/tests/unit/confluence/test_pages.py
@@ -680,3 +680,49 @@ class TestPagesMixin:
                 version_comment="Updated test",
                 always_update=True,
             )
+
+    def test_update_page_with_parent_id(self, pages_mixin):
+        """Test updating a page and changing its parent."""
+        # Arrange
+        page_id = "987654321"
+        title = "Updated Page"
+        body = "<p>Updated content</p>"
+        parent_id = "123456789"
+        is_minor_edit = False
+        version_comment = "Parent changed"
+
+        # Mock get_page_content to return a document
+        mock_document = ConfluencePage(
+            id=page_id,
+            title=title,
+            content="Updated content",
+            space={"key": "PROJ", "name": "Project"},
+            version={"number": 2},
+        )
+        with patch.object(pages_mixin, "get_page_content", return_value=mock_document):
+            # Act
+            result = pages_mixin.update_page(
+                page_id=page_id,
+                title=title,
+                body=body,
+                is_minor_edit=is_minor_edit,
+                version_comment=version_comment,
+                is_markdown=False,
+                parent_id=parent_id,
+            )
+
+            # Assert
+            pages_mixin.confluence.update_page.assert_called_once_with(
+                page_id=page_id,
+                title=title,
+                body=body,
+                type="page",
+                representation="storage",
+                minor_edit=is_minor_edit,
+                version_comment=version_comment,
+                always_update=True,
+                parent_id=parent_id,
+            )
+            assert result.id == page_id
+            assert result.title == title
+            assert result.version.number == 2
diff --git a/tests/unit/jira/test_sprints.py b/tests/unit/jira/test_sprints.py
index ff1e2ae..1bb4832 100644
--- a/tests/unit/jira/test_sprints.py
+++ b/tests/unit/jira/test_sprints.py
@@ -118,7 +118,102 @@ def test_get_all_sprints_from_board_non_dict_response(sprints_mixin):
 def test_get_all_sprints_from_board_model(sprints_mixin, mock_sprints):
     sprints_mixin.jira.get_all_sprints_from_board.return_value = mock_sprints
 
-    result = sprints_mixin.get_all_sprints_from_board_model("1000")
+    result = sprints_mixin.get_all_sprints_from_board_model(board_id="1000", state=None)
     assert result == [
         JiraSprint.from_api_response(value) for value in mock_sprints["values"]
     ]
+
+
+def test_update_sprint_success(sprints_mixin, mock_sprints):
+    """Test update_sprint method with valid data."""
+    mock_updated_sprint = mock_sprints["values"][0]
+    sprints_mixin.jira.update_partially_sprint.return_value = mock_updated_sprint
+
+    result = sprints_mixin.update_sprint(
+        sprint_id="10000",
+        sprint_name="Updated Sprint Name",
+        state="active",
+        start_date="2024-05-01T00:00:00.000Z",
+        end_date="2024-05-15T00:00:00.000Z",
+        goal="Updated goal",
+    )
+
+    assert result == JiraSprint.from_api_response(mock_updated_sprint)
+    sprints_mixin.jira.update_partially_sprint.assert_called_once_with(
+        sprint_id="10000",
+        data={
+            "name": "Updated Sprint Name",
+            "state": "active",
+            "startDate": "2024-05-01T00:00:00.000Z",
+            "endDate": "2024-05-15T00:00:00.000Z",
+            "goal": "Updated goal",
+        },
+    )
+
+
+def test_update_sprint_invalid_state(sprints_mixin):
+    """Test update_sprint method with invalid state."""
+    result = sprints_mixin.update_sprint(
+        sprint_id="10000",
+        sprint_name="Updated Sprint Name",
+        state="invalid_state",
+        start_date=None,
+        end_date=None,
+        goal=None,
+    )
+
+    assert result is None
+    sprints_mixin.jira.update_partially_sprint.assert_not_called()
+
+
+def test_update_sprint_missing_sprint_id(sprints_mixin):
+    """Test update_sprint method with missing sprint_id."""
+    result = sprints_mixin.update_sprint(
+        sprint_id=None,
+        sprint_name="Updated Sprint Name",
+        state="active",
+        start_date=None,
+        end_date=None,
+        goal=None,
+    )
+
+    assert result is None
+    sprints_mixin.jira.update_partially_sprint.assert_not_called()
+
+
+def test_update_sprint_http_error(sprints_mixin):
+    """Test update_sprint method with HTTPError."""
+    sprints_mixin.jira.update_partially_sprint.side_effect = requests.HTTPError(
+        response=MagicMock(content="API Error content")
+    )
+
+    result = sprints_mixin.update_sprint(
+        sprint_id="10000",
+        sprint_name="Updated Sprint Name",
+        state="active",
+        start_date=None,
+        end_date=None,
+        goal=None,
+    )
+
+    assert result is None
+    sprints_mixin.jira.update_partially_sprint.assert_called_once()
+
+
+def test_update_sprint_exception(sprints_mixin):
+    """Test update_sprint method with a generic exception."""
+    sprints_mixin.jira.update_partially_sprint.side_effect = Exception(
+        "Unexpected Error"
+    )
+
+    result = sprints_mixin.update_sprint(
+        sprint_id="10000",
+        sprint_name="Updated Sprint Name",
+        state="active",
+        start_date=None,
+        end_date=None,
+        goal=None,
+    )
+
+    assert result is None
+    sprints_mixin.jira.update_partially_sprint.assert_called_once()