Repository: sooperset/mcp-atlassian

Issue:
[Bug]: Confluence URL is not well formatted
### Prerequisites

- [x] I have searched the [existing issues](https://github.com/sooperset/mcp-atlassian/issues) to make sure this bug has not already been reported.
- [x] I have checked the [README](https://github.com/sooperset/mcp-atlassian/blob/main/README.md) for relevant information.

### Bug Description

When I use the confluence search tool it fails with following:

INFO - mcp.server.lowlevel.server - Processing request of type CallToolRequest
DEBUG - mcp.server.lowlevel.server - Dispatching request of type CallToolRequest
DEBUG - atlassian.rest_client - curl --silent -X GET -H 'Content-Type: application/json' -H 'Accept: application/json'  ''https://<JIRA>.net/rest'/rest/api/search?start=0&limit=5&cql=Example+AND+type+%3D+page'
ERROR - mcp-atlassian - Network error during search: No connection adapters were found for "'https://<JIRA>.net/rest'/rest/api/search?start=0&limit=5&cql=Example+AND+type+%3D+page"
DEBUG - mcp.server.lowlevel.server - Response sent

Seems as the constructed URL contains colons, so instead of being as 

'https://<JIRA>.net/rest/rest/api/search?start=0&limit=5&cql=Example+AND+type+%3D+page'

is

''https://<JIRA>.net/rest'/rest/api/search?start=0&limit=5&cql=Example+AND+type+%3D+page'

### Steps to Reproduce

1. Start server with docker run --rm -p 9000:9000 --env-file <LOCATION>\.env ghcr.io/sooperset/mcp-atlassian:latest --transport sse --port 9000 -vv
2. Use following configuration in Librechat yaml 
mcpServers:
  mcp-atlassian-sse:
    "url": "http://localhost:9000/sse"
3. When calling the tool above issue is displayed.

### Expected Behavior

I expected Confluence search tool to search CQL correctly

### Actual Behavior

```shell
The search returns the issue described in bug descirption
```

### mcp-atlassian Version

latest

### Installation Method

Docker

### Operating System

Windows

### Python Version

3.11.9

### Atlassian Instance Type

Confluence Server / Data Center

### Client Application

Librechat

### Additional Context

_No response_

Discussion:
@sooperset: Hi @ala85,

Thanks for this detailed bug report with logs! The URL formatting issue is definitely causing the connection error you're seeing.

Looking at the error log, it appears quotes are being incorrectly added to the URL:
`''https://.net/rest'/rest/api/search?...`

Could you please check your `.env` file and verify the exact format of your `CONFLUENCE_URL` setting? It should look like:
`CONFLUENCE_URL=https://yourdomain.atlassian.net`

If you're using Server/Data Center, ensure there are no extra quotes, spaces, or trailing slashes in the value. I suspect there might be single or double quotes already included in your environment variable that are causing this malformation.

I'll investigate this on our side as well to ensure proper URL handling in all cases.@zparnold: Might be worth to investigate @sooperset if there's some issue automation like they have in the Kubernetes project to automatically handle the lifecycle of issues in case there's no follow up from the interested party@sooperset: @zparnold That's a good idea! I'll look into some solutions@sooperset: let me close this issue due to the inactivity. feel free to reopen this issue if you find this issue again!

Solution patch:
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 0000000..5f61d7e
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,53 @@
+name: 'Manage Stale Issues and PRs'
+
+on:
+  schedule:
+    # Runs daily at midnight UTC
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+  pull-requests: write
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v9
+        with:
+          # === Core Timing Settings ===
+          days-before-stale: 14 # Start with 2 weeks
+          days-before-close: -1 # Disable automatic closing initially
+
+          # === Labels ===
+          stale-issue-label: 'stale'
+          stale-pr-label: 'stale'
+
+          # === Bot Messages ===
+          stale-issue-message: >
+            This issue has been automatically marked as stale because it has not had
+            recent activity for 14 days. It will be closed if no further activity occurs.
+            Please leave a comment or remove the 'stale' label if you believe this issue is still relevant.
+            Thank you for your contributions!
+          stale-pr-message: >
+            This pull request has been automatically marked as stale because it has not had
+            recent activity for 14 days. It will be closed if no further activity occurs.
+            Please leave a comment or remove the 'stale' label if you believe this PR is still relevant.
+            Thank you for your contributions!
+
+          # === Exemptions ===
+          exempt-issue-labels: 'pinned,security,good first issue,help wanted,bug,enhancement,feature request,documentation,awaiting-user-feedback,needs-investigation'
+          exempt-pr-labels: 'pinned,security,work-in-progress,awaiting-review,do-not-merge'
+          exempt-all-milestones: true
+          exempt-all-assignees: false # Consider if most issues should have assignees
+          exempt-draft-pr: true
+
+          # === Behavior Control ===
+          remove-stale-when-updated: true
+          operations-per-run: 30
+          debug-only: false
+          delete-branch: false
+
+          # --- Statistics ---
+          enable-statistics: true
diff --git a/src/mcp_atlassian/confluence/client.py b/src/mcp_atlassian/confluence/client.py
index be10d07..903e02a 100644
--- a/src/mcp_atlassian/confluence/client.py
+++ b/src/mcp_atlassian/confluence/client.py
@@ -125,6 +125,28 @@ class ConfluenceClient:
         """
         return self.confluence.get_user_details_by_accountid(account_id, expand)
 
+    def get_user_details_by_username(
+        self, username: str, expand: str = None
+    ) -> dict[str, Any]:
+        """Get user details by username.
+
+        This is typically used for Confluence Server/DC instances where username
+        might be used as an identifier.
+
+        Args:
+            username: The username of the user
+            expand: OPTIONAL expand for get status of user.
+                Possible param is "status". Results are "Active, Deactivated"
+
+        Returns:
+            User details as a dictionary
+
+        Raises:
+            Various exceptions from the Atlassian API if user doesn't exist or
+            if there are permission issues
+        """
+        return self.confluence.get_user_details_by_username(username, expand)
+
     def _process_html_content(
         self, html_content: str, space_key: str
     ) -> tuple[str, str]:
diff --git a/src/mcp_atlassian/models/confluence/page.py b/src/mcp_atlassian/models/confluence/page.py
index 661d7f2..2e5938d 100644
--- a/src/mcp_atlassian/models/confluence/page.py
+++ b/src/mcp_atlassian/models/confluence/page.py
@@ -157,6 +157,11 @@ class ConfluencePage(ApiModel, TimestampMixin):
             if content_format in body:
                 content = body.get(content_format, {}).get("value", EMPTY_STRING)
 
+        # Adjust content_format if convert_to_markdown is False and content is processed HTML
+        convert_to_markdown = kwargs.get("convert_to_markdown", True)
+        if not convert_to_markdown:
+            content_format = "html"
+
         # Process author/creator
         author = None
         if author_data := data.get("author"):
diff --git a/src/mcp_atlassian/preprocessing/base.py b/src/mcp_atlassian/preprocessing/base.py
index 1daf7f5..7e7dafa 100644
--- a/src/mcp_atlassian/preprocessing/base.py
+++ b/src/mcp_atlassian/preprocessing/base.py
@@ -18,6 +18,10 @@ class ConfluenceClient(Protocol):
         """Get user details by account ID."""
         ...
 
+    def get_user_details_by_username(self, username: str) -> dict[str, Any]:
+        """Get user details by username (for Server/DC compatibility)."""
+        ...
+
 
 class BasePreprocessor:
     """Base class for text preprocessing operations."""
@@ -54,6 +58,7 @@ class BasePreprocessor:
 
             # Process user mentions
             self._process_user_mentions_in_soup(soup)
+            self._process_user_profile_macros_in_soup(soup)
 
             # Convert to string and markdown
             processed_html = str(soup)
@@ -93,6 +98,79 @@ class BasePreprocessor:
                     if isinstance(account_id, str):
                         self._replace_user_mention(user_element, account_id)
 
+    def _process_user_profile_macros_in_soup(self, soup: BeautifulSoup) -> None:
+        """
+        Process Confluence User Profile macros in BeautifulSoup object.
+        Replaces <ac:structured-macro ac:name="profile">...</ac:structured-macro>
+        with the user's display name, typically formatted as @DisplayName.
+
+        Args:
+            soup: BeautifulSoup object containing HTML
+        """
+        profile_macros = soup.find_all(
+            "ac:structured-macro", attrs={"ac:name": "profile"}
+        )
+
+        for macro_element in profile_macros:
+            user_param = macro_element.find("ac:parameter", attrs={"ac:name": "user"})
+            if not user_param:
+                logger.debug(
+                    "User profile macro found without a 'user' parameter. Replacing with placeholder."
+                )
+                macro_element.replace_with("[User Profile Macro (Malformed)]")
+                continue
+
+            user_ref = user_param.find("ri:user")
+            if not user_ref:
+                logger.debug(
+                    "User profile macro's 'user' parameter found without 'ri:user' tag. Replacing with placeholder."
+                )
+                macro_element.replace_with("[User Profile Macro (Malformed)]")
+                continue
+
+            account_id = user_ref.get("ri:account-id")
+            userkey = user_ref.get("ri:userkey")  # Fallback for Confluence Server/DC
+
+            user_identifier_for_log = account_id or userkey
+            display_name = None
+
+            if self.confluence_client and user_identifier_for_log:
+                try:
+                    if account_id and isinstance(account_id, str):
+                        user_details = (
+                            self.confluence_client.get_user_details_by_accountid(
+                                account_id
+                            )
+                        )
+                        display_name = user_details.get("displayName")
+                    elif userkey and isinstance(userkey, str):
+                        # For Confluence Server/DC, userkey might be the username
+                        user_details = (
+                            self.confluence_client.get_user_details_by_username(userkey)
+                        )
+                        display_name = user_details.get("displayName")
+                except Exception as e:
+                    logger.warning(
+                        f"Error fetching user details for profile macro (user: {user_identifier_for_log}): {e}"
+                    )
+            elif not self.confluence_client:
+                logger.warning(
+                    "Confluence client not available for User Profile Macro processing."
+                )
+
+            if display_name:
+                replacement_text = f"@{display_name}"
+                macro_element.replace_with(replacement_text)
+            else:
+                fallback_identifier = (
+                    user_identifier_for_log
+                    if user_identifier_for_log
+                    else "unknown_user"
+                )
+                fallback_text = f"[User Profile: {fallback_identifier}]"
+                macro_element.replace_with(fallback_text)
+                logger.debug(f"Using fallback for user profile macro: {fallback_text}")
+
     def _replace_user_mention(self, user_element: Tag, account_id: str) -> None:
         """
         Replace a user mention with the user's display name.


Test patch:
diff --git a/tests/fixtures/confluence_mocks.py b/tests/fixtures/confluence_mocks.py
index 95120ff..ca2ffe0 100644
--- a/tests/fixtures/confluence_mocks.py
+++ b/tests/fixtures/confluence_mocks.py
@@ -146,7 +146,12 @@ MOCK_PAGE_RESPONSE = {
     },
     "body": {
         "storage": {
-            "value": '<h2><ac:emoticon ac:name="blue-star" />&nbsp;Date</h2><p><time datetime="2024-01-01" /></p><h2><ac:emoticon ac:name="blue-star" />&nbsp;Participants</h2><ul><li><p><ac:link><ri:user ri:account-id="user123" /></ac:link></p></li></ul><h2><ac:emoticon ac:name="blue-star" />&nbsp;Goals</h2><ul><li><p>Example goal</p></li></ul>',
+            "value": '<h2><ac:emoticon ac:name="blue-star" />&nbsp;Date</h2><p><time datetime="2024-01-01" /></p><h2><ac:emoticon ac:name="blue-star" />&nbsp;Participants</h2><ul><li><p><ac:link><ri:user ri:account-id="user123" /></ac:link></p></li></ul><h2><ac:emoticon ac:name="blue-star" />&nbsp;Goals</h2><ul><li><p>Example goal</p></li></ul>'
+            '<p><ac:structured-macro ac:name="profile">'
+            '<ac:parameter ac:name="user">'
+            '<ri:user ri:account-id="user123" />'
+            "</ac:parameter>"
+            "</ac:structured-macro></p>",
             "representation": "storage",
             "embeddedContent": [],
             "_expandable": {"content": "/rest/api/content/987654321"},
diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py
index a038616..99aca5c 100644
--- a/tests/test_preprocessing.py
+++ b/tests/test_preprocessing.py
@@ -308,3 +308,98 @@ This is some **bold** and *italic* text.
     assert "<em>" in storage_format or "<i>" in storage_format  # Italic
     assert "<a href=" in storage_format.lower()  # Link
     assert "example.com" in storage_format
+
+
+def test_process_confluence_profile_macro(preprocessor_with_confluence):
+    """Test processing Confluence User Profile Macro in page content."""
+    html_content = MOCK_PAGE_RESPONSE["body"]["storage"]["value"]
+    processed_html, processed_markdown = (
+        preprocessor_with_confluence.process_html_content(html_content)
+    )
+    # Should replace macro with @Test User user123
+    assert "@Test User user123" in processed_html
+    assert "@Test User user123" in processed_markdown
+
+
+def test_process_confluence_profile_macro_malformed(preprocessor_with_confluence):
+    """Test processing malformed User Profile Macro (missing user param and ri:user)."""
+    # Macro missing ac:parameter
+    html_missing_param = '<ac:structured-macro ac:name="profile"></ac:structured-macro>'
+    processed_html, processed_markdown = (
+        preprocessor_with_confluence.process_html_content(html_missing_param)
+    )
+    assert "[User Profile Macro (Malformed)]" in processed_html
+    assert "[User Profile Macro (Malformed)]" in processed_markdown
+
+    # Macro with ac:parameter but missing ri:user
+    html_missing_riuser = '<ac:structured-macro ac:name="profile"><ac:parameter ac:name="user"></ac:parameter></ac:structured-macro>'
+    processed_html, processed_markdown = (
+        preprocessor_with_confluence.process_html_content(html_missing_riuser)
+    )
+    assert "[User Profile Macro (Malformed)]" in processed_html
+    assert "[User Profile Macro (Malformed)]" in processed_markdown
+
+
+def test_process_confluence_profile_macro_fallback():
+    """Test fallback when confluence_client is None."""
+    from mcp_atlassian.preprocessing.confluence import ConfluencePreprocessor
+
+    html = (
+        '<ac:structured-macro ac:name="profile">'
+        '<ac:parameter ac:name="user">'
+        '<ri:user ri:account-id="user999" />'
+        "</ac:parameter>"
+        "</ac:structured-macro>"
+    )
+    preprocessor = ConfluencePreprocessor(
+        base_url="https://example.atlassian.net", confluence_client=None
+    )
+    processed_html, processed_markdown = preprocessor.process_html_content(html)
+    assert "[User Profile: user999]" in processed_html
+    assert "[User Profile: user999]" in processed_markdown
+
+
+def test_process_user_profile_macro_multiple():
+    """Test processing multiple User Profile Macros with account-id and userkey."""
+    from mcp_atlassian.preprocessing.confluence import ConfluencePreprocessor
+
+    html = (
+        "<p>This page mentions a user via profile macro: "
+        '<ac:structured-macro ac:name="profile" ac:schema-version="1">'
+        '<ac:parameter ac:name="user">'
+        '<ri:user ri:account-id="test-account-id-123" />'
+        "</ac:parameter>"
+        "</ac:structured-macro>. "
+        "And another one: "
+        '<ac:structured-macro ac:name="profile" ac:schema-version="1">'
+        '<ac:parameter ac:name="user">'
+        '<ri:user ri:userkey="test-userkey-456" />'
+        "</ac:parameter>"
+        "</ac:structured-macro>."
+        "</p>"
+    )
+
+    class CustomMockConfluenceClient:
+        def get_user_details_by_accountid(self, account_id):
+            return (
+                {"displayName": "Test User One"}
+                if account_id == "test-account-id-123"
+                else {}
+            )
+
+        def get_user_details_by_username(self, username):
+            return (
+                {"displayName": "Test User Two"}
+                if username == "test-userkey-456"
+                else {}
+            )
+
+    preprocessor = ConfluencePreprocessor(
+        base_url="https://example.atlassian.net",
+        confluence_client=CustomMockConfluenceClient(),
+    )
+    processed_html, processed_markdown = preprocessor.process_html_content(html)
+    assert "@Test User One" in processed_html
+    assert "@Test User Two" in processed_html
+    assert "@Test User One" in processed_markdown
+    assert "@Test User Two" in processed_markdown