Repository: sooperset/mcp-atlassian

Issue:
[Feature]: Add "add comment" functionality for Confluence
### Is your feature request related to a problem? Please describe.

Hey everyone, 

for several of our workflows, it would be super valuable if an "add comment" functionality would be also supported by the Atlassian MCP for Confluence. For example, we currently use Confluence to document several types of meetings. And by adding comments to them, we signal the authors and observers which actions have been followed up on certain topics, so they can follow those as well. If we'd use atlassian-mcp to derive those insights automatically and create certain issues, it would be amazing, if it could also add the comments to the meeting notes at the right spot also.

Thanks,
Max

### Describe the solution you'd like

Support an "add comment" tool for Confluence

### Describe alternatives you've considered

_No response_

### Use Case

_No response_

### Additional Context

_No response_

Discussion:
@sooperset: Thanks for opening this feature request, @mxhrbrgr! Adding the ability to programmatically comment on Confluence pages is indeed a valuable addition. We can see how this would significantly enhance workflows, especially for things like documenting meeting actions, as you mentioned.

Contributions for this feature are definitely welcome, and we'd be happy to review a Pull Request if you or someone else is interested in implementing it!@RainRainLiu:  This is a fantastic feature, and I really need it too!

Solution patch:
diff --git a/src/mcp_atlassian/confluence/comments.py b/src/mcp_atlassian/confluence/comments.py
index 0d02014..710adb0 100644
--- a/src/mcp_atlassian/confluence/comments.py
+++ b/src/mcp_atlassian/confluence/comments.py
@@ -83,3 +83,64 @@ class CommentsMixin(ConfluenceClient):
             logger.error(f"Unexpected error fetching comments: {str(e)}")
             logger.debug("Full exception details for comments:", exc_info=True)
             return []
+
+    def add_comment(self, page_id: str, content: str) -> ConfluenceComment | None:
+        """
+        Add a comment to a Confluence page.
+
+        Args:
+            page_id: The ID of the page to add the comment to
+            content: The content of the comment (in Confluence storage format)
+
+        Returns:
+            ConfluenceComment object if comment was added successfully, None otherwise
+        """
+        try:
+            # Get page info to extract space details
+            page = self.confluence.get_page_by_id(page_id=page_id, expand="space")
+            space_key = page.get("space", {}).get("key", "")
+
+            # Convert markdown to Confluence storage format if needed
+            # The atlassian-python-api expects content in Confluence storage format
+            if not content.strip().startswith("<"):
+                # If content doesn't appear to be HTML/XML, treat it as markdown
+                content = self.preprocessor.markdown_to_confluence_storage(content)
+
+            # Add the comment via the Confluence API
+            response = self.confluence.add_comment(page_id, content)
+
+            if not response:
+                logger.error("Failed to add comment: empty response")
+                return None
+
+            # Process the comment to return a consistent model
+            processed_html, processed_markdown = self.preprocessor.process_html_content(
+                response.get("body", {}).get("view", {}).get("value", ""),
+                space_key=space_key,
+            )
+
+            # Modify the response to include processed content
+            modified_response = response.copy()
+            if "body" not in modified_response:
+                modified_response["body"] = {}
+            if "view" not in modified_response["body"]:
+                modified_response["body"]["view"] = {}
+
+            modified_response["body"]["view"]["value"] = processed_markdown
+
+            # Create and return the comment model
+            return ConfluenceComment.from_api_response(
+                modified_response,
+                base_url=self.config.url,
+            )
+
+        except requests.RequestException as e:
+            logger.error(f"Network error when adding comment: {str(e)}")
+            return None
+        except (ValueError, TypeError, KeyError) as e:
+            logger.error(f"Error processing comment data: {str(e)}")
+            return None
+        except Exception as e:  # noqa: BLE001 - Intentional fallback with full logging
+            logger.error(f"Unexpected error adding comment: {str(e)}")
+            logger.debug("Full exception details for adding comment:", exc_info=True)
+            return None
diff --git a/src/mcp_atlassian/servers/confluence.py b/src/mcp_atlassian/servers/confluence.py
index 55c50f9..b42aa21 100644
--- a/src/mcp_atlassian/servers/confluence.py
+++ b/src/mcp_atlassian/servers/confluence.py
@@ -526,3 +526,59 @@ async def delete_page(
         }
 
     return json.dumps(response, indent=2, ensure_ascii=False)
+
+
+@confluence_mcp.tool(tags={"confluence", "write"})
+async def add_comment(
+    ctx: Context,
+    page_id: Annotated[
+        str, Field(description="The ID of the page to add a comment to")
+    ],
+    content: Annotated[
+        str, Field(description="The comment content in Markdown format")
+    ],
+) -> str:
+    """Add a comment to a Confluence page.
+
+    Args:
+        ctx: The FastMCP context.
+        page_id: The ID of the page to add a comment to.
+        content: The comment content in Markdown format.
+
+    Returns:
+        JSON string representing the created comment.
+
+    Raises:
+        ValueError: If in read-only mode or Confluence client is unavailable.
+    """
+    lifespan_ctx = ctx.request_context.lifespan_context
+    if lifespan_ctx.read_only:
+        logger.warning("Attempted to call add_comment in read-only mode.")
+        raise ValueError("Cannot add comment in read-only mode.")
+    if not lifespan_ctx or not lifespan_ctx.confluence:
+        raise ValueError("Confluence client is not configured or available.")
+    confluence = lifespan_ctx.confluence
+
+    try:
+        comment = confluence.add_comment(page_id=page_id, content=content)
+        if comment:
+            comment_data = comment.to_simplified_dict()
+            response = {
+                "success": True,
+                "message": "Comment added successfully",
+                "comment": comment_data,
+            }
+        else:
+            response = {
+                "success": False,
+                "message": f"Unable to add comment to page {page_id}. API request completed but comment creation unsuccessful.",
+            }
+    except Exception as e:
+        logger.error(f"Error adding comment to Confluence page {page_id}: {str(e)}")
+        response = {
+            "success": False,
+            "message": f"Error adding comment to page {page_id}",
+            "error": str(e),
+        }
+
+    return json.dumps(response, indent=2, ensure_ascii=False)


Test patch:
diff --git a/tests/unit/confluence/test_comments.py b/tests/unit/confluence/test_comments.py
index 9fd17f9..2c93f1b 100644
--- a/tests/unit/confluence/test_comments.py
+++ b/tests/unit/confluence/test_comments.py
@@ -136,3 +136,132 @@ class TestCommentsMixin:
         # Assert
         assert isinstance(result, list)
         assert len(result) == 0  # Empty list with no comments
+
+    def test_add_comment_success(self, comments_mixin):
+        """Test adding a comment with success response."""
+        # Setup
+        page_id = "12345"
+        content = "This is a test comment"
+
+        # Mock the page retrieval
+        comments_mixin.confluence.get_page_by_id.return_value = {
+            "space": {"key": "TEST"}
+        }
+
+        # Mock the preprocessor's conversion method
+        comments_mixin.preprocessor.markdown_to_confluence_storage.return_value = (
+            "<p>This is a test comment</p>"
+        )
+
+        # Configure the mock to return a successful response
+        comments_mixin.confluence.add_comment.return_value = {
+            "id": "98765",
+            "body": {"view": {"value": "<p>This is a test comment</p>"}},
+            "version": {"number": 1},
+            "author": {"displayName": "Test User"},
+        }
+
+        # Mock the HTML processing
+        comments_mixin.preprocessor.process_html_content.return_value = (
+            "<p>This is a test comment</p>",
+            "This is a test comment",
+        )
+
+        # Call the method
+        result = comments_mixin.add_comment(page_id, content)
+
+        # Verify
+        comments_mixin.confluence.add_comment.assert_called_once_with(
+            page_id, "<p>This is a test comment</p>"
+        )
+        assert result is not None
+        assert result.id == "98765"
+        assert result.body == "This is a test comment"
+
+    def test_add_comment_with_html_content(self, comments_mixin):
+        """Test adding a comment with HTML content."""
+        # Setup
+        page_id = "12345"
+        content = "<p>This is an <strong>HTML</strong> comment</p>"
+
+        # Mock the page retrieval
+        comments_mixin.confluence.get_page_by_id.return_value = {
+            "space": {"key": "TEST"}
+        }
+
+        # Configure the mock to return a successful response
+        comments_mixin.confluence.add_comment.return_value = {
+            "id": "98765",
+            "body": {
+                "view": {"value": "<p>This is an <strong>HTML</strong> comment</p>"}
+            },
+            "version": {"number": 1},
+            "author": {"displayName": "Test User"},
+        }
+
+        # Mock the HTML processing
+        comments_mixin.preprocessor.process_html_content.return_value = (
+            "<p>This is an <strong>HTML</strong> comment</p>",
+            "This is an **HTML** comment",
+        )
+
+        # Call the method
+        result = comments_mixin.add_comment(page_id, content)
+
+        # Verify - should not call markdown conversion since content is already HTML
+        comments_mixin.preprocessor.markdown_to_confluence_storage.assert_not_called()
+        comments_mixin.confluence.add_comment.assert_called_once_with(page_id, content)
+        assert result is not None
+        assert result.body == "This is an **HTML** comment"
+
+    def test_add_comment_api_error(self, comments_mixin):
+        """Test handling of API errors when adding a comment."""
+        # Setup
+        page_id = "12345"
+        content = "This is a test comment"
+
+        # Mock the page retrieval
+        comments_mixin.confluence.get_page_by_id.return_value = {
+            "space": {"key": "TEST"}
+        }
+
+        # Mock the preprocessor's conversion method
+        comments_mixin.preprocessor.markdown_to_confluence_storage.return_value = (
+            "<p>This is a test comment</p>"
+        )
+
+        # Mock the API to raise an exception
+        comments_mixin.confluence.add_comment.side_effect = requests.RequestException(
+            "API error"
+        )
+
+        # Call the method
+        result = comments_mixin.add_comment(page_id, content)
+
+        # Verify
+        assert result is None
+
+    def test_add_comment_empty_response(self, comments_mixin):
+        """Test handling of empty API response when adding a comment."""
+        # Setup
+        page_id = "12345"
+        content = "This is a test comment"
+
+        # Mock the page retrieval
+        comments_mixin.confluence.get_page_by_id.return_value = {
+            "space": {"key": "TEST"}
+        }
+
+        # Mock the preprocessor's conversion method
+        comments_mixin.preprocessor.markdown_to_confluence_storage.return_value = (
+            "<p>This is a test comment</p>"
+        )
+
+        # Configure the mock to return an empty response
+        comments_mixin.confluence.add_comment.return_value = None
+
+        # Call the method
+        result = comments_mixin.add_comment(page_id, content)
+
+        # Verify
+        assert result is None
diff --git a/tests/unit/servers/test_confluence_server.py b/tests/unit/servers/test_confluence_server.py
index ece4d38..2027f03 100644
--- a/tests/unit/servers/test_confluence_server.py
+++ b/tests/unit/servers/test_confluence_server.py
@@ -73,6 +73,16 @@ def mock_confluence_fetcher():
     # Mock add_page_label method
     mock_fetcher.add_page_label.return_value = [mock_label]
 
+    # Mock add_comment method
+    mock_comment = MagicMock()
+    mock_comment.to_simplified_dict.return_value = {
+        "id": "987",
+        "author": "Test User",
+        "created": "2023-08-01T13:00:00.000Z",
+        "body": "This is a test comment added via API",
+    }
+    mock_fetcher.add_comment.return_value = mock_comment
+
     return mock_fetcher
 
 
@@ -97,6 +107,7 @@ def test_confluence_mcp(mock_confluence_fetcher):
 
     # Import the tool functions we want to test
     from src.mcp_atlassian.servers.confluence import (
+        add_comment,
         add_label,
         create_page,
         delete_page,
@@ -118,6 +129,7 @@ def test_confluence_mcp(mock_confluence_fetcher):
     test_mcp.tool()(create_page)
     test_mcp.tool()(update_page)
     test_mcp.tool()(delete_page)
+    test_mcp.tool()(add_comment)
 
     return test_mcp
 
@@ -145,6 +157,7 @@ def read_only_test_confluence_mcp(mock_confluence_fetcher):
 
     # Import and register tools as before
     from src.mcp_atlassian.servers.confluence import (
+        add_comment,
         add_label,
         create_page,
         delete_page,
@@ -165,6 +178,7 @@ def read_only_test_confluence_mcp(mock_confluence_fetcher):
     test_mcp.tool()(create_page)
     test_mcp.tool()(update_page)
     test_mcp.tool()(delete_page)
+    test_mcp.tool()(add_comment)
 
     return test_mcp
 
@@ -190,6 +204,7 @@ def no_fetcher_test_confluence_mcp():
     )
     # Import and register tools
     from src.mcp_atlassian.servers.confluence import (
+        add_comment,
         add_label,
         create_page,
         delete_page,
@@ -210,6 +225,7 @@ def no_fetcher_test_confluence_mcp():
     test_mcp.tool()(create_page)
     test_mcp.tool()(update_page)
     test_mcp.tool()(delete_page)
+    test_mcp.tool()(add_comment)
 
     return test_mcp
 
@@ -475,6 +491,18 @@ async def test_read_only_mode_delete_page(read_only_client, mock_confluence_fetc
     mock_confluence_fetcher.delete_page.assert_not_called()
 
 
+@pytest.mark.anyio
+async def test_read_only_mode_add_comment(read_only_client, mock_confluence_fetcher):
+    """Test add_comment is blocked in read-only mode."""
+    with pytest.raises(Exception) as excinfo:
+        await read_only_client.call_tool(
+            "add_comment",
+            {"page_id": "123", "content": "Test comment in read-only mode"},
+        )
+    assert "read-only mode" in str(excinfo.value)
+    mock_confluence_fetcher.add_comment.assert_not_called()
+
+
 @pytest.mark.anyio
 async def test_missing_credentials_error(no_fetcher_client):
     """Test error handling when Confluence fetcher is not available."""
@@ -504,3 +532,24 @@ async def test_api_error_handling_delete(client, mock_confluence_fetcher):
     assert result_data["success"] is False
     assert "Error deleting page" in result_data["message"]
     assert api_error_msg in result_data["error"]
+
+
+@pytest.mark.anyio
+async def test_add_comment(client, mock_confluence_fetcher):
+    """Test adding a comment to a Confluence page."""
+    response = await client.call_tool(
+        "add_comment",
+        {"page_id": "123456", "content": "Test comment content"},
+    )
+
+    # Verify the fetcher was called with the right arguments
+    mock_confluence_fetcher.add_comment.assert_called_once_with(
+        page_id="123456", content="Test comment content"
+    )
+
+    # Check that the response contains the expected data
+    result_data = json.loads(response[0].text)
+    assert isinstance(result_data, dict)
+    assert result_data["success"] is True
+    assert "comment" in result_data
+    assert result_data["comment"]["id"] == "987"