#
tokens: 49690/50000 36/194 files (page 2/13)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 13. Use http://codebase.md/sooperset/mcp-atlassian?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .devcontainer
│   ├── devcontainer.json
│   ├── Dockerfile
│   ├── post-create.sh
│   └── post-start.sh
├── .dockerignore
├── .env.example
├── .github
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows
│       ├── docker-publish.yml
│       ├── lint.yml
│       ├── publish.yml
│       ├── stale.yml
│       └── tests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── AGENTS.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── scripts
│   ├── oauth_authorize.py
│   └── test_with_real_data.sh
├── SECURITY.md
├── smithery.yaml
├── src
│   └── mcp_atlassian
│       ├── __init__.py
│       ├── confluence
│       │   ├── __init__.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── labels.py
│       │   ├── pages.py
│       │   ├── search.py
│       │   ├── spaces.py
│       │   ├── users.py
│       │   ├── utils.py
│       │   └── v2_adapter.py
│       ├── exceptions.py
│       ├── jira
│       │   ├── __init__.py
│       │   ├── attachments.py
│       │   ├── boards.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── epics.py
│       │   ├── fields.py
│       │   ├── formatting.py
│       │   ├── issues.py
│       │   ├── links.py
│       │   ├── projects.py
│       │   ├── protocols.py
│       │   ├── search.py
│       │   ├── sprints.py
│       │   ├── transitions.py
│       │   ├── users.py
│       │   └── worklog.py
│       ├── models
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence
│       │   │   ├── __init__.py
│       │   │   ├── comment.py
│       │   │   ├── common.py
│       │   │   ├── label.py
│       │   │   ├── page.py
│       │   │   ├── search.py
│       │   │   ├── space.py
│       │   │   └── user_search.py
│       │   ├── constants.py
│       │   └── jira
│       │       ├── __init__.py
│       │       ├── agile.py
│       │       ├── comment.py
│       │       ├── common.py
│       │       ├── issue.py
│       │       ├── link.py
│       │       ├── project.py
│       │       ├── search.py
│       │       ├── version.py
│       │       ├── workflow.py
│       │       └── worklog.py
│       ├── preprocessing
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence.py
│       │   └── jira.py
│       ├── servers
│       │   ├── __init__.py
│       │   ├── confluence.py
│       │   ├── context.py
│       │   ├── dependencies.py
│       │   ├── jira.py
│       │   └── main.py
│       └── utils
│           ├── __init__.py
│           ├── date.py
│           ├── decorators.py
│           ├── env.py
│           ├── environment.py
│           ├── io.py
│           ├── lifecycle.py
│           ├── logging.py
│           ├── oauth_setup.py
│           ├── oauth.py
│           ├── ssl.py
│           ├── tools.py
│           └── urls.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures
│   │   ├── __init__.py
│   │   ├── confluence_mocks.py
│   │   └── jira_mocks.py
│   ├── integration
│   │   ├── conftest.py
│   │   ├── README.md
│   │   ├── test_authentication.py
│   │   ├── test_content_processing.py
│   │   ├── test_cross_service.py
│   │   ├── test_mcp_protocol.py
│   │   ├── test_proxy.py
│   │   ├── test_real_api.py
│   │   ├── test_ssl_verification.py
│   │   ├── test_stdin_monitoring_fix.py
│   │   └── test_transport_lifecycle.py
│   ├── README.md
│   ├── test_preprocessing.py
│   ├── test_real_api_validation.py
│   ├── unit
│   │   ├── confluence
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_labels.py
│   │   │   ├── test_pages.py
│   │   │   ├── test_search.py
│   │   │   ├── test_spaces.py
│   │   │   ├── test_users.py
│   │   │   ├── test_utils.py
│   │   │   └── test_v2_adapter.py
│   │   ├── jira
│   │   │   ├── conftest.py
│   │   │   ├── test_attachments.py
│   │   │   ├── test_boards.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_epics.py
│   │   │   ├── test_fields.py
│   │   │   ├── test_formatting.py
│   │   │   ├── test_issues_markdown.py
│   │   │   ├── test_issues.py
│   │   │   ├── test_links.py
│   │   │   ├── test_projects.py
│   │   │   ├── test_protocols.py
│   │   │   ├── test_search.py
│   │   │   ├── test_sprints.py
│   │   │   ├── test_transitions.py
│   │   │   ├── test_users.py
│   │   │   └── test_worklog.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_base_models.py
│   │   │   ├── test_confluence_models.py
│   │   │   ├── test_constants.py
│   │   │   └── test_jira_models.py
│   │   ├── servers
│   │   │   ├── __init__.py
│   │   │   ├── test_confluence_server.py
│   │   │   ├── test_context.py
│   │   │   ├── test_dependencies.py
│   │   │   ├── test_jira_server.py
│   │   │   └── test_main_server.py
│   │   ├── test_exceptions.py
│   │   ├── test_main_transport_selection.py
│   │   └── utils
│   │       ├── __init__.py
│   │       ├── test_custom_headers.py
│   │       ├── test_date.py
│   │       ├── test_decorators.py
│   │       ├── test_env.py
│   │       ├── test_environment.py
│   │       ├── test_io.py
│   │       ├── test_lifecycle.py
│   │       ├── test_logging.py
│   │       ├── test_masking.py
│   │       ├── test_oauth_setup.py
│   │       ├── test_oauth.py
│   │       ├── test_ssl.py
│   │       ├── test_tools.py
│   │       └── test_urls.py
│   └── utils
│       ├── __init__.py
│       ├── assertions.py
│       ├── base.py
│       ├── factories.py
│       └── mocks.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/tests/unit/jira/test_boards.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for the Jira BoardMixin"""
  2 | 
  3 | from unittest.mock import MagicMock
  4 | 
  5 | import pytest
  6 | import requests
  7 | 
  8 | from mcp_atlassian.jira import JiraConfig
  9 | from mcp_atlassian.jira.boards import BoardsMixin
 10 | from mcp_atlassian.models.jira import JiraBoard
 11 | 
 12 | 
 13 | @pytest.fixture
 14 | def mock_config():
 15 |     """Fixture to create a mock JiraConfig instance."""
 16 |     config = MagicMock(spec=JiraConfig)
 17 |     config.url = "https://test.atlassian.net"
 18 |     config.username = "[email protected]"
 19 |     config.api_token = "test-token"
 20 |     config.auth_type = "pat"
 21 |     return config
 22 | 
 23 | 
 24 | @pytest.fixture
 25 | def boards_mixin(mock_config):
 26 |     """Fixture to create a BoardsMixin instance for testing."""
 27 |     mixin = BoardsMixin(config=mock_config)
 28 |     mixin.jira = MagicMock()
 29 | 
 30 |     return mixin
 31 | 
 32 | 
 33 | @pytest.fixture
 34 | def mock_boards():
 35 |     """Fixture to return mock boards data."""
 36 |     return {
 37 |         "maxResults": 2,
 38 |         "startAt": 0,
 39 |         "total": 2,
 40 |         "isLast": True,
 41 |         "values": [
 42 |             {
 43 |                 "id": 1000,
 44 |                 "self": "https://test.atlassian.net/rest/agile/1.0/board/1000",
 45 |                 "name": " Board One",
 46 |                 "type": "scrum",
 47 |             },
 48 |             {
 49 |                 "id": 1001,
 50 |                 "self": "https://test.atlassian.net/rest/agile/1.0/board/1001",
 51 |                 "name": " Board Two",
 52 |                 "type": "kanban",
 53 |             },
 54 |         ],
 55 |     }
 56 | 
 57 | 
 58 | def test_get_all_agile_boards(boards_mixin, mock_boards):
 59 |     """Test get_all_agile_boards method."""
 60 |     boards_mixin.jira.get_all_agile_boards.return_value = mock_boards
 61 | 
 62 |     result = boards_mixin.get_all_agile_boards()
 63 |     assert result == mock_boards["values"]
 64 | 
 65 | 
 66 | def test_get_all_agile_boards_exception(boards_mixin):
 67 |     """Test get_all_agile_boards method with exception."""
 68 |     boards_mixin.jira.get_all_agile_boards.side_effect = Exception("API Error")
 69 | 
 70 |     result = boards_mixin.get_all_agile_boards()
 71 |     assert result == []
 72 |     boards_mixin.jira.get_all_agile_boards.assert_called_once()
 73 | 
 74 | 
 75 | def test_get_all_agile_boards_http_error(boards_mixin):
 76 |     """Test get_all_agile_boards method with HTTPError."""
 77 |     boards_mixin.jira.get_all_agile_boards.side_effect = requests.HTTPError(
 78 |         response=MagicMock(content="API Error content")
 79 |     )
 80 | 
 81 |     result = boards_mixin.get_all_agile_boards()
 82 |     assert result == []
 83 |     boards_mixin.jira.get_all_agile_boards.assert_called_once()
 84 | 
 85 | 
 86 | def test_get_all_agile_boards_non_dict_response(boards_mixin):
 87 |     """Test get_all_agile_boards method with non-list response."""
 88 |     boards_mixin.jira.get_all_agile_boards.return_value = "not a dict"
 89 | 
 90 |     result = boards_mixin.get_all_agile_boards()
 91 |     assert result == []
 92 |     boards_mixin.jira.get_all_agile_boards.assert_called_once()
 93 | 
 94 | 
 95 | def test_get_all_agile_boards_model(boards_mixin, mock_boards):
 96 |     boards_mixin.jira.get_all_agile_boards.return_value = mock_boards
 97 | 
 98 |     result = boards_mixin.get_all_agile_boards_model()
 99 |     assert result == [
100 |         JiraBoard.from_api_response(value) for value in mock_boards["values"]
101 |     ]
102 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/search.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Jira search result models.
  3 | 
  4 | This module provides Pydantic models for Jira search (JQL) results.
  5 | """
  6 | 
  7 | import logging
  8 | from typing import Any
  9 | 
 10 | from pydantic import Field, model_validator
 11 | 
 12 | from ..base import ApiModel
 13 | from .issue import JiraIssue
 14 | 
 15 | logger = logging.getLogger(__name__)
 16 | 
 17 | 
 18 | class JiraSearchResult(ApiModel):
 19 |     """
 20 |     Model representing a Jira search (JQL) result.
 21 |     """
 22 | 
 23 |     total: int = 0
 24 |     start_at: int = 0
 25 |     max_results: int = 0
 26 |     issues: list[JiraIssue] = Field(default_factory=list)
 27 | 
 28 |     @classmethod
 29 |     def from_api_response(
 30 |         cls, data: dict[str, Any], **kwargs: Any
 31 |     ) -> "JiraSearchResult":
 32 |         """
 33 |         Create a JiraSearchResult from a Jira API response.
 34 | 
 35 |         Args:
 36 |             data: The search result data from the Jira API
 37 |             **kwargs: Additional arguments to pass to the constructor
 38 | 
 39 |         Returns:
 40 |             A JiraSearchResult instance
 41 |         """
 42 |         if not data:
 43 |             return cls()
 44 | 
 45 |         if not isinstance(data, dict):
 46 |             logger.debug("Received non-dictionary data, returning default instance")
 47 |             return cls()
 48 | 
 49 |         issues = []
 50 |         issues_data = data.get("issues", [])
 51 |         if isinstance(issues_data, list):
 52 |             for issue_data in issues_data:
 53 |                 if issue_data:
 54 |                     requested_fields = kwargs.get("requested_fields")
 55 |                     issues.append(
 56 |                         JiraIssue.from_api_response(
 57 |                             issue_data, requested_fields=requested_fields
 58 |                         )
 59 |                     )
 60 | 
 61 |         raw_total = data.get("total")
 62 |         raw_start_at = data.get("startAt")
 63 |         raw_max_results = data.get("maxResults")
 64 | 
 65 |         try:
 66 |             total = int(raw_total) if raw_total is not None else -1
 67 |         except (ValueError, TypeError):
 68 |             total = -1
 69 | 
 70 |         try:
 71 |             start_at = int(raw_start_at) if raw_start_at is not None else -1
 72 |         except (ValueError, TypeError):
 73 |             start_at = -1
 74 | 
 75 |         try:
 76 |             max_results = int(raw_max_results) if raw_max_results is not None else -1
 77 |         except (ValueError, TypeError):
 78 |             max_results = -1
 79 | 
 80 |         return cls(
 81 |             total=total,
 82 |             start_at=start_at,
 83 |             max_results=max_results,
 84 |             issues=issues,
 85 |         )
 86 | 
 87 |     @model_validator(mode="after")
 88 |     def validate_search_result(self) -> "JiraSearchResult":
 89 |         """
 90 |         Validate the search result.
 91 | 
 92 |         This validator ensures that pagination values are sensible and
 93 |         consistent with the number of issues returned.
 94 | 
 95 |         Returns:
 96 |             The validated JiraSearchResult instance
 97 |         """
 98 |         return self
 99 | 
100 |     def to_simplified_dict(self) -> dict[str, Any]:
101 |         """Convert to simplified dictionary for API response."""
102 |         return {
103 |             "total": self.total,
104 |             "start_at": self.start_at,
105 |             "max_results": self.max_results,
106 |             "issues": [issue.to_simplified_dict() for issue in self.issues],
107 |         }
108 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/preprocessing/confluence.py:
--------------------------------------------------------------------------------

```python
 1 | """Confluence-specific text preprocessing module."""
 2 | 
 3 | import logging
 4 | import shutil
 5 | import tempfile
 6 | from pathlib import Path
 7 | 
 8 | from md2conf.converter import (
 9 |     ConfluenceConverterOptions,
10 |     ConfluenceStorageFormatConverter,
11 |     elements_from_string,
12 |     elements_to_string,
13 |     markdown_to_html,
14 | )
15 | 
16 | from .base import BasePreprocessor
17 | 
18 | logger = logging.getLogger("mcp-atlassian")
19 | 
20 | 
21 | class ConfluencePreprocessor(BasePreprocessor):
22 |     """Handles text preprocessing for Confluence content."""
23 | 
24 |     def __init__(self, base_url: str) -> None:
25 |         """
26 |         Initialize the Confluence text preprocessor.
27 | 
28 |         Args:
29 |             base_url: Base URL for Confluence API
30 |         """
31 |         super().__init__(base_url=base_url)
32 | 
33 |     def markdown_to_confluence_storage(
34 |         self, markdown_content: str, *, enable_heading_anchors: bool = False
35 |     ) -> str:
36 |         """
37 |         Convert Markdown content to Confluence storage format (XHTML)
38 | 
39 |         Args:
40 |             markdown_content: Markdown text to convert
41 |             enable_heading_anchors: Whether to enable automatic heading anchor generation (default: False)
42 | 
43 |         Returns:
44 |             Confluence storage format (XHTML) string
45 |         """
46 |         try:
47 |             # First convert markdown to HTML
48 |             html_content = markdown_to_html(markdown_content)
49 | 
50 |             # Create a temporary directory for any potential attachments
51 |             temp_dir = tempfile.mkdtemp()
52 | 
53 |             try:
54 |                 # Parse the HTML into an element tree
55 |                 root = elements_from_string(html_content)
56 | 
57 |                 # Create converter options
58 |                 options = ConfluenceConverterOptions(
59 |                     ignore_invalid_url=True,
60 |                     heading_anchors=enable_heading_anchors,
61 |                     render_mermaid=False,
62 |                 )
63 | 
64 |                 # Create a converter
65 |                 converter = ConfluenceStorageFormatConverter(
66 |                     options=options,
67 |                     path=Path(temp_dir) / "temp.md",
68 |                     root_dir=Path(temp_dir),
69 |                     page_metadata={},
70 |                 )
71 | 
72 |                 # Transform the HTML to Confluence storage format
73 |                 converter.visit(root)
74 | 
75 |                 # Convert the element tree back to a string
76 |                 storage_format = elements_to_string(root)
77 | 
78 |                 return str(storage_format)
79 |             finally:
80 |                 # Clean up the temporary directory
81 |                 shutil.rmtree(temp_dir, ignore_errors=True)
82 | 
83 |         except Exception as e:
84 |             logger.error(f"Error converting markdown to Confluence storage format: {e}")
85 |             logger.exception(e)
86 | 
87 |             # Fall back to a simpler method if the conversion fails
88 |             html_content = markdown_to_html(markdown_content)
89 | 
90 |             # Use a different approach that doesn't rely on the HTML macro
91 |             # This creates a proper Confluence storage format document
92 |             storage_format = f"""<p>{html_content}</p>"""
93 | 
94 |             return str(storage_format)
95 | 
96 |     # Confluence-specific methods can be added here
97 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/project.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Jira project models.
  3 | 
  4 | This module provides Pydantic models for Jira projects.
  5 | """
  6 | 
  7 | import logging
  8 | from typing import Any
  9 | 
 10 | from ..base import ApiModel
 11 | from ..constants import (
 12 |     EMPTY_STRING,
 13 |     JIRA_DEFAULT_PROJECT,
 14 |     UNKNOWN,
 15 | )
 16 | from .common import JiraUser
 17 | 
 18 | logger = logging.getLogger(__name__)
 19 | 
 20 | 
 21 | class JiraProject(ApiModel):
 22 |     """
 23 |     Model representing a Jira project.
 24 | 
 25 |     This model contains the basic information about a Jira project,
 26 |     including its key, name, and category.
 27 |     """
 28 | 
 29 |     id: str = JIRA_DEFAULT_PROJECT
 30 |     key: str = EMPTY_STRING
 31 |     name: str = UNKNOWN
 32 |     description: str | None = None
 33 |     lead: JiraUser | None = None
 34 |     url: str | None = None
 35 |     category_name: str | None = None
 36 |     avatar_url: str | None = None
 37 | 
 38 |     @classmethod
 39 |     def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraProject":
 40 |         """
 41 |         Create a JiraProject from a Jira API response.
 42 | 
 43 |         Args:
 44 |             data: The project data from the Jira API
 45 | 
 46 |         Returns:
 47 |             A JiraProject instance
 48 |         """
 49 |         if not data:
 50 |             return cls()
 51 | 
 52 |         # Handle non-dictionary data by returning a default instance
 53 |         if not isinstance(data, dict):
 54 |             logger.debug("Received non-dictionary data, returning default instance")
 55 |             return cls()
 56 | 
 57 |         # Extract lead data if available
 58 |         lead = None
 59 |         lead_data = data.get("lead")
 60 |         if lead_data:
 61 |             lead = JiraUser.from_api_response(lead_data)
 62 | 
 63 |         # Get avatar URL from avatarUrls if available
 64 |         avatar_url = None
 65 |         if avatars := data.get("avatarUrls"):
 66 |             if isinstance(avatars, dict):
 67 |                 # Get the largest available avatar (48x48)
 68 |                 avatar_url = avatars.get("48x48")
 69 | 
 70 |         # Get project category name if available
 71 |         category_name = None
 72 |         if project_category := data.get("projectCategory"):
 73 |             if isinstance(project_category, dict):
 74 |                 category_name = project_category.get("name")
 75 | 
 76 |         # Ensure ID is a string
 77 |         project_id = data.get("id", JIRA_DEFAULT_PROJECT)
 78 |         if project_id is not None:
 79 |             project_id = str(project_id)
 80 | 
 81 |         return cls(
 82 |             id=project_id,
 83 |             key=str(data.get("key", EMPTY_STRING)),
 84 |             name=str(data.get("name", UNKNOWN)),
 85 |             description=data.get("description"),
 86 |             lead=lead,
 87 |             url=data.get("self"),  # API URL for the project
 88 |             category_name=category_name,
 89 |             avatar_url=avatar_url,
 90 |         )
 91 | 
 92 |     def to_simplified_dict(self) -> dict[str, Any]:
 93 |         """Convert to simplified dictionary for API response."""
 94 |         result = {
 95 |             "key": self.key,
 96 |             "name": self.name,
 97 |         }
 98 | 
 99 |         if self.description:
100 |             result["description"] = self.description
101 | 
102 |         if self.category_name:
103 |             result["category"] = self.category_name
104 | 
105 |         if self.avatar_url:
106 |             result["avatar_url"] = self.avatar_url
107 | 
108 |         if self.lead:
109 |             result["lead"] = self.lead.to_simplified_dict()
110 | 
111 |         return result
112 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/worklog.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Jira worklog models.
  3 | 
  4 | This module provides Pydantic models for Jira worklogs (time tracking entries).
  5 | """
  6 | 
  7 | import logging
  8 | from typing import Any
  9 | 
 10 | from ..base import ApiModel, TimestampMixin
 11 | from ..constants import (
 12 |     EMPTY_STRING,
 13 |     JIRA_DEFAULT_ID,
 14 | )
 15 | from .common import JiraUser
 16 | 
 17 | logger = logging.getLogger(__name__)
 18 | 
 19 | 
 20 | class JiraWorklog(ApiModel, TimestampMixin):
 21 |     """
 22 |     Model representing a Jira worklog entry.
 23 | 
 24 |     This model contains information about time spent on an issue,
 25 |     including the author, time spent, and related metadata.
 26 |     """
 27 | 
 28 |     id: str = JIRA_DEFAULT_ID
 29 |     author: JiraUser | None = None
 30 |     comment: str | None = None
 31 |     created: str = EMPTY_STRING
 32 |     updated: str = EMPTY_STRING
 33 |     started: str = EMPTY_STRING
 34 |     time_spent: str = EMPTY_STRING
 35 |     time_spent_seconds: int = 0
 36 | 
 37 |     @classmethod
 38 |     def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraWorklog":
 39 |         """
 40 |         Create a JiraWorklog from a Jira API response.
 41 | 
 42 |         Args:
 43 |             data: The worklog data from the Jira API
 44 | 
 45 |         Returns:
 46 |             A JiraWorklog instance
 47 |         """
 48 |         if not data:
 49 |             return cls()
 50 | 
 51 |         # Handle non-dictionary data by returning a default instance
 52 |         if not isinstance(data, dict):
 53 |             logger.debug("Received non-dictionary data, returning default instance")
 54 |             return cls()
 55 | 
 56 |         # Extract author data
 57 |         author = None
 58 |         author_data = data.get("author")
 59 |         if author_data:
 60 |             author = JiraUser.from_api_response(author_data)
 61 | 
 62 |         # Ensure ID is a string
 63 |         worklog_id = data.get("id", JIRA_DEFAULT_ID)
 64 |         if worklog_id is not None:
 65 |             worklog_id = str(worklog_id)
 66 | 
 67 |         # Parse time spent seconds with type safety
 68 |         time_spent_seconds = data.get("timeSpentSeconds", 0)
 69 |         try:
 70 |             time_spent_seconds = (
 71 |                 int(time_spent_seconds) if time_spent_seconds is not None else 0
 72 |             )
 73 |         except (ValueError, TypeError):
 74 |             time_spent_seconds = 0
 75 | 
 76 |         return cls(
 77 |             id=worklog_id,
 78 |             author=author,
 79 |             comment=data.get("comment"),
 80 |             created=str(data.get("created", EMPTY_STRING)),
 81 |             updated=str(data.get("updated", EMPTY_STRING)),
 82 |             started=str(data.get("started", EMPTY_STRING)),
 83 |             time_spent=str(data.get("timeSpent", EMPTY_STRING)),
 84 |             time_spent_seconds=time_spent_seconds,
 85 |         )
 86 | 
 87 |     def to_simplified_dict(self) -> dict[str, Any]:
 88 |         """Convert to simplified dictionary for API response."""
 89 |         result = {
 90 |             "time_spent": self.time_spent,
 91 |             "time_spent_seconds": self.time_spent_seconds,
 92 |         }
 93 | 
 94 |         if self.author:
 95 |             result["author"] = self.author.to_simplified_dict()
 96 | 
 97 |         if self.comment:
 98 |             result["comment"] = self.comment
 99 | 
100 |         if self.started:
101 |             result["started"] = self.started
102 | 
103 |         if self.created:
104 |             result["created"] = self.created
105 | 
106 |         if self.updated:
107 |             result["updated"] = self.updated
108 | 
109 |         return result
110 | 
```

--------------------------------------------------------------------------------
/tests/unit/utils/test_urls.py:
--------------------------------------------------------------------------------

```python
 1 | """Tests for the URL utilities module."""
 2 | 
 3 | from mcp_atlassian.utils.urls import is_atlassian_cloud_url
 4 | 
 5 | 
 6 | def test_is_atlassian_cloud_url_empty():
 7 |     """Test that is_atlassian_cloud_url returns False for empty URL."""
 8 |     assert is_atlassian_cloud_url("") is False
 9 |     assert is_atlassian_cloud_url(None) is False
10 | 
11 | 
12 | def test_is_atlassian_cloud_url_cloud():
13 |     """Test that is_atlassian_cloud_url returns True for Atlassian Cloud URLs."""
14 |     # Test standard Atlassian Cloud URLs
15 |     assert is_atlassian_cloud_url("https://example.atlassian.net") is True
16 |     assert is_atlassian_cloud_url("https://company.atlassian.net/wiki") is True
17 |     assert is_atlassian_cloud_url("https://subdomain.atlassian.net/jira") is True
18 |     assert is_atlassian_cloud_url("http://other.atlassian.net") is True
19 | 
20 |     # Test Jira Cloud specific domains
21 |     assert is_atlassian_cloud_url("https://company.jira.com") is True
22 |     assert is_atlassian_cloud_url("https://team.jira-dev.com") is True
23 | 
24 | 
25 | def test_is_atlassian_cloud_url_multi_cloud_oauth():
26 |     """Test that is_atlassian_cloud_url returns True for Multi-Cloud OAuth URLs."""
27 |     # Test api.atlassian.com URLs used by Multi-Cloud OAuth
28 |     assert (
29 |         is_atlassian_cloud_url("https://api.atlassian.com/ex/jira/abc123/rest/api/2/")
30 |         is True
31 |     )
32 |     assert (
33 |         is_atlassian_cloud_url("https://api.atlassian.com/ex/confluence/xyz789/")
34 |         is True
35 |     )
36 |     assert is_atlassian_cloud_url("http://api.atlassian.com/ex/jira/test/") is True
37 |     assert is_atlassian_cloud_url("https://api.atlassian.com") is True
38 | 
39 | 
40 | def test_is_atlassian_cloud_url_server():
41 |     """Test that is_atlassian_cloud_url returns False for Atlassian Server/Data Center URLs."""
42 |     # Test with various server/data center domains
43 |     assert is_atlassian_cloud_url("https://jira.example.com") is False
44 |     assert is_atlassian_cloud_url("https://confluence.company.org") is False
45 |     assert is_atlassian_cloud_url("https://jira.internal") is False
46 | 
47 | 
48 | def test_is_atlassian_cloud_url_localhost():
49 |     """Test that is_atlassian_cloud_url returns False for localhost URLs."""
50 |     # Test with localhost
51 |     assert is_atlassian_cloud_url("http://localhost") is False
52 |     assert is_atlassian_cloud_url("http://localhost:8080") is False
53 |     assert is_atlassian_cloud_url("https://localhost/jira") is False
54 | 
55 | 
56 | def test_is_atlassian_cloud_url_ip_addresses():
57 |     """Test that is_atlassian_cloud_url returns False for IP-based URLs."""
58 |     # Test with IP addresses
59 |     assert is_atlassian_cloud_url("http://127.0.0.1") is False
60 |     assert is_atlassian_cloud_url("http://127.0.0.1:8080") is False
61 |     assert is_atlassian_cloud_url("https://192.168.1.100") is False
62 |     assert is_atlassian_cloud_url("https://10.0.0.1") is False
63 |     assert is_atlassian_cloud_url("https://172.16.0.1") is False
64 |     assert is_atlassian_cloud_url("https://172.31.255.254") is False
65 | 
66 | 
67 | def test_is_atlassian_cloud_url_with_protocols():
68 |     """Test that is_atlassian_cloud_url works with different protocols."""
69 |     # Test with different protocols
70 |     assert is_atlassian_cloud_url("https://example.atlassian.net") is True
71 |     assert is_atlassian_cloud_url("http://example.atlassian.net") is True
72 |     assert (
73 |         is_atlassian_cloud_url("ftp://example.atlassian.net") is True
74 |     )  # URL parsing still works
75 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
  1 | [project]
  2 | name = "mcp-atlassian"
  3 | dynamic = ["version"]
  4 | description = "The Model Context Protocol (MCP) Atlassian integration is an open-source implementation that bridges Atlassian products (Jira and Confluence) with AI language models following Anthropic's MCP specification. This project enables secure, contextual AI interactions with Atlassian tools while maintaining data privacy and security. Key features include:"
  5 | readme = "README.md"
  6 | requires-python = ">=3.10"
  7 | dependencies = [
  8 |     "atlassian-python-api>=4.0.0",
  9 |     "requests[socks]>=2.31.0",
 10 |     "beautifulsoup4>=4.12.3",
 11 |     "httpx>=0.28.0",
 12 |     "mcp>=1.8.0,<2.0.0",
 13 |     "fastmcp>=2.3.4,<2.4.0",
 14 |     "python-dotenv>=1.0.1",
 15 |     "markdownify>=0.11.6",
 16 |     "markdown>=3.7.0",
 17 |     "markdown-to-confluence>=0.3.0,<0.4.0",
 18 |     "pydantic>=2.10.6",
 19 |     "trio>=0.29.0",
 20 |     "click>=8.1.7",
 21 |     "uvicorn>=0.27.1",
 22 |     "starlette>=0.37.1",
 23 |     "thefuzz>=0.22.1",
 24 |     "python-dateutil>=2.9.0.post0",
 25 |     "types-python-dateutil>=2.9.0.20241206",
 26 |     "keyring>=25.6.0",
 27 |     "cachetools>=5.0.0",
 28 |     "types-cachetools>=5.5.0.20240820",
 29 | ]
 30 | [[project.authors]]
 31 | name = "sooperset"
 32 | email = "[email protected]"
 33 | 
 34 | [build-system]
 35 | requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
 36 | build-backend = "hatchling.build"
 37 | 
 38 | [project.scripts]
 39 | mcp-atlassian = "mcp_atlassian:main"
 40 | 
 41 | [dependency-groups]
 42 | dev = [
 43 |     "uv>=0.1.0",
 44 |     "pytest>=8.0.0",
 45 |     "pytest-cov>=4.1.0",
 46 |     "pytest-asyncio>=0.23.0",
 47 |     "pre-commit>=3.6.0",
 48 |     "ruff>=0.3.0",
 49 |     "black>=24.2.0",
 50 |     "mypy>=1.8.0",
 51 |     "mcp[cli]>=1.3.0"
 52 | ]
 53 | 
 54 | [tool.ruff]
 55 | exclude = [
 56 |     ".bzr",
 57 |     ".direnv",
 58 |     ".eggs",
 59 |     ".git",
 60 |     ".git-rewrite",
 61 |     ".hg",
 62 |     ".mypy_cache",
 63 |     ".nox",
 64 |     ".pants.d",
 65 |     ".pytype",
 66 |     ".ruff_cache",
 67 |     ".svn",
 68 |     ".tox",
 69 |     ".venv",
 70 |     "__pypackages__",
 71 |     "_build",
 72 |     "buck-out",
 73 |     "build",
 74 |     "dist",
 75 |     "node_modules",
 76 |     "venv",
 77 | ]
 78 | line-length = 88
 79 | indent-width = 4
 80 | target-version = "py310"
 81 | 
 82 | [tool.ruff.lint]
 83 | select = ["E", "F", "B", "W", "I", "N", "UP", "ANN", "S", "BLE", "FBT", "C4", "DTZ", "T10", "EM", "ISC", "ICN"]
 84 | ignore = ["ANN401", "EM101"]
 85 | fixable = ["ALL"]
 86 | unfixable = []
 87 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
 88 | 
 89 | [tool.ruff.lint.per-file-ignores]
 90 | "tests/**/*.py" = ["S", "ANN", "B017"]
 91 | "tests/fixtures/*.py" = ["E501"]
 92 | "src/mcp_atlassian/server.py" = ["E501"]
 93 | 
 94 | [tool.ruff.format]
 95 | quote-style = "double"
 96 | indent-style = "space"
 97 | skip-magic-trailing-comma = false
 98 | line-ending = "auto"
 99 | 
100 | [tool.mypy]
101 | python_version = "3.10"
102 | warn_return_any = true
103 | warn_unused_configs = true
104 | disallow_untyped_defs = true
105 | disallow_incomplete_defs = true
106 | check_untyped_defs = true
107 | disallow_untyped_decorators = false
108 | no_implicit_optional = true
109 | warn_redundant_casts = true
110 | warn_unused_ignores = false
111 | warn_no_return = true
112 | warn_unreachable = true
113 | strict_equality = true
114 | strict_optional = true
115 | disallow_subclassing_any = true
116 | warn_incomplete_stub = true
117 | exclude = "^src/"
118 | explicit_package_bases = true
119 | 
120 | [[tool.mypy.overrides]]
121 | module = "tests.*"
122 | disallow_untyped_defs = false
123 | check_untyped_defs = false
124 | 
125 | [[tool.mypy.overrides]]
126 | module = "atlassian.*"
127 | ignore_missing_imports = true
128 | 
129 | [[tool.mypy.overrides]]
130 | module = "markdownify.*"
131 | ignore_missing_imports = true
132 | 
133 | [[tool.mypy.overrides]]
134 | module = "src.mcp_atlassian.*"
135 | disallow_untyped_defs = false
136 | 
137 | [tool.hatch.version]
138 | source = "uv-dynamic-versioning"
139 | 
140 | [tool.uv-dynamic-versioning]
141 | vcs = "git"
142 | style = "pep440"
143 | bump = true
144 | fallback-version = "0.0.0"
145 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/common.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Common Confluence entity models.
  3 | This module provides Pydantic models for common Confluence entities like users
  4 | and attachments.
  5 | """
  6 | 
  7 | import logging
  8 | import warnings
  9 | from typing import Any
 10 | 
 11 | from ..base import ApiModel
 12 | from ..constants import (
 13 |     UNASSIGNED,
 14 | )
 15 | 
 16 | logger = logging.getLogger(__name__)
 17 | 
 18 | 
 19 | class ConfluenceUser(ApiModel):
 20 |     """
 21 |     Model representing a Confluence user.
 22 |     """
 23 | 
 24 |     account_id: str | None = None
 25 |     display_name: str = UNASSIGNED
 26 |     email: str | None = None
 27 |     profile_picture: str | None = None
 28 |     is_active: bool = True
 29 |     locale: str | None = None
 30 | 
 31 |     @property
 32 |     def name(self) -> str:
 33 |         """
 34 |         Alias for display_name to maintain compatibility with tests.
 35 | 
 36 |         Deprecated: Use display_name instead.
 37 |         """
 38 |         warnings.warn(
 39 |             "The 'name' property is deprecated. Use 'display_name' instead.",
 40 |             DeprecationWarning,
 41 |             stacklevel=2,
 42 |         )
 43 |         return self.display_name
 44 | 
 45 |     @classmethod
 46 |     def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "ConfluenceUser":
 47 |         """
 48 |         Create a ConfluenceUser from a Confluence API response.
 49 | 
 50 |         Args:
 51 |             data: The user data from the Confluence API
 52 | 
 53 |         Returns:
 54 |             A ConfluenceUser instance
 55 |         """
 56 |         if not data:
 57 |             return cls()
 58 | 
 59 |         profile_pic = None
 60 |         if pic_data := data.get("profilePicture"):
 61 |             # Use the full path to the profile picture
 62 |             profile_pic = pic_data.get("path")
 63 | 
 64 |         return cls(
 65 |             account_id=data.get("accountId"),
 66 |             display_name=data.get("displayName", UNASSIGNED),
 67 |             email=data.get("email"),
 68 |             profile_picture=profile_pic,
 69 |             is_active=data.get("accountStatus") == "active",
 70 |             locale=data.get("locale"),
 71 |         )
 72 | 
 73 |     def to_simplified_dict(self) -> dict[str, Any]:
 74 |         """Convert to simplified dictionary for API response."""
 75 |         return {
 76 |             "display_name": self.display_name,
 77 |             "email": self.email,
 78 |             "profile_picture": self.profile_picture,
 79 |         }
 80 | 
 81 | 
 82 | class ConfluenceAttachment(ApiModel):
 83 |     """
 84 |     Model representing a Confluence attachment.
 85 |     """
 86 | 
 87 |     id: str | None = None
 88 |     type: str | None = None
 89 |     status: str | None = None
 90 |     title: str | None = None
 91 |     media_type: str | None = None
 92 |     file_size: int | None = None
 93 | 
 94 |     @classmethod
 95 |     def from_api_response(
 96 |         cls, data: dict[str, Any], **kwargs: Any
 97 |     ) -> "ConfluenceAttachment":
 98 |         """
 99 |         Create a ConfluenceAttachment from a Confluence API response.
100 | 
101 |         Args:
102 |             data: The attachment data from the Confluence API
103 | 
104 |         Returns:
105 |             A ConfluenceAttachment instance
106 |         """
107 |         if not data:
108 |             return cls()
109 | 
110 |         return cls(
111 |             id=data.get("id"),
112 |             type=data.get("type"),
113 |             status=data.get("status"),
114 |             title=data.get("title"),
115 |             media_type=data.get("extensions", {}).get("mediaType"),
116 |             file_size=data.get("extensions", {}).get("fileSize"),
117 |         )
118 | 
119 |     def to_simplified_dict(self) -> dict[str, Any]:
120 |         """Convert to simplified dictionary for API response."""
121 |         return {
122 |             "id": self.id,
123 |             "type": self.type,
124 |             "status": self.status,
125 |             "title": self.title,
126 |             "media_type": self.media_type,
127 |             "file_size": self.file_size,
128 |         }
129 | 
```

--------------------------------------------------------------------------------
/tests/unit/models/test_base_models.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Tests for the base models and utility classes.
  3 | """
  4 | 
  5 | from typing import Any
  6 | 
  7 | import pytest
  8 | 
  9 | from src.mcp_atlassian.models.base import ApiModel, TimestampMixin
 10 | from src.mcp_atlassian.models.constants import EMPTY_STRING
 11 | 
 12 | 
 13 | class TestApiModel:
 14 |     """Tests for the ApiModel base class."""
 15 | 
 16 |     def test_base_from_api_response_not_implemented(self):
 17 |         """Test that from_api_response raises NotImplementedError if not overridden."""
 18 |         with pytest.raises(NotImplementedError):
 19 |             ApiModel.from_api_response({})
 20 | 
 21 |     def test_base_to_simplified_dict(self):
 22 |         """Test that to_simplified_dict returns a dictionary with non-None values."""
 23 | 
 24 |         # Create a test subclass with some fields
 25 |         class TestModel(ApiModel):
 26 |             field1: str = "test"
 27 |             field2: int = 123
 28 |             field3: str = None
 29 | 
 30 |             @classmethod
 31 |             def from_api_response(cls, data: dict[str, Any], **kwargs):
 32 |                 return cls()
 33 | 
 34 |         model = TestModel()
 35 |         result = model.to_simplified_dict()
 36 | 
 37 |         assert isinstance(result, dict)
 38 |         assert "field1" in result
 39 |         assert "field2" in result
 40 |         assert "field3" not in result  # None values should be excluded
 41 |         assert result["field1"] == "test"
 42 |         assert result["field2"] == 123
 43 | 
 44 | 
 45 | class TestTimestampMixin:
 46 |     """Tests for the TimestampMixin utility class."""
 47 | 
 48 |     def test_format_timestamp_valid(self):
 49 |         """Test formatting a valid ISO 8601 timestamp."""
 50 |         timestamp = "2024-01-01T12:34:56.789+0000"
 51 |         formatter = TimestampMixin()
 52 | 
 53 |         result = formatter.format_timestamp(timestamp)
 54 | 
 55 |         assert result == "2024-01-01 12:34:56"
 56 | 
 57 |     def test_format_timestamp_with_z(self):
 58 |         """Test formatting a timestamp with Z (UTC) timezone."""
 59 |         timestamp = "2024-01-01T12:34:56.789Z"
 60 |         formatter = TimestampMixin()
 61 | 
 62 |         result = formatter.format_timestamp(timestamp)
 63 | 
 64 |         assert result == "2024-01-01 12:34:56"
 65 | 
 66 |     def test_format_timestamp_none(self):
 67 |         """Test formatting a None timestamp."""
 68 |         formatter = TimestampMixin()
 69 | 
 70 |         result = formatter.format_timestamp(None)
 71 | 
 72 |         assert result == EMPTY_STRING
 73 | 
 74 |     def test_format_timestamp_invalid(self):
 75 |         """Test formatting an invalid timestamp string."""
 76 |         invalid_timestamp = "not-a-timestamp"
 77 |         formatter = TimestampMixin()
 78 | 
 79 |         result = formatter.format_timestamp(invalid_timestamp)
 80 | 
 81 |         assert result == invalid_timestamp  # Should return the original string
 82 | 
 83 |     def test_is_valid_timestamp_valid(self):
 84 |         """Test validating a valid ISO 8601 timestamp."""
 85 |         timestamp = "2024-01-01T12:34:56.789+0000"
 86 |         formatter = TimestampMixin()
 87 | 
 88 |         assert formatter.is_valid_timestamp(timestamp) is True
 89 | 
 90 |     def test_is_valid_timestamp_with_z(self):
 91 |         """Test validating a timestamp with Z (UTC) timezone."""
 92 |         timestamp = "2024-01-01T12:34:56.789Z"
 93 |         formatter = TimestampMixin()
 94 | 
 95 |         assert formatter.is_valid_timestamp(timestamp) is True
 96 | 
 97 |     def test_is_valid_timestamp_none(self):
 98 |         """Test validating a None timestamp."""
 99 |         formatter = TimestampMixin()
100 | 
101 |         assert formatter.is_valid_timestamp(None) is False
102 | 
103 |     def test_is_valid_timestamp_invalid(self):
104 |         """Test validating an invalid timestamp string."""
105 |         invalid_timestamp = "not-a-timestamp"
106 |         formatter = TimestampMixin()
107 | 
108 |         assert formatter.is_valid_timestamp(invalid_timestamp) is False
109 | 
```

--------------------------------------------------------------------------------
/tests/utils/assertions.py:
--------------------------------------------------------------------------------

```python
 1 | """Custom assertions and helpers for MCP Atlassian tests."""
 2 | 
 3 | from typing import Any
 4 | from unittest.mock import MagicMock
 5 | 
 6 | 
 7 | def assert_api_called_with(mock: MagicMock, method: str, **expected_kwargs) -> None:
 8 |     """Assert API method was called with expected parameters."""
 9 |     mock.assert_called_once()
10 |     actual_kwargs = mock.call_args.kwargs if mock.call_args else {}
11 | 
12 |     for key, expected_value in expected_kwargs.items():
13 |         assert key in actual_kwargs, f"Expected parameter '{key}' not found in call"
14 |         assert actual_kwargs[key] == expected_value, (
15 |             f"Parameter '{key}': expected {expected_value}, got {actual_kwargs[key]}"
16 |         )
17 | 
18 | 
19 | def assert_mock_called_with_partial(mock: MagicMock, **partial_kwargs) -> None:
20 |     """Assert mock was called with at least the specified kwargs."""
21 |     assert mock.called, "Mock was not called"
22 | 
23 |     if mock.call_args is None:
24 |         raise AssertionError("Mock was called but call_args is None")
25 | 
26 |     actual_kwargs = mock.call_args.kwargs
27 |     for key, expected_value in partial_kwargs.items():
28 |         assert key in actual_kwargs, f"Expected parameter '{key}' not found"
29 |         assert actual_kwargs[key] == expected_value, (
30 |             f"Parameter '{key}': expected {expected_value}, got {actual_kwargs[key]}"
31 |         )
32 | 
33 | 
34 | def assert_environment_vars_set(env_dict: dict[str, str], **expected_vars) -> None:
35 |     """Assert environment variables are set to expected values."""
36 |     for var_name, expected_value in expected_vars.items():
37 |         assert var_name in env_dict, f"Environment variable '{var_name}' not set"
38 |         assert env_dict[var_name] == expected_value, (
39 |             f"Environment variable '{var_name}': expected '{expected_value}', "
40 |             f"got '{env_dict[var_name]}'"
41 |         )
42 | 
43 | 
44 | def assert_config_contains(config: dict[str, Any], **expected_config) -> None:
45 |     """Assert configuration contains expected key-value pairs."""
46 |     for key, expected_value in expected_config.items():
47 |         assert key in config, f"Configuration key '{key}' not found"
48 |         assert config[key] == expected_value, (
49 |             f"Configuration '{key}': expected {expected_value}, got {config[key]}"
50 |         )
51 | 
52 | 
53 | def assert_exception_chain(
54 |     exception: Exception, expected_cause: type | None = None
55 | ) -> None:
56 |     """Assert exception has expected cause in chain."""
57 |     if expected_cause is None:
58 |         assert exception.__cause__ is None, "Expected no exception cause"
59 |     else:
60 |         assert exception.__cause__ is not None, (
61 |             "Expected exception cause but found none"
62 |         )
63 |         assert isinstance(exception.__cause__, expected_cause), (
64 |             f"Expected cause type {expected_cause}, got {type(exception.__cause__)}"
65 |         )
66 | 
67 | 
68 | def assert_log_contains(caplog, level: str, message: str) -> None:
69 |     """Assert log contains message at specified level."""
70 |     records = [r for r in caplog.records if r.levelname == level.upper()]
71 |     messages = [r.message for r in records]
72 | 
73 |     assert any(message in msg for msg in messages), (
74 |         f"Expected log message containing '{message}' at level {level}, "
75 |         f"got messages: {messages}"
76 |     )
77 | 
78 | 
79 | def assert_dict_subset(subset: dict[str, Any], full_dict: dict[str, Any]) -> None:
80 |     """Assert that subset is contained within full_dict."""
81 |     for key, value in subset.items():
82 |         assert key in full_dict, f"Key '{key}' not found in dictionary"
83 |         if isinstance(value, dict) and isinstance(full_dict[key], dict):
84 |             assert_dict_subset(value, full_dict[key])
85 |         else:
86 |             assert full_dict[key] == value, (
87 |                 f"Key '{key}': expected {value}, got {full_dict[key]}"
88 |             )
89 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/logging.py:
--------------------------------------------------------------------------------

```python
  1 | """Logging utilities for MCP Atlassian.
  2 | 
  3 | This module provides enhanced logging capabilities for MCP Atlassian,
  4 | including level-dependent stream handling to route logs to the appropriate
  5 | output stream based on their level.
  6 | """
  7 | 
  8 | import logging
  9 | import sys
 10 | from typing import TextIO
 11 | 
 12 | 
 13 | def setup_logging(
 14 |     level: int = logging.WARNING, stream: TextIO = sys.stderr
 15 | ) -> logging.Logger:
 16 |     """
 17 |     Configure MCP-Atlassian logging with level-based stream routing.
 18 | 
 19 |     Args:
 20 |         level: The minimum logging level to display (default: WARNING)
 21 |         stream: The stream to write logs to (default: sys.stderr)
 22 | 
 23 |     Returns:
 24 |         The configured logger instance
 25 |     """
 26 |     # Configure root logger
 27 |     root_logger = logging.getLogger()
 28 |     root_logger.setLevel(level)
 29 | 
 30 |     # Remove existing handlers to prevent duplication
 31 |     for handler in root_logger.handlers[:]:
 32 |         root_logger.removeHandler(handler)
 33 | 
 34 |     # Add the level-dependent handler
 35 |     handler = logging.StreamHandler(stream)
 36 |     formatter = logging.Formatter("%(levelname)s - %(name)s - %(message)s")
 37 |     handler.setFormatter(formatter)
 38 |     root_logger.addHandler(handler)
 39 | 
 40 |     # Configure specific loggers
 41 |     loggers = ["mcp-atlassian", "mcp.server", "mcp.server.lowlevel.server", "mcp-jira"]
 42 | 
 43 |     for logger_name in loggers:
 44 |         logger = logging.getLogger(logger_name)
 45 |         logger.setLevel(level)
 46 | 
 47 |     # Return the application logger
 48 |     return logging.getLogger("mcp-atlassian")
 49 | 
 50 | 
 51 | def mask_sensitive(value: str | None, keep_chars: int = 4) -> str:
 52 |     """Masks sensitive strings for logging.
 53 | 
 54 |     Args:
 55 |         value: The string to mask
 56 |         keep_chars: Number of characters to keep visible at start and end
 57 | 
 58 |     Returns:
 59 |         Masked string with most characters replaced by asterisks
 60 |     """
 61 |     if not value:
 62 |         return "Not Provided"
 63 |     if len(value) <= keep_chars * 2:
 64 |         return "*" * len(value)
 65 |     start = value[:keep_chars]
 66 |     end = value[-keep_chars:]
 67 |     middle = "*" * (len(value) - keep_chars * 2)
 68 |     return f"{start}{middle}{end}"
 69 | 
 70 | 
 71 | def get_masked_session_headers(headers: dict[str, str]) -> dict[str, str]:
 72 |     """Get session headers with sensitive values masked for safe logging.
 73 | 
 74 |     Args:
 75 |         headers: Dictionary of HTTP headers
 76 | 
 77 |     Returns:
 78 |         Dictionary with sensitive headers masked
 79 |     """
 80 |     sensitive_headers = {"Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization"}
 81 |     masked_headers = {}
 82 | 
 83 |     for key, value in headers.items():
 84 |         if key in sensitive_headers:
 85 |             if key == "Authorization":
 86 |                 # Preserve auth type but mask the credentials
 87 |                 if value.startswith("Basic "):
 88 |                     masked_headers[key] = f"Basic {mask_sensitive(value[6:])}"
 89 |                 elif value.startswith("Bearer "):
 90 |                     masked_headers[key] = f"Bearer {mask_sensitive(value[7:])}"
 91 |                 else:
 92 |                     masked_headers[key] = mask_sensitive(value)
 93 |             else:
 94 |                 masked_headers[key] = mask_sensitive(value)
 95 |         else:
 96 |             masked_headers[key] = str(value)
 97 | 
 98 |     return masked_headers
 99 | 
100 | 
101 | def log_config_param(
102 |     logger: logging.Logger,
103 |     service: str,
104 |     param: str,
105 |     value: str | None,
106 |     sensitive: bool = False,
107 | ) -> None:
108 |     """Logs a configuration parameter, masking if sensitive.
109 | 
110 |     Args:
111 |         logger: The logger to use
112 |         service: The service name (Jira or Confluence)
113 |         param: The parameter name
114 |         value: The parameter value
115 |         sensitive: Whether the value should be masked
116 |     """
117 |     display_value = mask_sensitive(value) if sensitive else (value or "Not Provided")
118 |     logger.info(f"{service} {param}: {display_value}")
119 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/ssl.py:
--------------------------------------------------------------------------------

```python
 1 | """SSL-related utility functions for MCP Atlassian."""
 2 | 
 3 | import logging
 4 | import ssl
 5 | from typing import Any
 6 | from urllib.parse import urlparse
 7 | 
 8 | from requests.adapters import HTTPAdapter
 9 | from requests.sessions import Session
10 | from urllib3.poolmanager import PoolManager
11 | 
12 | logger = logging.getLogger("mcp-atlassian")
13 | 
14 | 
15 | class SSLIgnoreAdapter(HTTPAdapter):
16 |     """HTTP adapter that ignores SSL verification.
17 | 
18 |     A custom transport adapter that disables SSL certificate verification for specific domains.
19 |     This implementation ensures that both verify_mode is set to CERT_NONE and check_hostname
20 |     is disabled, which is required for properly ignoring SSL certificates.
21 | 
22 |     This adapter also enables legacy SSL renegotiation which may be required for some older servers.
23 |     Note that this reduces security and should only be used when absolutely necessary.
24 |     """
25 | 
26 |     def init_poolmanager(
27 |         self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any
28 |     ) -> None:
29 |         """Initialize the connection pool manager with SSL verification disabled.
30 | 
31 |         This method is called when the adapter is created, and it's the proper place to
32 |         disable SSL verification completely.
33 | 
34 |         Args:
35 |             connections: Number of connections to save in the pool
36 |             maxsize: Maximum number of connections in the pool
37 |             block: Whether to block when the pool is full
38 |             pool_kwargs: Additional arguments for the pool manager
39 |         """
40 |         # Configure SSL context to disable verification completely
41 |         context = ssl.create_default_context()
42 |         context.check_hostname = False
43 |         context.verify_mode = ssl.CERT_NONE
44 | 
45 |         # Enable legacy SSL renegotiation
46 |         context.options |= 0x4  # SSL_OP_LEGACY_SERVER_CONNECT
47 |         context.options |= 0x40000  # SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
48 | 
49 |         self.poolmanager = PoolManager(
50 |             num_pools=connections,
51 |             maxsize=maxsize,
52 |             block=block,
53 |             ssl_context=context,
54 |             **pool_kwargs,
55 |         )
56 | 
57 |     def cert_verify(self, conn: Any, url: str, verify: bool, cert: Any | None) -> None:
58 |         """Override cert verification to disable SSL verification.
59 | 
60 |         This method is still included for backward compatibility, but the main
61 |         SSL disabling happens in init_poolmanager.
62 | 
63 |         Args:
64 |             conn: The connection
65 |             url: The URL being requested
66 |             verify: The original verify parameter (ignored)
67 |             cert: Client certificate path
68 |         """
69 |         super().cert_verify(conn, url, verify=False, cert=cert)
70 | 
71 | 
72 | def configure_ssl_verification(
73 |     service_name: str, url: str, session: Session, ssl_verify: bool
74 | ) -> None:
75 |     """Configure SSL verification for a specific service.
76 | 
77 |     If SSL verification is disabled, this function will configure the session
78 |     to use a custom SSL adapter that bypasses certificate validation for the
79 |     service's domain.
80 | 
81 |     Args:
82 |         service_name: Name of the service for logging (e.g., "Confluence", "Jira")
83 |         url: The base URL of the service
84 |         session: The requests session to configure
85 |         ssl_verify: Whether SSL verification should be enabled
86 |     """
87 |     if not ssl_verify:
88 |         logger.warning(
89 |             f"{service_name} SSL verification disabled. This is insecure and should only be used in testing environments."
90 |         )
91 | 
92 |         # Get the domain from the configured URL
93 |         domain = urlparse(url).netloc
94 | 
95 |         # Mount the adapter to handle requests to this domain
96 |         adapter = SSLIgnoreAdapter()
97 |         session.mount(f"https://{domain}", adapter)
98 |         session.mount(f"http://{domain}", adapter)
99 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/jira/comments.py:
--------------------------------------------------------------------------------

```python
  1 | """Module for Jira comment operations."""
  2 | 
  3 | import logging
  4 | from typing import Any
  5 | 
  6 | from ..utils import parse_date
  7 | from .client import JiraClient
  8 | 
  9 | logger = logging.getLogger("mcp-jira")
 10 | 
 11 | 
 12 | class CommentsMixin(JiraClient):
 13 |     """Mixin for Jira comment operations."""
 14 | 
 15 |     def get_issue_comments(
 16 |         self, issue_key: str, limit: int = 50
 17 |     ) -> list[dict[str, Any]]:
 18 |         """
 19 |         Get comments for a specific issue.
 20 | 
 21 |         Args:
 22 |             issue_key: The issue key (e.g. 'PROJ-123')
 23 |             limit: Maximum number of comments to return
 24 | 
 25 |         Returns:
 26 |             List of comments with author, creation date, and content
 27 | 
 28 |         Raises:
 29 |             Exception: If there is an error getting comments
 30 |         """
 31 |         try:
 32 |             comments = self.jira.issue_get_comments(issue_key)
 33 | 
 34 |             if not isinstance(comments, dict):
 35 |                 msg = f"Unexpected return value type from `jira.issue_get_comments`: {type(comments)}"
 36 |                 logger.error(msg)
 37 |                 raise TypeError(msg)
 38 | 
 39 |             processed_comments = []
 40 |             for comment in comments.get("comments", [])[:limit]:
 41 |                 processed_comment = {
 42 |                     "id": comment.get("id"),
 43 |                     "body": self._clean_text(comment.get("body", "")),
 44 |                     "created": str(parse_date(comment.get("created"))),
 45 |                     "updated": str(parse_date(comment.get("updated"))),
 46 |                     "author": comment.get("author", {}).get("displayName", "Unknown"),
 47 |                 }
 48 |                 processed_comments.append(processed_comment)
 49 | 
 50 |             return processed_comments
 51 |         except Exception as e:
 52 |             logger.error(f"Error getting comments for issue {issue_key}: {str(e)}")
 53 |             raise Exception(f"Error getting comments: {str(e)}") from e
 54 | 
 55 |     def add_comment(self, issue_key: str, comment: str) -> dict[str, Any]:
 56 |         """
 57 |         Add a comment to an issue.
 58 | 
 59 |         Args:
 60 |             issue_key: The issue key (e.g. 'PROJ-123')
 61 |             comment: Comment text to add (in Markdown format)
 62 | 
 63 |         Returns:
 64 |             The created comment details
 65 | 
 66 |         Raises:
 67 |             Exception: If there is an error adding the comment
 68 |         """
 69 |         try:
 70 |             # Convert Markdown to Jira's markup format
 71 |             jira_formatted_comment = self._markdown_to_jira(comment)
 72 | 
 73 |             result = self.jira.issue_add_comment(issue_key, jira_formatted_comment)
 74 |             if not isinstance(result, dict):
 75 |                 msg = f"Unexpected return value type from `jira.issue_add_comment`: {type(result)}"
 76 |                 logger.error(msg)
 77 |                 raise TypeError(msg)
 78 | 
 79 |             return {
 80 |                 "id": result.get("id"),
 81 |                 "body": self._clean_text(result.get("body", "")),
 82 |                 "created": str(parse_date(result.get("created"))),
 83 |                 "author": result.get("author", {}).get("displayName", "Unknown"),
 84 |             }
 85 |         except Exception as e:
 86 |             logger.error(f"Error adding comment to issue {issue_key}: {str(e)}")
 87 |             raise Exception(f"Error adding comment: {str(e)}") from e
 88 | 
 89 |     def _markdown_to_jira(self, markdown_text: str) -> str:
 90 |         """
 91 |         Convert Markdown syntax to Jira markup syntax.
 92 | 
 93 |         This method uses the TextPreprocessor implementation for consistent
 94 |         conversion between Markdown and Jira markup.
 95 | 
 96 |         Args:
 97 |             markdown_text: Text in Markdown format
 98 | 
 99 |         Returns:
100 |             Text in Jira markup format
101 |         """
102 |         if not markdown_text:
103 |             return ""
104 | 
105 |         # Use the existing preprocessor
106 |         try:
107 |             return self.preprocessor.markdown_to_jira(markdown_text)
108 |         except Exception as e:
109 |             logger.warning(f"Error converting markdown to Jira format: {str(e)}")
110 |             # Return the original text if conversion fails
111 |             return markdown_text
112 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/users.py:
--------------------------------------------------------------------------------

```python
  1 | """Module for Confluence user operations."""
  2 | 
  3 | import logging
  4 | from typing import Any
  5 | 
  6 | from requests.exceptions import HTTPError
  7 | 
  8 | from ..exceptions import MCPAtlassianAuthenticationError
  9 | from .client import ConfluenceClient
 10 | 
 11 | logger = logging.getLogger("mcp-atlassian")
 12 | 
 13 | 
 14 | class UsersMixin(ConfluenceClient):
 15 |     """Mixin for Confluence user operations."""
 16 | 
 17 |     def get_user_details_by_accountid(
 18 |         self, account_id: str, expand: str = None
 19 |     ) -> dict[str, Any]:
 20 |         """Get user details by account ID.
 21 | 
 22 |         Args:
 23 |             account_id: The account ID of the user.
 24 |             expand: Optional expand for get status of user. Possible param is "status". Results are "Active, Deactivated".
 25 | 
 26 |         Returns:
 27 |             User details as a dictionary.
 28 | 
 29 |         Raises:
 30 |             Various exceptions from the Atlassian API if user doesn't exist or if there are permission issues.
 31 |         """
 32 |         return self.confluence.get_user_details_by_accountid(account_id, expand)
 33 | 
 34 |     def get_user_details_by_username(
 35 |         self, username: str, expand: str = None
 36 |     ) -> dict[str, Any]:
 37 |         """Get user details by username.
 38 | 
 39 |         This is typically used for Confluence Server/DC instances where username
 40 |         might be used as an identifier.
 41 | 
 42 |         Args:
 43 |             username: The username of the user.
 44 |             expand: Optional expand for get status of user. Possible param is "status". Results are "Active, Deactivated".
 45 | 
 46 |         Returns:
 47 |             User details as a dictionary.
 48 | 
 49 |         Raises:
 50 |             Various exceptions from the Atlassian API if user doesn't exist or if there are permission issues.
 51 |         """
 52 |         return self.confluence.get_user_details_by_username(username, expand)
 53 | 
 54 |     def get_current_user_info(self) -> dict[str, Any]:
 55 |         """
 56 |         Retrieve details for the currently authenticated user by calling Confluence's '/rest/api/user/current' endpoint.
 57 | 
 58 |         Returns:
 59 |             dict[str, Any]: The user details as returned by the API.
 60 | 
 61 |         Raises:
 62 |             MCPAtlassianAuthenticationError: If authentication fails or the response is not valid user data.
 63 |         """
 64 |         try:
 65 |             user_data = self.confluence.get("rest/api/user/current")
 66 |             if not isinstance(user_data, dict):
 67 |                 logger.error(
 68 |                     f"Confluence /rest/api/user/current endpoint returned non-dict data type: {type(user_data)}. "
 69 |                     f"Response text (partial): {str(user_data)[:500]}"
 70 |                 )
 71 |                 raise MCPAtlassianAuthenticationError(
 72 |                     "Confluence token validation failed: Did not receive valid JSON user data from /rest/api/user/current endpoint."
 73 |                 )
 74 |             return user_data
 75 |         except HTTPError as http_err:
 76 |             if http_err.response is not None and http_err.response.status_code in [
 77 |                 401,
 78 |                 403,
 79 |             ]:
 80 |                 logger.warning(
 81 |                     f"Confluence token validation failed with HTTP {http_err.response.status_code} for /rest/api/user/current."
 82 |                 )
 83 |                 raise MCPAtlassianAuthenticationError(
 84 |                     f"Confluence token validation failed: {http_err.response.status_code} from /rest/api/user/current"
 85 |                 ) from http_err
 86 |             logger.error(
 87 |                 f"HTTPError when calling Confluence /rest/api/user/current: {http_err}",
 88 |                 exc_info=True,
 89 |             )
 90 |             raise MCPAtlassianAuthenticationError(
 91 |                 f"Confluence token validation failed with HTTPError: {http_err}"
 92 |             ) from http_err
 93 |         except Exception as e:
 94 |             logger.error(
 95 |                 f"Unexpected error fetching current Confluence user details: {e}",
 96 |                 exc_info=True,
 97 |             )
 98 |             raise MCPAtlassianAuthenticationError(
 99 |                 f"Confluence token validation failed: {e}"
100 |             ) from e
101 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/spaces.py:
--------------------------------------------------------------------------------

```python
  1 | """Module for Confluence space operations."""
  2 | 
  3 | import logging
  4 | from typing import cast
  5 | 
  6 | import requests
  7 | 
  8 | from .client import ConfluenceClient
  9 | 
 10 | logger = logging.getLogger("mcp-atlassian")
 11 | 
 12 | 
 13 | class SpacesMixin(ConfluenceClient):
 14 |     """Mixin for Confluence space operations."""
 15 | 
 16 |     def get_spaces(self, start: int = 0, limit: int = 10) -> dict[str, object]:
 17 |         """
 18 |         Get all available spaces.
 19 | 
 20 |         Args:
 21 |             start: The starting index for pagination
 22 |             limit: Maximum number of spaces to return
 23 | 
 24 |         Returns:
 25 |             Dictionary containing space information with results and metadata
 26 |         """
 27 |         spaces = self.confluence.get_all_spaces(start=start, limit=limit)
 28 |         # Cast the return value to the expected type
 29 |         return cast(dict[str, object], spaces)
 30 | 
 31 |     def get_user_contributed_spaces(self, limit: int = 250) -> dict:
 32 |         """
 33 |         Get spaces the current user has contributed to.
 34 | 
 35 |         Args:
 36 |             limit: Maximum number of results to return
 37 | 
 38 |         Returns:
 39 |             Dictionary of space keys to space information
 40 |         """
 41 |         try:
 42 |             # Use CQL to find content the user has contributed to
 43 |             cql = "contributor = currentUser() order by lastmodified DESC"
 44 |             results = self.confluence.cql(cql=cql, limit=limit)
 45 | 
 46 |             # Extract and deduplicate spaces
 47 |             spaces = {}
 48 |             for result in results.get("results", []):
 49 |                 space_key = None
 50 |                 space_name = None
 51 | 
 52 |                 # Try to extract space from container
 53 |                 if "resultGlobalContainer" in result:
 54 |                     container = result.get("resultGlobalContainer", {})
 55 |                     space_name = container.get("title")
 56 |                     display_url = container.get("displayUrl", "")
 57 |                     if display_url and "/spaces/" in display_url:
 58 |                         space_key = display_url.split("/spaces/")[1].split("/")[0]
 59 | 
 60 |                 # Try to extract from content expandable
 61 |                 if (
 62 |                     not space_key
 63 |                     and "content" in result
 64 |                     and "_expandable" in result["content"]
 65 |                 ):
 66 |                     expandable = result["content"].get("_expandable", {})
 67 |                     space_path = expandable.get("space", "")
 68 |                     if space_path and space_path.startswith("/rest/api/space/"):
 69 |                         space_key = space_path.split("/rest/api/space/")[1]
 70 | 
 71 |                 # Try to extract from URL
 72 |                 if not space_key and "url" in result:
 73 |                     url = result.get("url", "")
 74 |                     if url and url.startswith("/spaces/"):
 75 |                         space_key = url.split("/spaces/")[1].split("/")[0]
 76 | 
 77 |                 # Only add if we found a space key and it's not already in our results
 78 |                 if space_key and space_key not in spaces:
 79 |                     # Add some defaults if we couldn't extract all fields
 80 |                     space_name = space_name or f"Space {space_key}"
 81 |                     spaces[space_key] = {"key": space_key, "name": space_name}
 82 | 
 83 |             return spaces
 84 | 
 85 |         except KeyError as e:
 86 |             logger.error(f"Missing key in Confluence spaces data: {str(e)}")
 87 |             return {}
 88 |         except ValueError as e:
 89 |             logger.error(f"Invalid value in Confluence spaces: {str(e)}")
 90 |             return {}
 91 |         except TypeError as e:
 92 |             logger.error(f"Type error when processing Confluence spaces: {str(e)}")
 93 |             return {}
 94 |         except requests.RequestException as e:
 95 |             logger.error(f"Network error when fetching spaces: {str(e)}")
 96 |             return {}
 97 |         except Exception as e:  # noqa: BLE001 - Intentional fallback with logging
 98 |             logger.error(f"Unexpected error fetching Confluence spaces: {str(e)}")
 99 |             logger.debug("Full exception details for Confluence spaces:", exc_info=True)
100 |             return {}
101 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/decorators.py:
--------------------------------------------------------------------------------

```python
  1 | import logging
  2 | from collections.abc import Awaitable, Callable
  3 | from functools import wraps
  4 | from typing import Any, TypeVar
  5 | 
  6 | import requests
  7 | from fastmcp import Context
  8 | from requests.exceptions import HTTPError
  9 | 
 10 | from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
 11 | 
 12 | logger = logging.getLogger(__name__)
 13 | 
 14 | 
 15 | F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
 16 | 
 17 | 
 18 | def check_write_access(func: F) -> F:
 19 |     """
 20 |     Decorator for FastMCP tools to check if the application is in read-only mode.
 21 |     If in read-only mode, it raises a ValueError.
 22 |     Assumes the decorated function is async and has `ctx: Context` as its first argument.
 23 |     """
 24 | 
 25 |     @wraps(func)
 26 |     async def wrapper(ctx: Context, *args: Any, **kwargs: Any) -> Any:
 27 |         lifespan_ctx_dict = ctx.request_context.lifespan_context
 28 |         app_lifespan_ctx = (
 29 |             lifespan_ctx_dict.get("app_lifespan_context")
 30 |             if isinstance(lifespan_ctx_dict, dict)
 31 |             else None
 32 |         )  # type: ignore
 33 | 
 34 |         if app_lifespan_ctx is not None and app_lifespan_ctx.read_only:
 35 |             tool_name = func.__name__
 36 |             action_description = tool_name.replace(
 37 |                 "_", " "
 38 |             )  # e.g., "create_issue" -> "create issue"
 39 |             logger.warning(f"Attempted to call tool '{tool_name}' in read-only mode.")
 40 |             raise ValueError(f"Cannot {action_description} in read-only mode.")
 41 | 
 42 |         return await func(ctx, *args, **kwargs)
 43 | 
 44 |     return wrapper  # type: ignore
 45 | 
 46 | 
 47 | def handle_atlassian_api_errors(service_name: str = "Atlassian API") -> Callable:
 48 |     """
 49 |     Decorator to handle common Atlassian API exceptions (Jira, Confluence, etc.).
 50 | 
 51 |     Args:
 52 |         service_name: Name of the service for error logging (e.g., "Jira API").
 53 |     """
 54 | 
 55 |     def decorator(func: Callable) -> Callable:
 56 |         @wraps(func)
 57 |         def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
 58 |             try:
 59 |                 return func(self, *args, **kwargs)
 60 |             except HTTPError as http_err:
 61 |                 if http_err.response is not None and http_err.response.status_code in [
 62 |                     401,
 63 |                     403,
 64 |                 ]:
 65 |                     error_msg = (
 66 |                         f"Authentication failed for {service_name} "
 67 |                         f"({http_err.response.status_code}). "
 68 |                         "Token may be expired or invalid. Please verify credentials."
 69 |                     )
 70 |                     logger.error(error_msg)
 71 |                     raise MCPAtlassianAuthenticationError(error_msg) from http_err
 72 |                 else:
 73 |                     operation_name = getattr(func, "__name__", "API operation")
 74 |                     logger.error(
 75 |                         f"HTTP error during {operation_name}: {http_err}",
 76 |                         exc_info=False,
 77 |                     )
 78 |                     raise http_err
 79 |             except KeyError as e:
 80 |                 operation_name = getattr(func, "__name__", "API operation")
 81 |                 logger.error(f"Missing key in {operation_name} results: {str(e)}")
 82 |                 return []
 83 |             except requests.RequestException as e:
 84 |                 operation_name = getattr(func, "__name__", "API operation")
 85 |                 logger.error(f"Network error during {operation_name}: {str(e)}")
 86 |                 return []
 87 |             except (ValueError, TypeError) as e:
 88 |                 operation_name = getattr(func, "__name__", "API operation")
 89 |                 logger.error(f"Error processing {operation_name} results: {str(e)}")
 90 |                 return []
 91 |             except Exception as e:  # noqa: BLE001 - Intentional fallback with logging
 92 |                 operation_name = getattr(func, "__name__", "API operation")
 93 |                 logger.error(f"Unexpected error during {operation_name}: {str(e)}")
 94 |                 logger.debug(
 95 |                     f"Full exception details for {operation_name}:", exc_info=True
 96 |                 )
 97 |                 return []
 98 | 
 99 |         return wrapper
100 | 
101 |     return decorator
102 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/base.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Base models and utility classes for the MCP Atlassian API models.
  3 | 
  4 | This module provides base classes and mixins that are used by the
  5 | Jira and Confluence models to ensure consistent behavior and reduce
  6 | code duplication.
  7 | """
  8 | 
  9 | from datetime import datetime
 10 | from typing import Any, TypeVar
 11 | 
 12 | from pydantic import BaseModel
 13 | 
 14 | from .constants import EMPTY_STRING
 15 | 
 16 | # Type variable for the return type of from_api_response
 17 | T = TypeVar("T", bound="ApiModel")
 18 | 
 19 | 
 20 | class ApiModel(BaseModel):
 21 |     """
 22 |     Base model for all API models with common conversion methods.
 23 | 
 24 |     This provides a standard interface for converting API responses
 25 |     to models and for converting models to simplified dictionaries
 26 |     for API responses.
 27 |     """
 28 | 
 29 |     @classmethod
 30 |     def from_api_response(cls: type[T], data: dict[str, Any], **kwargs: Any) -> T:
 31 |         """
 32 |         Convert an API response to a model instance.
 33 | 
 34 |         Args:
 35 |             data: The API response data
 36 |             **kwargs: Additional context parameters
 37 | 
 38 |         Returns:
 39 |             An instance of the model
 40 | 
 41 |         Raises:
 42 |             NotImplementedError: If the subclass does not implement this method
 43 |         """
 44 |         raise NotImplementedError("Subclasses must implement from_api_response")
 45 | 
 46 |     def to_simplified_dict(self) -> dict[str, Any]:
 47 |         """
 48 |         Convert the model to a simplified dictionary for API responses.
 49 | 
 50 |         Returns:
 51 |             A dictionary with only the essential fields for API responses
 52 |         """
 53 |         return self.model_dump(exclude_none=True)
 54 | 
 55 | 
 56 | class TimestampMixin:
 57 |     """
 58 |     Mixin for handling Atlassian API timestamp formats.
 59 |     """
 60 | 
 61 |     @staticmethod
 62 |     def format_timestamp(timestamp: str | None) -> str:
 63 |         """
 64 |         Format an Atlassian timestamp to a human-readable format.
 65 | 
 66 |         Args:
 67 |             timestamp: An ISO 8601 timestamp string
 68 | 
 69 |         Returns:
 70 |             A formatted date string or empty string if the input is invalid
 71 |         """
 72 |         if not timestamp:
 73 |             return EMPTY_STRING
 74 | 
 75 |         try:
 76 |             # Parse ISO 8601 format like "2024-01-01T10:00:00.000+0000"
 77 |             # Convert Z format to +00:00 for compatibility with fromisoformat
 78 |             ts = timestamp.replace("Z", "+00:00")
 79 | 
 80 |             # Handle timezone format without colon (+0000 -> +00:00)
 81 |             if "+" in ts and ":" not in ts[-5:]:
 82 |                 tz_pos = ts.rfind("+")
 83 |                 if tz_pos != -1 and len(ts) >= tz_pos + 5:
 84 |                     ts = ts[: tz_pos + 3] + ":" + ts[tz_pos + 3 :]
 85 |             elif "-" in ts and ":" not in ts[-5:]:
 86 |                 tz_pos = ts.rfind("-")
 87 |                 if tz_pos != -1 and len(ts) >= tz_pos + 5:
 88 |                     ts = ts[: tz_pos + 3] + ":" + ts[tz_pos + 3 :]
 89 | 
 90 |             dt = datetime.fromisoformat(ts)
 91 |             return dt.strftime("%Y-%m-%d %H:%M:%S")
 92 |         except (ValueError, TypeError):
 93 |             return timestamp or EMPTY_STRING
 94 | 
 95 |     @staticmethod
 96 |     def is_valid_timestamp(timestamp: str | None) -> bool:
 97 |         """
 98 |         Check if a string is a valid ISO 8601 timestamp.
 99 | 
100 |         Args:
101 |             timestamp: The string to check
102 | 
103 |         Returns:
104 |             True if the string is a valid timestamp, False otherwise
105 |         """
106 |         if not timestamp:
107 |             return False
108 | 
109 |         try:
110 |             # Convert Z format to +00:00 for compatibility with fromisoformat
111 |             ts = timestamp.replace("Z", "+00:00")
112 | 
113 |             # Handle timezone format without colon (+0000 -> +00:00)
114 |             if "+" in ts and ":" not in ts[-5:]:
115 |                 tz_pos = ts.rfind("+")
116 |                 if tz_pos != -1 and len(ts) >= tz_pos + 5:
117 |                     ts = ts[: tz_pos + 3] + ":" + ts[tz_pos + 3 :]
118 |             elif "-" in ts and ":" not in ts[-5:]:
119 |                 tz_pos = ts.rfind("-")
120 |                 if tz_pos != -1 and len(ts) >= tz_pos + 5:
121 |                     ts = ts[: tz_pos + 3] + ":" + ts[tz_pos + 3 :]
122 | 
123 |             datetime.fromisoformat(ts)
124 |             return True
125 |         except (ValueError, TypeError):
126 |             return False
127 | 
```

--------------------------------------------------------------------------------
/tests/utils/factories.py:
--------------------------------------------------------------------------------

```python
  1 | """Test data factories for creating consistent test objects."""
  2 | 
  3 | from typing import Any
  4 | 
  5 | 
  6 | class JiraIssueFactory:
  7 |     """Factory for creating Jira issue test data."""
  8 | 
  9 |     @staticmethod
 10 |     def create(key: str = "TEST-123", **overrides) -> dict[str, Any]:
 11 |         """Create a Jira issue with default values."""
 12 |         defaults = {
 13 |             "id": "12345",
 14 |             "key": key,
 15 |             "self": f"https://test.atlassian.net/rest/api/3/issue/{key}",
 16 |             "fields": {
 17 |                 "summary": "Test Issue Summary",
 18 |                 "description": "Test issue description",
 19 |                 "status": {"name": "Open", "id": "1", "statusCategory": {"key": "new"}},
 20 |                 "issuetype": {"name": "Task", "id": "10001"},
 21 |                 "priority": {"name": "Medium", "id": "3"},
 22 |                 "assignee": {
 23 |                     "displayName": "Test User",
 24 |                     "emailAddress": "[email protected]",
 25 |                 },
 26 |                 "created": "2023-01-01T12:00:00.000+0000",
 27 |                 "updated": "2023-01-01T12:00:00.000+0000",
 28 |             },
 29 |         }
 30 |         return deep_merge(defaults, overrides)
 31 | 
 32 |     @staticmethod
 33 |     def create_minimal(key: str = "TEST-123") -> dict[str, Any]:
 34 |         """Create minimal Jira issue for basic tests."""
 35 |         return {
 36 |             "key": key,
 37 |             "fields": {"summary": "Test Issue", "status": {"name": "Open"}},
 38 |         }
 39 | 
 40 | 
 41 | class ConfluencePageFactory:
 42 |     """Factory for creating Confluence page test data."""
 43 | 
 44 |     @staticmethod
 45 |     def create(page_id: str = "123456", **overrides) -> dict[str, Any]:
 46 |         """Create a Confluence page with default values."""
 47 |         defaults = {
 48 |             "id": page_id,
 49 |             "title": "Test Page",
 50 |             "type": "page",
 51 |             "status": "current",
 52 |             "space": {"key": "TEST", "name": "Test Space"},
 53 |             "body": {
 54 |                 "storage": {"value": "<p>Test content</p>", "representation": "storage"}
 55 |             },
 56 |             "version": {"number": 1},
 57 |             "_links": {
 58 |                 "webui": f"/spaces/TEST/pages/{page_id}",
 59 |                 "self": f"https://test.atlassian.net/wiki/rest/api/content/{page_id}",
 60 |             },
 61 |         }
 62 |         return deep_merge(defaults, overrides)
 63 | 
 64 | 
 65 | class AuthConfigFactory:
 66 |     """Factory for authentication configuration objects."""
 67 | 
 68 |     @staticmethod
 69 |     def create_oauth_config(**overrides) -> dict[str, str]:
 70 |         """Create OAuth configuration."""
 71 |         defaults = {
 72 |             "client_id": "test-client-id",
 73 |             "client_secret": "test-client-secret",
 74 |             "redirect_uri": "http://localhost:8080/callback",
 75 |             "scope": "read:jira-work write:jira-work",
 76 |             "cloud_id": "test-cloud-id",
 77 |             "access_token": "test-access-token",
 78 |             "refresh_token": "test-refresh-token",
 79 |         }
 80 |         return {**defaults, **overrides}
 81 | 
 82 |     @staticmethod
 83 |     def create_basic_auth_config(**overrides) -> dict[str, str]:
 84 |         """Create basic auth configuration."""
 85 |         defaults = {
 86 |             "url": "https://test.atlassian.net",
 87 |             "username": "[email protected]",
 88 |             "api_token": "test-api-token",
 89 |         }
 90 |         return {**defaults, **overrides}
 91 | 
 92 | 
 93 | class ErrorResponseFactory:
 94 |     """Factory for creating error response test data."""
 95 | 
 96 |     @staticmethod
 97 |     def create_api_error(
 98 |         status_code: int = 400, message: str = "Bad Request"
 99 |     ) -> dict[str, Any]:
100 |         """Create API error response."""
101 |         return {"errorMessages": [message], "errors": {}, "status": status_code}
102 | 
103 |     @staticmethod
104 |     def create_auth_error() -> dict[str, Any]:
105 |         """Create authentication error response."""
106 |         return {"errorMessages": ["Authentication failed"], "status": 401}
107 | 
108 | 
109 | def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
110 |     """Deep merge two dictionaries."""
111 |     result = base.copy()
112 |     for key, value in override.items():
113 |         if key in result and isinstance(result[key], dict) and isinstance(value, dict):
114 |             result[key] = deep_merge(result[key], value)
115 |         else:
116 |             result[key] = value
117 |     return result
118 | 
```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------

```yaml
  1 | name: "\U0001F41B Bug Report"
  2 | description: Create a report to help us improve mcp-atlassian
  3 | title: "[Bug]: "
  4 | labels: ["bug"]
  5 | body:
  6 |   - type: markdown
  7 |     attributes:
  8 |       value: |
  9 |         Thanks for taking the time to fill out this bug report! Please provide as much detail as possible.
 10 |   - type: checkboxes
 11 |     id: prerequisites
 12 |     attributes:
 13 |       label: Prerequisites
 14 |       description: Please confirm the following before submitting the issue.
 15 |       options:
 16 |         - label: I have searched the [existing issues](https://github.com/sooperset/mcp-atlassian/issues) to make sure this bug has not already been reported.
 17 |           required: true
 18 |         - label: I have checked the [README](https://github.com/sooperset/mcp-atlassian/blob/main/README.md) for relevant information.
 19 |           required: true
 20 |   - type: textarea
 21 |     id: description
 22 |     attributes:
 23 |       label: Bug Description
 24 |       description: A clear and concise description of what the bug is.
 25 |       placeholder: "When I call the `jira_create_issue` tool with..., it fails with..."
 26 |     validations:
 27 |       required: true
 28 |   - type: textarea
 29 |     id: steps-to-reproduce
 30 |     attributes:
 31 |       label: Steps to Reproduce
 32 |       description: Provide detailed steps to reproduce the behavior.
 33 |       placeholder: |
 34 |         1. Start the server with command `...`
 35 |         2. Send a `list_tools` request using `...`
 36 |         3. Call the tool `xyz` with arguments `...`
 37 |         4. See error `...`
 38 |     validations:
 39 |       required: true
 40 |   - type: textarea
 41 |     id: expected-behavior
 42 |     attributes:
 43 |       label: Expected Behavior
 44 |       description: A clear and concise description of what you expected to happen.
 45 |       placeholder: "I expected the Jira issue to be created successfully and return its key."
 46 |     validations:
 47 |       required: true
 48 |   - type: textarea
 49 |     id: actual-behavior
 50 |     attributes:
 51 |       label: Actual Behavior
 52 |       description: What actually happened? Include full error messages, logs (from the server and the client if possible), or screenshots.
 53 |       placeholder: "The server returned an error message: '...' / The tool call returned an empty list."
 54 |       render: shell
 55 |     validations:
 56 |       required: true
 57 |   - type: input
 58 |     id: version
 59 |     attributes:
 60 |       label: mcp-atlassian Version
 61 |       description: Which version of `mcp-atlassian` are you using? (Check `pip show mcp-atlassian` or `pyproject.toml`)
 62 |       placeholder: "e.g., 0.6.5"
 63 |     validations:
 64 |       required: true
 65 |   - type: dropdown
 66 |     id: installation
 67 |     attributes:
 68 |       label: Installation Method
 69 |       description: How did you install `mcp-atlassian`?
 70 |       options:
 71 |         - From PyPI (pip install mcp-atlassian / uv add mcp-atlassian)
 72 |         - From source (git clone)
 73 |         - Docker
 74 |         - Other
 75 |     validations:
 76 |       required: true
 77 |   - type: dropdown
 78 |     id: os
 79 |     attributes:
 80 |       label: Operating System
 81 |       description: What operating system are you using?
 82 |       options:
 83 |         - Windows
 84 |         - macOS
 85 |         - Linux (Specify distribution below if relevant)
 86 |         - Other
 87 |     validations:
 88 |       required: true
 89 |   - type: input
 90 |     id: python-version
 91 |     attributes:
 92 |       label: Python Version
 93 |       description: What version of Python are you using? (`python --version`)
 94 |       placeholder: "e.g., 3.11.4"
 95 |     validations:
 96 |       required: true
 97 |   - type: dropdown
 98 |     id: atlassian-instance
 99 |     attributes:
100 |       label: Atlassian Instance Type
101 |       description: Are you connecting to Atlassian Cloud or Server/Data Center?
102 |       multiple: true
103 |       options:
104 |         - Jira Cloud
105 |         - Jira Server / Data Center
106 |         - Confluence Cloud
107 |         - Confluence Server / Data Center
108 |     validations:
109 |       required: true
110 |   - type: input
111 |     id: client-app
112 |     attributes:
113 |       label: Client Application
114 |       description: What application/library are you using to interact with the MCP server? (This is important!)
115 |       placeholder: "e.g., Cursor, LangChain, custom script, Inspector Tool"
116 |     validations:
117 |       required: true
118 |   - type: textarea
119 |     id: additional-context
120 |     attributes:
121 |       label: Additional Context
122 |       description: Add any other context about the problem here (e.g., environment variables, network configuration like proxies, specific Jira/Confluence setup).
123 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/user_search.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Confluence user search result models.
  3 | This module provides Pydantic models for Confluence user search results.
  4 | """
  5 | 
  6 | import logging
  7 | from typing import Any
  8 | 
  9 | from pydantic import Field
 10 | 
 11 | from ..base import ApiModel, TimestampMixin
 12 | from .common import ConfluenceUser
 13 | 
 14 | logger = logging.getLogger(__name__)
 15 | 
 16 | 
 17 | class ConfluenceUserSearchResult(ApiModel):
 18 |     """
 19 |     Model representing a single user search result.
 20 |     """
 21 | 
 22 |     user: ConfluenceUser | None = None
 23 |     title: str | None = None
 24 |     excerpt: str | None = None
 25 |     url: str | None = None
 26 |     entity_type: str = "user"
 27 |     last_modified: str | None = None
 28 |     score: float = 0.0
 29 | 
 30 |     @classmethod
 31 |     def from_api_response(
 32 |         cls, data: dict[str, Any], **kwargs: Any
 33 |     ) -> "ConfluenceUserSearchResult":
 34 |         """
 35 |         Create a ConfluenceUserSearchResult from a Confluence API response.
 36 | 
 37 |         Args:
 38 |             data: The user search result data from the Confluence API
 39 |             **kwargs: Additional context parameters
 40 | 
 41 |         Returns:
 42 |             A ConfluenceUserSearchResult instance
 43 |         """
 44 |         if not data:
 45 |             return cls()
 46 | 
 47 |         # Extract user data from the result
 48 |         user_data = data.get("user", {})
 49 |         user = ConfluenceUser.from_api_response(user_data) if user_data else None
 50 | 
 51 |         return cls(
 52 |             user=user,
 53 |             title=data.get("title"),
 54 |             excerpt=data.get("excerpt"),
 55 |             url=data.get("url"),
 56 |             entity_type=data.get("entityType", "user"),
 57 |             last_modified=data.get("lastModified"),
 58 |             score=data.get("score", 0.0),
 59 |         )
 60 | 
 61 |     def to_simplified_dict(self) -> dict[str, Any]:
 62 |         """Convert to simplified dictionary for API response."""
 63 |         result = {
 64 |             "entity_type": self.entity_type,
 65 |             "title": self.title,
 66 |             "score": self.score,
 67 |         }
 68 | 
 69 |         if self.user:
 70 |             result["user"] = {
 71 |                 "account_id": self.user.account_id,
 72 |                 "display_name": self.user.display_name,
 73 |                 "email": self.user.email,
 74 |                 "profile_picture": self.user.profile_picture,
 75 |                 "is_active": self.user.is_active,
 76 |             }
 77 | 
 78 |         if self.url:
 79 |             result["url"] = self.url
 80 | 
 81 |         if self.last_modified:
 82 |             result["last_modified"] = self.last_modified
 83 | 
 84 |         if self.excerpt:
 85 |             result["excerpt"] = self.excerpt
 86 | 
 87 |         return result
 88 | 
 89 | 
 90 | class ConfluenceUserSearchResults(ApiModel, TimestampMixin):
 91 |     """
 92 |     Model representing a collection of user search results.
 93 |     """
 94 | 
 95 |     total_size: int = 0
 96 |     start: int = 0
 97 |     limit: int = 0
 98 |     results: list[ConfluenceUserSearchResult] = Field(default_factory=list)
 99 |     cql_query: str | None = None
100 |     search_duration: int | None = None
101 | 
102 |     @classmethod
103 |     def from_api_response(
104 |         cls, data: dict[str, Any], **kwargs: Any
105 |     ) -> "ConfluenceUserSearchResults":
106 |         """
107 |         Create a ConfluenceUserSearchResults from a Confluence API response.
108 | 
109 |         Args:
110 |             data: The search result data from the Confluence API
111 |             **kwargs: Additional context parameters
112 | 
113 |         Returns:
114 |             A ConfluenceUserSearchResults instance
115 |         """
116 |         if not data:
117 |             return cls()
118 | 
119 |         # Convert search results to ConfluenceUserSearchResult models
120 |         results = []
121 |         for result_data in data.get("results", []):
122 |             user_result = ConfluenceUserSearchResult.from_api_response(
123 |                 result_data, **kwargs
124 |             )
125 |             results.append(user_result)
126 | 
127 |         return cls(
128 |             total_size=data.get("totalSize", 0),
129 |             start=data.get("start", 0),
130 |             limit=data.get("limit", 0),
131 |             results=results,
132 |             cql_query=data.get("cqlQuery"),
133 |             search_duration=data.get("searchDuration"),
134 |         )
135 | 
136 |     def to_simplified_dict(self) -> dict[str, Any]:
137 |         """Convert to simplified dictionary for API response."""
138 |         return {
139 |             "total_size": self.total_size,
140 |             "start": self.start,
141 |             "limit": self.limit,
142 |             "cql_query": self.cql_query,
143 |             "search_duration": self.search_duration,
144 |             "results": [result.to_simplified_dict() for result in self.results],
145 |         }
146 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/agile.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Jira agile models.
  3 | 
  4 | This module provides Pydantic models for Jira agile entities,
  5 | such as boards and sprints.
  6 | """
  7 | 
  8 | import logging
  9 | from typing import Any
 10 | 
 11 | from ..base import ApiModel
 12 | from ..constants import (
 13 |     EMPTY_STRING,
 14 |     JIRA_DEFAULT_ID,
 15 |     UNKNOWN,
 16 | )
 17 | 
 18 | logger = logging.getLogger(__name__)
 19 | 
 20 | 
 21 | class JiraBoard(ApiModel):
 22 |     """
 23 |     Model representing a Jira board.
 24 |     """
 25 | 
 26 |     id: str = JIRA_DEFAULT_ID
 27 |     name: str = UNKNOWN
 28 |     type: str = UNKNOWN
 29 | 
 30 |     @classmethod
 31 |     def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraBoard":
 32 |         """
 33 |         Create a JiraBoard from a Jira API response.
 34 | 
 35 |         Args:
 36 |             data: The board data from the Jira API
 37 | 
 38 |         Returns:
 39 |             A JiraBoard instance
 40 |         """
 41 |         if not data:
 42 |             return cls()
 43 | 
 44 |         # Handle non-dictionary data by returning a default instance
 45 |         if not isinstance(data, dict):
 46 |             logger.debug("Received non-dictionary data, returning default instance")
 47 |             return cls()
 48 | 
 49 |         # Ensure ID is a string
 50 |         board_id = data.get("id", JIRA_DEFAULT_ID)
 51 |         if board_id is not None:
 52 |             board_id = str(board_id)
 53 | 
 54 |         # We assume boards always have a name and type, but enforce strings
 55 |         board_name = str(data.get("name", UNKNOWN))
 56 |         board_type = str(data.get("type", UNKNOWN))
 57 | 
 58 |         return cls(
 59 |             id=board_id,
 60 |             name=board_name,
 61 |             type=board_type,
 62 |         )
 63 | 
 64 |     def to_simplified_dict(self) -> dict[str, Any]:
 65 |         """Convert to simplified dictionary for API response."""
 66 |         return {
 67 |             "id": self.id,
 68 |             "name": self.name,
 69 |             "type": self.type,
 70 |         }
 71 | 
 72 | 
 73 | class JiraSprint(ApiModel):
 74 |     """
 75 |     Model representing a Jira sprint.
 76 |     """
 77 | 
 78 |     id: str = JIRA_DEFAULT_ID
 79 |     state: str = UNKNOWN
 80 |     name: str = UNKNOWN
 81 |     start_date: str = EMPTY_STRING
 82 |     end_date: str = EMPTY_STRING
 83 |     activated_date: str = EMPTY_STRING
 84 |     origin_board_id: str = JIRA_DEFAULT_ID
 85 |     goal: str = EMPTY_STRING
 86 |     synced: bool = False
 87 |     auto_start_stop: bool = False
 88 | 
 89 |     @classmethod
 90 |     def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraSprint":
 91 |         """
 92 |         Create a JiraSprint from a Jira API response.
 93 | 
 94 |         Args:
 95 |             data: The sprint data from the Jira API
 96 | 
 97 |         Returns:
 98 |             A JiraSprint instance
 99 |         """
100 |         if not data:
101 |             return cls()
102 | 
103 |         # Handle non-dictionary data by returning a default instance
104 |         if not isinstance(data, dict):
105 |             logger.debug("Received non-dictionary data, returning default instance")
106 |             return cls()
107 | 
108 |         # Ensure ID and origin board ID are strings
109 |         sprint_id = data.get("id", JIRA_DEFAULT_ID)
110 |         if sprint_id is not None:
111 |             sprint_id = str(sprint_id)
112 | 
113 |         origin_board_id = data.get("originBoardId", JIRA_DEFAULT_ID)
114 |         if origin_board_id is not None:
115 |             origin_board_id = str(origin_board_id)
116 | 
117 |         # Boolean fields
118 |         synced = bool(data.get("synced", False))
119 |         auto_start_stop = bool(data.get("autoStartStop", False))
120 | 
121 |         return cls(
122 |             id=sprint_id,
123 |             state=str(data.get("state", UNKNOWN)),
124 |             name=str(data.get("name", UNKNOWN)),
125 |             start_date=str(data.get("startDate", EMPTY_STRING)),
126 |             end_date=str(data.get("endDate", EMPTY_STRING)),
127 |             activated_date=str(data.get("activatedDate", EMPTY_STRING)),
128 |             origin_board_id=origin_board_id,
129 |             goal=str(data.get("goal", EMPTY_STRING)),
130 |             synced=synced,
131 |             auto_start_stop=auto_start_stop,
132 |         )
133 | 
134 |     def to_simplified_dict(self) -> dict[str, Any]:
135 |         """Convert to simplified dictionary for API response."""
136 |         result = {
137 |             "id": self.id,
138 |             "name": self.name,
139 |             "state": self.state,
140 |         }
141 | 
142 |         if self.goal and self.goal != EMPTY_STRING:
143 |             result["goal"] = self.goal
144 | 
145 |         # Only include dates if they're not empty
146 |         if self.start_date and self.start_date != EMPTY_STRING:
147 |             result["start_date"] = self.start_date
148 | 
149 |         if self.end_date and self.end_date != EMPTY_STRING:
150 |             result["end_date"] = self.end_date
151 | 
152 |         return result
153 | 
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
  1 | # Smithery.ai configuration for mcp-atlassian
  2 | startCommand:
  3 |   type: stdio # Specifies the server communicates over standard input/output
  4 |   configSchema:
  5 |     # JSON Schema defining the configuration options users need to provide
  6 |     type: object
  7 |     required:
  8 |       - jiraUrl
  9 |       - confluenceUrl
 10 |       # Add other strictly required credentials based on auth needs
 11 |     properties:
 12 |       # Confluence Config
 13 |       confluenceUrl:
 14 |         type: string
 15 |         description: "Base URL for your Confluence instance (e.g., https://your-domain.atlassian.net/wiki or https://confluence.yourcompany.com)."
 16 |       confluenceUsername:
 17 |         type: string
 18 |         description: "(Optional for Cloud Basic Auth) Your Confluence username or email."
 19 |       confluenceApiToken:
 20 |         type: string
 21 |         description: "(Optional for Cloud Basic Auth) Your Confluence API token."
 22 |         format: password # Hides the value in UI inputs
 23 |       confluencePersonalToken:
 24 |         type: string
 25 |         description: "(Optional for Server/DC Token Auth) Your Confluence Personal Access Token."
 26 |         format: password
 27 |       confluenceSslVerify:
 28 |         type: boolean
 29 |         description: "(Optional, Server/DC only) Verify SSL certificate for Confluence. Defaults to true."
 30 |         default: true
 31 |       confluenceSpacesFilter:
 32 |         type: string
 33 |         description: "(Optional) Comma-separated list of Confluence space keys to limit searches to (e.g., 'DEV,QA')."
 34 | 
 35 |       # Jira Config
 36 |       jiraUrl:
 37 |         type: string
 38 |         description: "Base URL for your Jira instance (e.g., https://your-domain.atlassian.net or https://jira.yourcompany.com)."
 39 |       jiraUsername:
 40 |         type: string
 41 |         description: "(Optional for Cloud Basic Auth or Server/DC Basic Auth) Your Jira username or email."
 42 |       jiraApiToken:
 43 |         type: string
 44 |         description: "(Optional for Cloud Basic Auth or Server/DC Basic Auth) Your Jira API token."
 45 |         format: password
 46 |       jiraPersonalToken:
 47 |         type: string
 48 |         description: "(Optional for Server/DC Token Auth) Your Jira Personal Access Token."
 49 |         format: password
 50 |       jiraSslVerify:
 51 |         type: boolean
 52 |         description: "(Optional, Server/DC only) Verify SSL certificate for Jira. Defaults to true."
 53 |         default: true
 54 |       jiraProjectsFilter:
 55 |         type: string
 56 |         description: "(Optional) Comma-separated list of Jira project keys to limit searches to (e.g., 'PROJ1,PROJ2')."
 57 | 
 58 |       # General Config
 59 |       readOnlyMode:
 60 |         type: boolean
 61 |         description: "(Optional) Run in read-only mode (prevents create/update/delete). Defaults to false."
 62 |         default: false
 63 | 
 64 |     additionalProperties: false # Disallow properties not defined above
 65 | 
 66 |   commandFunction:
 67 |     # A JavaScript function that produces the CLI command and environment variables
 68 |     # needed to start the MCP server, based on the user's configuration.
 69 |     |-
 70 |     (config) => {
 71 |       // The command matches the ENTRYPOINT in the Dockerfile
 72 |       const command = 'mcp-atlassian';
 73 |       const args = []; // No arguments needed as config is via ENV
 74 | 
 75 |       // Map the config schema properties to the environment variables
 76 |       const env = {
 77 |         // Confluence ENV VARS
 78 |         CONFLUENCE_URL: config.confluenceUrl,
 79 |         CONFLUENCE_USERNAME: config.confluenceUsername,
 80 |         CONFLUENCE_API_TOKEN: config.confluenceApiToken,
 81 |         CONFLUENCE_PERSONAL_TOKEN: config.confluencePersonalToken,
 82 |         CONFLUENCE_SSL_VERIFY: config.confluenceSslVerify !== undefined ? String(config.confluenceSslVerify) : 'true',
 83 |         CONFLUENCE_SPACES_FILTER: config.confluenceSpacesFilter,
 84 | 
 85 |         // Jira ENV VARS
 86 |         JIRA_URL: config.jiraUrl,
 87 |         JIRA_USERNAME: config.jiraUsername,
 88 |         JIRA_API_TOKEN: config.jiraApiToken,
 89 |         JIRA_PERSONAL_TOKEN: config.jiraPersonalToken,
 90 |         JIRA_SSL_VERIFY: config.jiraSslVerify !== undefined ? String(config.jiraSslVerify) : 'true',
 91 |         JIRA_PROJECTS_FILTER: config.jiraProjectsFilter,
 92 | 
 93 |         // General ENV VARS
 94 |         READ_ONLY_MODE: config.readOnlyMode !== undefined ? String(config.readOnlyMode) : 'false',
 95 |       };
 96 | 
 97 |       // Filter out undefined/null env variables
 98 |       const filteredEnv = Object.entries(env)
 99 |         .filter(([key, value]) => value !== undefined && value !== null)
100 |         .reduce((obj, [key, value]) => {
101 |           obj[key] = value;
102 |           return obj;
103 |         }, {});
104 | 
105 |       return {
106 |         command: command,
107 |         args: args,
108 |         env: filteredEnv
109 |       };
110 |     }
111 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/search.py:
--------------------------------------------------------------------------------

```python
  1 | """Module for Confluence search operations."""
  2 | 
  3 | import logging
  4 | 
  5 | from ..models.confluence import (
  6 |     ConfluencePage,
  7 |     ConfluenceSearchResult,
  8 |     ConfluenceUserSearchResult,
  9 |     ConfluenceUserSearchResults,
 10 | )
 11 | from ..utils.decorators import handle_atlassian_api_errors
 12 | from .client import ConfluenceClient
 13 | from .utils import quote_cql_identifier_if_needed
 14 | 
 15 | logger = logging.getLogger("mcp-atlassian")
 16 | 
 17 | 
 18 | class SearchMixin(ConfluenceClient):
 19 |     """Mixin for Confluence search operations."""
 20 | 
 21 |     @handle_atlassian_api_errors("Confluence API")
 22 |     def search(
 23 |         self, cql: str, limit: int = 10, spaces_filter: str | None = None
 24 |     ) -> list[ConfluencePage]:
 25 |         """
 26 |         Search content using Confluence Query Language (CQL).
 27 | 
 28 |         Args:
 29 |             cql: Confluence Query Language string
 30 |             limit: Maximum number of results to return
 31 |             spaces_filter: Optional comma-separated list of space keys to filter by,
 32 |                 overrides config
 33 | 
 34 |         Returns:
 35 |             List of ConfluencePage models containing search results
 36 | 
 37 |         Raises:
 38 |             MCPAtlassianAuthenticationError: If authentication fails with the
 39 |                 Confluence API (401/403)
 40 |         """
 41 |         # Use spaces_filter parameter if provided, otherwise fall back to config
 42 |         filter_to_use = spaces_filter or self.config.spaces_filter
 43 | 
 44 |         # Apply spaces filter if present
 45 |         if filter_to_use:
 46 |             # Split spaces filter by commas and handle possible whitespace
 47 |             spaces = [s.strip() for s in filter_to_use.split(",")]
 48 | 
 49 |             # Build the space filter query part using proper quoting for each space key
 50 |             space_query = " OR ".join(
 51 |                 [f"space = {quote_cql_identifier_if_needed(space)}" for space in spaces]
 52 |             )
 53 | 
 54 |             # Add the space filter to existing query with parentheses
 55 |             if cql and space_query:
 56 |                 if "space = " not in cql:  # Only add if not already filtering by space
 57 |                     cql = f"({cql}) AND ({space_query})"
 58 |             else:
 59 |                 cql = space_query
 60 | 
 61 |             logger.info(f"Applied spaces filter to query: {cql}")
 62 | 
 63 |         # Execute the CQL search query
 64 |         results = self.confluence.cql(cql=cql, limit=limit)
 65 | 
 66 |         # Convert the response to a search result model
 67 |         search_result = ConfluenceSearchResult.from_api_response(
 68 |             results,
 69 |             base_url=self.config.url,
 70 |             cql_query=cql,
 71 |             is_cloud=self.config.is_cloud,
 72 |         )
 73 | 
 74 |         # Process result excerpts as content
 75 |         processed_pages = []
 76 |         for page in search_result.results:
 77 |             # Get the excerpt from the original search results
 78 |             for result_item in results.get("results", []):
 79 |                 if result_item.get("content", {}).get("id") == page.id:
 80 |                     excerpt = result_item.get("excerpt", "")
 81 |                     if excerpt:
 82 |                         # Process the excerpt as HTML content
 83 |                         space_key = page.space.key if page.space else ""
 84 |                         _, processed_markdown = self.preprocessor.process_html_content(
 85 |                             excerpt,
 86 |                             space_key=space_key,
 87 |                             confluence_client=self.confluence,
 88 |                         )
 89 |                         # Create a new page with processed content
 90 |                         page.content = processed_markdown
 91 |                     break
 92 | 
 93 |             processed_pages.append(page)
 94 | 
 95 |         # Return the list of result pages with processed content
 96 |         return processed_pages
 97 | 
 98 |     @handle_atlassian_api_errors("Confluence API")
 99 |     def search_user(
100 |         self, cql: str, limit: int = 10
101 |     ) -> list[ConfluenceUserSearchResult]:
102 |         """
103 |         Search users using Confluence Query Language (CQL).
104 | 
105 |         Args:
106 |             cql: Confluence Query Language string for user search
107 |             limit: Maximum number of results to return
108 | 
109 |         Returns:
110 |             List of ConfluenceUserSearchResult models containing user search results
111 | 
112 |         Raises:
113 |             MCPAtlassianAuthenticationError: If authentication fails with the
114 |                 Confluence API (401/403)
115 |         """
116 |         # Execute the user search query using the direct API endpoint
117 |         results = self.confluence.get(
118 |             "rest/api/search/user", params={"cql": cql, "limit": limit}
119 |         )
120 | 
121 |         # Convert the response to a user search result model
122 |         search_result = ConfluenceUserSearchResults.from_api_response(results or {})
123 | 
124 |         # Return the list of user search results
125 |         return search_result.results
126 | 
```

--------------------------------------------------------------------------------
/tests/unit/confluence/test_labels.py:
--------------------------------------------------------------------------------

```python
  1 | """Unit tests for the LabelsMixin class."""
  2 | 
  3 | from unittest.mock import patch
  4 | 
  5 | import pytest
  6 | import requests
  7 | 
  8 | from mcp_atlassian.confluence.labels import LabelsMixin
  9 | from mcp_atlassian.models.confluence import ConfluenceLabel
 10 | 
 11 | 
 12 | class TestLabelsMixin:
 13 |     """Tests for the LabelsMixin class."""
 14 | 
 15 |     @pytest.fixture
 16 |     def labels_mixin(self, confluence_client):
 17 |         """Create a LabelsMixin instance for testing."""
 18 |         # LabelsMixin inherits from ConfluenceClient, so we need to create it properly
 19 |         with patch(
 20 |             "mcp_atlassian.confluence.labels.ConfluenceClient.__init__"
 21 |         ) as mock_init:
 22 |             mock_init.return_value = None
 23 |             mixin = LabelsMixin()
 24 |             # Copy the necessary attributes from our mocked client
 25 |             mixin.confluence = confluence_client.confluence
 26 |             mixin.config = confluence_client.config
 27 |             mixin.preprocessor = confluence_client.preprocessor
 28 |             return mixin
 29 | 
 30 |     def test_get_page_labels_success(self, labels_mixin):
 31 |         """Test get_page_labels with success response."""
 32 |         # Setup
 33 |         page_id = "12345"
 34 | 
 35 |         # Call the method
 36 |         result = labels_mixin.get_page_labels(page_id)
 37 | 
 38 |         # Verify
 39 |         labels_mixin.confluence.get_page_labels.assert_called_once_with(page_id=page_id)
 40 |         assert len(result) == 3
 41 |         assert result[0].id == "456789123"
 42 |         assert result[0].prefix == "global"
 43 |         assert result[0].label == "meeting-notes"
 44 |         assert result[1].id == "456789124"
 45 |         assert result[1].prefix == "my"
 46 |         assert result[1].name == "important"
 47 |         assert result[2].id == "456789125"
 48 |         assert result[2].name == "test"
 49 | 
 50 |     def test_get_page_labels_api_error(self, labels_mixin):
 51 |         """Test handling of API errors."""
 52 |         # Mock the API to raise an exception
 53 |         labels_mixin.confluence.get_page_labels.side_effect = requests.RequestException(
 54 |             "API error"
 55 |         )
 56 | 
 57 |         # Act/Assert
 58 |         with pytest.raises(Exception, match="Failed fetching labels"):
 59 |             labels_mixin.get_page_labels("987654321")
 60 | 
 61 |     def test_get_page_labels_key_error(self, labels_mixin):
 62 |         """Test handling of missing keys in API response."""
 63 |         # Mock the response to be missing expected keys
 64 |         labels_mixin.confluence.get_page_labels.return_value = {"invalid": "data"}
 65 | 
 66 |         # Act/Assert
 67 |         with pytest.raises(Exception, match="Failed fetching labels"):
 68 |             labels_mixin.get_page_labels("987654321")
 69 | 
 70 |     def test_get_page_labels_value_error(self, labels_mixin):
 71 |         """Test handling of unexpected data types."""
 72 |         # Cause a value error by returning a string where a dict is expected
 73 |         labels_mixin.confluence.get_page_labels.return_value = "invalid"
 74 | 
 75 |         # Act/Assert
 76 |         with pytest.raises(Exception, match="Failed fetching labels"):
 77 |             labels_mixin.get_page_labels("987654321")
 78 | 
 79 |     def test_get_page_labels_with_empty_results(self, labels_mixin):
 80 |         """Test handling of empty results."""
 81 |         # Mock empty results
 82 |         labels_mixin.confluence.get_page_labels.return_value = {"results": []}
 83 | 
 84 |         # Act
 85 |         result = labels_mixin.get_page_labels("987654321")
 86 | 
 87 |         # Assert
 88 |         assert isinstance(result, list)
 89 |         assert len(result) == 0  # Empty list with no labels
 90 | 
 91 |     def test_add_page_label_success(self, labels_mixin):
 92 |         """Test adding a label"""
 93 |         # Arrange
 94 |         page_id = "987654321"
 95 |         name = "test-label"
 96 |         prefix = "global"
 97 | 
 98 |         # Mock add_page_label to return a list of ConfluenceLabels
 99 |         with patch.object(
100 |             labels_mixin,
101 |             "get_page_labels",
102 |             return_value=ConfluenceLabel(
103 |                 id="123456789",
104 |                 name=name,
105 |                 prefix=prefix,
106 |             ),
107 |         ):
108 |             # Act
109 |             result = labels_mixin.add_page_label(page_id, name)
110 | 
111 |             # Assert
112 |             labels_mixin.confluence.set_page_label.assert_called_once_with(
113 |                 page_id=page_id, label=name
114 |             )
115 | 
116 |             # Verify result is a ConfluenceLabel
117 |             assert isinstance(result, ConfluenceLabel)
118 |             assert result.id == "123456789"
119 |             assert result.name == name
120 |             assert result.prefix == prefix
121 | 
122 |     def test_add_page_label_error(self, labels_mixin):
123 |         """Test error handling when adding a label."""
124 |         # Arrange
125 |         labels_mixin.confluence.set_page_label.side_effect = Exception("API Error")
126 | 
127 |         # Act/Assert
128 |         with pytest.raises(Exception, match="Failed to add label"):
129 |             labels_mixin.add_page_label("987654321", "test")
130 | 
```

--------------------------------------------------------------------------------
/tests/unit/jira/test_custom_headers.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for JIRA custom headers functionality."""
  2 | 
  3 | import os
  4 | from unittest.mock import MagicMock, patch
  5 | 
  6 | from mcp_atlassian.jira.client import JiraClient
  7 | from mcp_atlassian.jira.config import JiraConfig
  8 | 
  9 | 
 10 | class TestJiraConfigCustomHeaders:
 11 |     """Test JiraConfig parsing of custom headers."""
 12 | 
 13 |     def test_no_custom_headers(self):
 14 |         """Test JiraConfig when no custom headers are configured."""
 15 |         with patch.dict(
 16 |             os.environ,
 17 |             {
 18 |                 "JIRA_URL": "https://test.atlassian.net",
 19 |                 "JIRA_USERNAME": "test_user",
 20 |                 "JIRA_API_TOKEN": "test_token",
 21 |             },
 22 |             clear=True,
 23 |         ):
 24 |             config = JiraConfig.from_env()
 25 |             assert config.custom_headers == {}
 26 | 
 27 |     def test_service_specific_headers_only(self):
 28 |         """Test JiraConfig parsing of service-specific headers only."""
 29 |         with patch.dict(
 30 |             os.environ,
 31 |             {
 32 |                 "JIRA_URL": "https://test.atlassian.net",
 33 |                 "JIRA_USERNAME": "test_user",
 34 |                 "JIRA_API_TOKEN": "test_token",
 35 |                 "JIRA_CUSTOM_HEADERS": "X-Jira-Specific=jira_value,X-Service=service_value",
 36 |             },
 37 |             clear=True,
 38 |         ):
 39 |             config = JiraConfig.from_env()
 40 |             expected = {"X-Jira-Specific": "jira_value", "X-Service": "service_value"}
 41 |             assert config.custom_headers == expected
 42 | 
 43 |     def test_malformed_headers_are_ignored(self):
 44 |         """Test that malformed headers are ignored gracefully."""
 45 |         with patch.dict(
 46 |             os.environ,
 47 |             {
 48 |                 "JIRA_URL": "https://test.atlassian.net",
 49 |                 "JIRA_USERNAME": "test_user",
 50 |                 "JIRA_API_TOKEN": "test_token",
 51 |                 "JIRA_CUSTOM_HEADERS": "malformed-header,X-Valid=valid_value,another-malformed",
 52 |             },
 53 |             clear=True,
 54 |         ):
 55 |             config = JiraConfig.from_env()
 56 |             expected = {"X-Valid": "valid_value"}
 57 |             assert config.custom_headers == expected
 58 | 
 59 |     def test_empty_header_strings(self):
 60 |         """Test handling of empty header strings."""
 61 |         with patch.dict(
 62 |             os.environ,
 63 |             {
 64 |                 "JIRA_URL": "https://test.atlassian.net",
 65 |                 "JIRA_USERNAME": "test_user",
 66 |                 "JIRA_API_TOKEN": "test_token",
 67 |                 "JIRA_CUSTOM_HEADERS": "   ",
 68 |             },
 69 |             clear=True,
 70 |         ):
 71 |             config = JiraConfig.from_env()
 72 |             assert config.custom_headers == {}
 73 | 
 74 | 
 75 | class TestJiraClientCustomHeaders:
 76 |     """Test JiraClient custom headers application."""
 77 | 
 78 |     def test_no_custom_headers_applied(self, monkeypatch):
 79 |         """Test that no headers are applied when none are configured."""
 80 |         # Mock Jira and related dependencies
 81 |         mock_jira = MagicMock()
 82 |         mock_session = MagicMock()
 83 |         mock_session.headers = {}
 84 |         mock_jira._session = mock_session
 85 | 
 86 |         monkeypatch.setattr(
 87 |             "mcp_atlassian.jira.client.Jira", lambda **kwargs: mock_jira
 88 |         )
 89 |         monkeypatch.setattr(
 90 |             "mcp_atlassian.jira.client.configure_ssl_verification",
 91 |             lambda **kwargs: None,
 92 |         )
 93 | 
 94 |         config = JiraConfig(
 95 |             url="https://test.atlassian.net",
 96 |             auth_type="basic",
 97 |             username="test_user",
 98 |             api_token="test_token",
 99 |             custom_headers={},
100 |         )
101 | 
102 |         client = JiraClient(config=config)
103 | 
104 |         # Verify no custom headers were applied
105 |         assert mock_session.headers == {}
106 | 
107 |     def test_custom_headers_applied_to_session(self, monkeypatch):
108 |         """Test that custom headers are applied to the JIRA session."""
109 |         # Mock Jira and related dependencies
110 |         mock_jira = MagicMock()
111 |         mock_session = MagicMock()
112 |         mock_session.headers = {}
113 |         mock_jira._session = mock_session
114 | 
115 |         monkeypatch.setattr(
116 |             "mcp_atlassian.jira.client.Jira", lambda **kwargs: mock_jira
117 |         )
118 |         monkeypatch.setattr(
119 |             "mcp_atlassian.jira.client.configure_ssl_verification",
120 |             lambda **kwargs: None,
121 |         )
122 | 
123 |         custom_headers = {
124 |             "X-Corp-Auth": "token123",
125 |             "X-Dept": "engineering",
126 |             "User-Agent": "CustomJiraClient/1.0",
127 |         }
128 | 
129 |         config = JiraConfig(
130 |             url="https://test.atlassian.net",
131 |             auth_type="basic",
132 |             username="test_user",
133 |             api_token="test_token",
134 |             custom_headers=custom_headers,
135 |         )
136 | 
137 |         client = JiraClient(config=config)
138 | 
139 |         # Verify custom headers were applied to session
140 |         for header_name, header_value in custom_headers.items():
141 |             assert mock_session.headers[header_name] == header_value
142 | 
```

--------------------------------------------------------------------------------
/tests/unit/models/test_constants.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for model constants.
  2 | 
  3 | Focused tests for model constants, validating correct values and business logic.
  4 | """
  5 | 
  6 | from mcp_atlassian.models.constants import (
  7 |     # Confluence defaults
  8 |     CONFLUENCE_DEFAULT_ID,
  9 |     CONFLUENCE_DEFAULT_SPACE,
 10 |     CONFLUENCE_DEFAULT_VERSION,
 11 |     # Date/Time defaults
 12 |     DEFAULT_TIMESTAMP,
 13 |     # Common defaults
 14 |     EMPTY_STRING,
 15 |     # Jira defaults
 16 |     JIRA_DEFAULT_ID,
 17 |     JIRA_DEFAULT_ISSUE_TYPE,
 18 |     JIRA_DEFAULT_KEY,
 19 |     JIRA_DEFAULT_PRIORITY,
 20 |     JIRA_DEFAULT_PROJECT,
 21 |     JIRA_DEFAULT_STATUS,
 22 |     NONE_VALUE,
 23 |     UNASSIGNED,
 24 |     UNKNOWN,
 25 | )
 26 | 
 27 | 
 28 | class TestCommonDefaults:
 29 |     """Test suite for common default constants."""
 30 | 
 31 |     def test_string_constants_values(self):
 32 |         """Test that common string constants have expected values."""
 33 |         assert EMPTY_STRING == ""
 34 |         assert UNKNOWN == "Unknown"
 35 |         assert UNASSIGNED == "Unassigned"
 36 |         assert NONE_VALUE == "None"
 37 | 
 38 |     def test_string_constants_types(self):
 39 |         """Test that all string constants are strings."""
 40 |         assert isinstance(EMPTY_STRING, str)
 41 |         assert isinstance(UNKNOWN, str)
 42 |         assert isinstance(UNASSIGNED, str)
 43 |         assert isinstance(NONE_VALUE, str)
 44 | 
 45 | 
 46 | class TestJiraDefaults:
 47 |     """Test suite for Jira default constants."""
 48 | 
 49 |     def test_jira_id_and_key_values(self):
 50 |         """Test Jira ID and key default values."""
 51 |         assert JIRA_DEFAULT_ID == "0"
 52 |         assert JIRA_DEFAULT_KEY == "UNKNOWN-0"
 53 |         assert JIRA_DEFAULT_PROJECT == "0"
 54 | 
 55 |     def test_jira_default_dict_structures(self):
 56 |         """Test that Jira default dictionaries have correct structure."""
 57 |         # Status
 58 |         assert isinstance(JIRA_DEFAULT_STATUS, dict)
 59 |         assert JIRA_DEFAULT_STATUS == {"name": UNKNOWN, "id": JIRA_DEFAULT_ID}
 60 | 
 61 |         # Priority
 62 |         assert isinstance(JIRA_DEFAULT_PRIORITY, dict)
 63 |         assert JIRA_DEFAULT_PRIORITY == {"name": NONE_VALUE, "id": JIRA_DEFAULT_ID}
 64 | 
 65 |         # Issue Type
 66 |         assert isinstance(JIRA_DEFAULT_ISSUE_TYPE, dict)
 67 |         assert JIRA_DEFAULT_ISSUE_TYPE == {"name": UNKNOWN, "id": JIRA_DEFAULT_ID}
 68 | 
 69 |     def test_jira_key_format(self):
 70 |         """Test that Jira key follows expected format."""
 71 |         parts = JIRA_DEFAULT_KEY.split("-")
 72 |         assert len(parts) == 2
 73 |         assert parts[0] == "UNKNOWN"
 74 |         assert parts[1] == "0"
 75 | 
 76 | 
 77 | class TestConfluenceDefaults:
 78 |     """Test suite for Confluence default constants."""
 79 | 
 80 |     def test_confluence_id_value(self):
 81 |         """Test Confluence default ID value."""
 82 |         assert CONFLUENCE_DEFAULT_ID == "0"
 83 | 
 84 |     def test_confluence_default_space_structure(self):
 85 |         """Test that Confluence default space has correct structure."""
 86 |         assert isinstance(CONFLUENCE_DEFAULT_SPACE, dict)
 87 |         expected_space = {
 88 |             "key": EMPTY_STRING,
 89 |             "name": UNKNOWN,
 90 |             "id": CONFLUENCE_DEFAULT_ID,
 91 |         }
 92 |         assert CONFLUENCE_DEFAULT_SPACE == expected_space
 93 | 
 94 |     def test_confluence_default_version_structure(self):
 95 |         """Test that Confluence default version has correct structure."""
 96 |         assert isinstance(CONFLUENCE_DEFAULT_VERSION, dict)
 97 |         expected_version = {"number": 0, "when": EMPTY_STRING}
 98 |         assert CONFLUENCE_DEFAULT_VERSION == expected_version
 99 |         assert isinstance(CONFLUENCE_DEFAULT_VERSION["number"], int)
100 | 
101 | 
102 | class TestDateTimeDefaults:
103 |     """Test suite for date/time default constants."""
104 | 
105 |     def test_default_timestamp_format(self):
106 |         """Test that DEFAULT_TIMESTAMP has expected format."""
107 |         assert DEFAULT_TIMESTAMP == "1970-01-01T00:00:00.000+0000"
108 |         assert isinstance(DEFAULT_TIMESTAMP, str)
109 |         assert DEFAULT_TIMESTAMP.startswith("1970-01-01T")
110 |         assert "+0000" in DEFAULT_TIMESTAMP
111 | 
112 | 
113 | class TestCrossReferenceConsistency:
114 |     """Test suite for consistency between related constants."""
115 | 
116 |     def test_id_consistency(self):
117 |         """Test that default IDs are consistent across structures."""
118 |         assert JIRA_DEFAULT_STATUS["id"] == JIRA_DEFAULT_ID
119 |         assert JIRA_DEFAULT_PRIORITY["id"] == JIRA_DEFAULT_ID
120 |         assert JIRA_DEFAULT_ISSUE_TYPE["id"] == JIRA_DEFAULT_ID
121 |         assert CONFLUENCE_DEFAULT_SPACE["id"] == CONFLUENCE_DEFAULT_ID
122 | 
123 |     def test_semantic_usage_consistency(self):
124 |         """Test that semantically similar fields use consistent values."""
125 |         # UNKNOWN used for required fields with unknown values
126 |         assert JIRA_DEFAULT_STATUS["name"] == UNKNOWN
127 |         assert JIRA_DEFAULT_ISSUE_TYPE["name"] == UNKNOWN
128 |         assert CONFLUENCE_DEFAULT_SPACE["name"] == UNKNOWN
129 | 
130 |         # NONE_VALUE used for nullable/optional fields
131 |         assert JIRA_DEFAULT_PRIORITY["name"] == NONE_VALUE
132 | 
133 |         # EMPTY_STRING used for optional string fields
134 |         assert CONFLUENCE_DEFAULT_SPACE["key"] == EMPTY_STRING
135 |         assert CONFLUENCE_DEFAULT_VERSION["when"] == EMPTY_STRING
136 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/environment.py:
--------------------------------------------------------------------------------

```python
  1 | """Utility functions related to environment checking."""
  2 | 
  3 | import logging
  4 | import os
  5 | 
  6 | from .urls import is_atlassian_cloud_url
  7 | 
  8 | logger = logging.getLogger("mcp-atlassian.utils.environment")
  9 | 
 10 | 
 11 | def get_available_services() -> dict[str, bool | None]:
 12 |     """Determine which services are available based on environment variables."""
 13 |     confluence_url = os.getenv("CONFLUENCE_URL")
 14 |     confluence_is_setup = False
 15 |     if confluence_url:
 16 |         is_cloud = is_atlassian_cloud_url(confluence_url)
 17 | 
 18 |         # OAuth check (highest precedence, applies to Cloud)
 19 |         if all(
 20 |             [
 21 |                 os.getenv("ATLASSIAN_OAUTH_CLIENT_ID"),
 22 |                 os.getenv("ATLASSIAN_OAUTH_CLIENT_SECRET"),
 23 |                 os.getenv("ATLASSIAN_OAUTH_REDIRECT_URI"),
 24 |                 os.getenv("ATLASSIAN_OAUTH_SCOPE"),
 25 |                 os.getenv(
 26 |                     "ATLASSIAN_OAUTH_CLOUD_ID"
 27 |                 ),  # CLOUD_ID is essential for OAuth client init
 28 |             ]
 29 |         ):
 30 |             confluence_is_setup = True
 31 |             logger.info(
 32 |                 "Using Confluence OAuth 2.0 (3LO) authentication (Cloud-only features)"
 33 |             )
 34 |         elif all(
 35 |             [
 36 |                 os.getenv("ATLASSIAN_OAUTH_ACCESS_TOKEN"),
 37 |                 os.getenv("ATLASSIAN_OAUTH_CLOUD_ID"),
 38 |             ]
 39 |         ):
 40 |             confluence_is_setup = True
 41 |             logger.info(
 42 |                 "Using Confluence OAuth 2.0 (3LO) authentication (Cloud-only features) "
 43 |                 "with provided access token"
 44 |             )
 45 |         elif is_cloud:  # Cloud non-OAuth
 46 |             if all(
 47 |                 [
 48 |                     os.getenv("CONFLUENCE_USERNAME"),
 49 |                     os.getenv("CONFLUENCE_API_TOKEN"),
 50 |                 ]
 51 |             ):
 52 |                 confluence_is_setup = True
 53 |                 logger.info("Using Confluence Cloud Basic Authentication (API Token)")
 54 |         else:  # Server/Data Center non-OAuth
 55 |             if os.getenv("CONFLUENCE_PERSONAL_TOKEN") or (
 56 |                 os.getenv("CONFLUENCE_USERNAME") and os.getenv("CONFLUENCE_API_TOKEN")
 57 |             ):
 58 |                 confluence_is_setup = True
 59 |                 logger.info(
 60 |                     "Using Confluence Server/Data Center authentication (PAT or Basic Auth)"
 61 |                 )
 62 |     elif os.getenv("ATLASSIAN_OAUTH_ENABLE", "").lower() in ("true", "1", "yes"):
 63 |         confluence_is_setup = True
 64 |         logger.info(
 65 |             "Using Confluence minimal OAuth configuration - expecting user-provided tokens via headers"
 66 |         )
 67 | 
 68 |     jira_url = os.getenv("JIRA_URL")
 69 |     jira_is_setup = False
 70 |     if jira_url:
 71 |         is_cloud = is_atlassian_cloud_url(jira_url)
 72 | 
 73 |         # OAuth check (highest precedence, applies to Cloud)
 74 |         if all(
 75 |             [
 76 |                 os.getenv("ATLASSIAN_OAUTH_CLIENT_ID"),
 77 |                 os.getenv("ATLASSIAN_OAUTH_CLIENT_SECRET"),
 78 |                 os.getenv("ATLASSIAN_OAUTH_REDIRECT_URI"),
 79 |                 os.getenv("ATLASSIAN_OAUTH_SCOPE"),
 80 |                 os.getenv("ATLASSIAN_OAUTH_CLOUD_ID"),
 81 |             ]
 82 |         ):
 83 |             jira_is_setup = True
 84 |             logger.info(
 85 |                 "Using Jira OAuth 2.0 (3LO) authentication (Cloud-only features)"
 86 |             )
 87 |         elif all(
 88 |             [
 89 |                 os.getenv("ATLASSIAN_OAUTH_ACCESS_TOKEN"),
 90 |                 os.getenv("ATLASSIAN_OAUTH_CLOUD_ID"),
 91 |             ]
 92 |         ):
 93 |             jira_is_setup = True
 94 |             logger.info(
 95 |                 "Using Jira OAuth 2.0 (3LO) authentication (Cloud-only features) "
 96 |                 "with provided access token"
 97 |             )
 98 |         elif is_cloud:  # Cloud non-OAuth
 99 |             if all(
100 |                 [
101 |                     os.getenv("JIRA_USERNAME"),
102 |                     os.getenv("JIRA_API_TOKEN"),
103 |                 ]
104 |             ):
105 |                 jira_is_setup = True
106 |                 logger.info("Using Jira Cloud Basic Authentication (API Token)")
107 |         else:  # Server/Data Center non-OAuth
108 |             if os.getenv("JIRA_PERSONAL_TOKEN") or (
109 |                 os.getenv("JIRA_USERNAME") and os.getenv("JIRA_API_TOKEN")
110 |             ):
111 |                 jira_is_setup = True
112 |                 logger.info(
113 |                     "Using Jira Server/Data Center authentication (PAT or Basic Auth)"
114 |                 )
115 |     elif os.getenv("ATLASSIAN_OAUTH_ENABLE", "").lower() in ("true", "1", "yes"):
116 |         jira_is_setup = True
117 |         logger.info(
118 |             "Using Jira minimal OAuth configuration - expecting user-provided tokens via headers"
119 |         )
120 | 
121 |     if not confluence_is_setup:
122 |         logger.info(
123 |             "Confluence is not configured or required environment variables are missing."
124 |         )
125 |     if not jira_is_setup:
126 |         logger.info(
127 |             "Jira is not configured or required environment variables are missing."
128 |         )
129 | 
130 |     return {"confluence": confluence_is_setup, "jira": jira_is_setup}
131 | 
```

--------------------------------------------------------------------------------
/tests/unit/confluence/test_v2_adapter.py:
--------------------------------------------------------------------------------

```python
  1 | """Unit tests for ConfluenceV2Adapter class."""
  2 | 
  3 | from unittest.mock import MagicMock, Mock
  4 | 
  5 | import pytest
  6 | import requests
  7 | from requests.exceptions import HTTPError
  8 | 
  9 | from mcp_atlassian.confluence.v2_adapter import ConfluenceV2Adapter
 10 | 
 11 | 
 12 | class TestConfluenceV2Adapter:
 13 |     """Test cases for ConfluenceV2Adapter."""
 14 | 
 15 |     @pytest.fixture
 16 |     def mock_session(self):
 17 |         """Create a mock session."""
 18 |         return MagicMock(spec=requests.Session)
 19 | 
 20 |     @pytest.fixture
 21 |     def v2_adapter(self, mock_session):
 22 |         """Create a ConfluenceV2Adapter instance."""
 23 |         return ConfluenceV2Adapter(
 24 |             session=mock_session, base_url="https://example.atlassian.net/wiki"
 25 |         )
 26 | 
 27 |     def test_get_page_success(self, v2_adapter, mock_session):
 28 |         """Test successful page retrieval."""
 29 |         # Mock the v2 API response
 30 |         mock_response = Mock()
 31 |         mock_response.status_code = 200
 32 |         mock_response.json.return_value = {
 33 |             "id": "123456",
 34 |             "status": "current",
 35 |             "title": "Test Page",
 36 |             "spaceId": "789",
 37 |             "version": {"number": 5},
 38 |             "body": {
 39 |                 "storage": {"value": "<p>Test content</p>", "representation": "storage"}
 40 |             },
 41 |             "_links": {"webui": "/pages/viewpage.action?pageId=123456"},
 42 |         }
 43 |         mock_session.get.return_value = mock_response
 44 | 
 45 |         # Mock space key lookup
 46 |         space_response = Mock()
 47 |         space_response.status_code = 200
 48 |         space_response.json.return_value = {"key": "TEST"}
 49 |         mock_session.get.side_effect = [mock_response, space_response]
 50 | 
 51 |         # Call the method
 52 |         result = v2_adapter.get_page("123456")
 53 | 
 54 |         # Verify the API call
 55 |         assert mock_session.get.call_count == 2
 56 |         mock_session.get.assert_any_call(
 57 |             "https://example.atlassian.net/wiki/api/v2/pages/123456",
 58 |             params={"body-format": "storage"},
 59 |         )
 60 | 
 61 |         # Verify the response format
 62 |         assert result["id"] == "123456"
 63 |         assert result["type"] == "page"
 64 |         assert result["title"] == "Test Page"
 65 |         assert result["space"]["key"] == "TEST"
 66 |         assert result["space"]["id"] == "789"
 67 |         assert result["version"]["number"] == 5
 68 |         assert result["body"]["storage"]["value"] == "<p>Test content</p>"
 69 |         assert result["body"]["storage"]["representation"] == "storage"
 70 | 
 71 |     def test_get_page_not_found(self, v2_adapter, mock_session):
 72 |         """Test page retrieval when page doesn't exist."""
 73 |         # Mock a 404 response
 74 |         mock_response = Mock()
 75 |         mock_response.status_code = 404
 76 |         mock_response.text = "Page not found"
 77 |         mock_response.raise_for_status.side_effect = HTTPError(response=mock_response)
 78 |         mock_session.get.return_value = mock_response
 79 | 
 80 |         # Call the method and expect an exception
 81 |         with pytest.raises(ValueError, match="Failed to get page '999999'"):
 82 |             v2_adapter.get_page("999999")
 83 | 
 84 |     def test_get_page_with_minimal_response(self, v2_adapter, mock_session):
 85 |         """Test page retrieval with minimal v2 response."""
 86 |         # Mock the v2 API response without optional fields
 87 |         mock_response = Mock()
 88 |         mock_response.status_code = 200
 89 |         mock_response.json.return_value = {
 90 |             "id": "123456",
 91 |             "status": "current",
 92 |             "title": "Minimal Page",
 93 |         }
 94 |         mock_session.get.return_value = mock_response
 95 | 
 96 |         # Call the method
 97 |         result = v2_adapter.get_page("123456")
 98 | 
 99 |         # Verify the response handles missing fields gracefully
100 |         assert result["id"] == "123456"
101 |         assert result["type"] == "page"
102 |         assert result["title"] == "Minimal Page"
103 |         assert result["space"]["key"] == "unknown"  # Fallback when no spaceId
104 |         assert result["version"]["number"] == 1  # Default version
105 | 
106 |     def test_get_page_network_error(self, v2_adapter, mock_session):
107 |         """Test page retrieval with network error."""
108 |         # Mock a network error
109 |         mock_session.get.side_effect = requests.RequestException("Network error")
110 | 
111 |         # Call the method and expect an exception
112 |         with pytest.raises(ValueError, match="Failed to get page '123456'"):
113 |             v2_adapter.get_page("123456")
114 | 
115 |     def test_get_page_with_expand_parameter(self, v2_adapter, mock_session):
116 |         """Test that expand parameter is accepted but not used."""
117 |         # Mock the v2 API response
118 |         mock_response = Mock()
119 |         mock_response.status_code = 200
120 |         mock_response.json.return_value = {
121 |             "id": "123456",
122 |             "status": "current",
123 |             "title": "Test Page",
124 |         }
125 |         mock_session.get.return_value = mock_response
126 | 
127 |         # Call with expand parameter
128 |         result = v2_adapter.get_page("123456", expand="body.storage,version")
129 | 
130 |         # Verify the API call doesn't include expand in params
131 |         mock_session.get.assert_called_once_with(
132 |             "https://example.atlassian.net/wiki/api/v2/pages/123456",
133 |             params={"body-format": "storage"},
134 |         )
135 | 
136 |         # Verify we still get a result
137 |         assert result["id"] == "123456"
138 | 
```

--------------------------------------------------------------------------------
/tests/unit/test_main_transport_selection.py:
--------------------------------------------------------------------------------

```python
  1 | """Unit tests for transport selection and execution.
  2 | 
  3 | These tests verify that:
  4 | 1. All transports use direct execution (no stdin monitoring)
  5 | 2. Transport selection logic works correctly (CLI vs environment)
  6 | 3. Error handling is preserved
  7 | """
  8 | 
  9 | from unittest.mock import AsyncMock, MagicMock, patch
 10 | 
 11 | import pytest
 12 | 
 13 | from mcp_atlassian import main
 14 | 
 15 | 
 16 | class TestMainTransportSelection:
 17 |     """Test the main function's transport-specific execution logic."""
 18 | 
 19 |     @pytest.fixture
 20 |     def mock_server(self):
 21 |         """Create a mock server instance."""
 22 |         server = MagicMock()
 23 |         server.run_async = AsyncMock(return_value=None)
 24 |         return server
 25 | 
 26 |     @pytest.fixture
 27 |     def mock_asyncio_run(self):
 28 |         """Mock asyncio.run to capture what coroutine is executed."""
 29 |         with patch("asyncio.run") as mock_run:
 30 |             # Store the coroutine for inspection
 31 |             mock_run.side_effect = lambda coro: setattr(mock_run, "_called_with", coro)
 32 |             yield mock_run
 33 | 
 34 |     @pytest.mark.parametrize("transport", ["stdio", "sse", "streamable-http"])
 35 |     def test_all_transports_use_direct_execution(
 36 |         self, mock_server, mock_asyncio_run, transport
 37 |     ):
 38 |         """Verify all transports use direct execution without stdin monitoring.
 39 | 
 40 |         This is a regression test for issues #519 and #524.
 41 |         """
 42 |         with patch("mcp_atlassian.servers.main.AtlassianMCP", return_value=mock_server):
 43 |             with patch.dict("os.environ", {"TRANSPORT": transport}):
 44 |                 with patch("sys.argv", ["mcp-atlassian"]):
 45 |                     try:
 46 |                         main()
 47 |                     except SystemExit:
 48 |                         pass
 49 | 
 50 |                     # Verify asyncio.run was called
 51 |                     assert mock_asyncio_run.called
 52 | 
 53 |                     # Get the coroutine info
 54 |                     called_coro = mock_asyncio_run._called_with
 55 |                     coro_repr = repr(called_coro)
 56 | 
 57 |                     # All transports must use direct execution
 58 |                     assert "run_with_stdio_monitoring" not in coro_repr
 59 |                     assert "run_async" in coro_repr or hasattr(called_coro, "cr_code")
 60 | 
 61 |     def test_cli_overrides_env_transport(self, mock_server, mock_asyncio_run):
 62 |         """Test that CLI transport argument overrides environment variable."""
 63 |         with patch("mcp_atlassian.servers.main.AtlassianMCP", return_value=mock_server):
 64 |             with patch.dict("os.environ", {"TRANSPORT": "sse"}):
 65 |                 # Simulate CLI args with --transport stdio
 66 |                 with patch("sys.argv", ["mcp-atlassian", "--transport", "stdio"]):
 67 |                     try:
 68 |                         main()
 69 |                     except SystemExit:
 70 |                         pass
 71 | 
 72 |                     # All transports now use direct execution
 73 |                     called_coro = mock_asyncio_run._called_with
 74 |                     coro_repr = repr(called_coro)
 75 |                     assert "run_async" in coro_repr or hasattr(called_coro, "cr_code")
 76 | 
 77 |     def test_signal_handlers_always_setup(self, mock_server):
 78 |         """Test that signal handlers are set up regardless of transport."""
 79 |         with patch("mcp_atlassian.servers.main.AtlassianMCP", return_value=mock_server):
 80 |             with patch("asyncio.run"):
 81 |                 # Patch where it's imported in the main module
 82 |                 with patch("mcp_atlassian.setup_signal_handlers") as mock_setup:
 83 |                     with patch.dict("os.environ", {"TRANSPORT": "stdio"}):
 84 |                         with patch("sys.argv", ["mcp-atlassian"]):
 85 |                             try:
 86 |                                 main()
 87 |                             except SystemExit:
 88 |                                 pass
 89 | 
 90 |                             # Signal handlers should always be set up
 91 |                             mock_setup.assert_called_once()
 92 | 
 93 |     def test_error_handling_preserved(self, mock_server):
 94 |         """Test that error handling works correctly for all transports."""
 95 |         # Make the server's run_async raise an exception when awaited
 96 |         error = RuntimeError("Server error")
 97 | 
 98 |         async def failing_run_async(**kwargs):
 99 |             raise error
100 | 
101 |         mock_server.run_async = failing_run_async
102 | 
103 |         with patch("mcp_atlassian.servers.main.AtlassianMCP", return_value=mock_server):
104 |             with patch("asyncio.run") as mock_run:
105 |                 # Simulate the exception propagating through asyncio.run
106 |                 mock_run.side_effect = error
107 | 
108 |                 with patch.dict("os.environ", {"TRANSPORT": "stdio"}):
109 |                     with patch("sys.argv", ["mcp-atlassian"]):
110 |                         # The main function logs the error and exits with code 1
111 |                         with patch("sys.exit") as mock_exit:
112 |                             main()
113 |                             # Verify error was handled - sys.exit called with 1 for error
114 |                             # and then with 0 in the finally block
115 |                             assert mock_exit.call_count == 2
116 |                             assert mock_exit.call_args_list[0][0][0] == 1  # Error exit
117 |                             assert (
118 |                                 mock_exit.call_args_list[1][0][0] == 0
119 |                             )  # Finally exit
120 | 
```

--------------------------------------------------------------------------------
/tests/unit/servers/test_main_server.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for the main MCP server implementation."""
  2 | 
  3 | from unittest.mock import AsyncMock, MagicMock, patch
  4 | 
  5 | import httpx
  6 | import pytest
  7 | from starlette.requests import Request
  8 | from starlette.responses import JSONResponse
  9 | 
 10 | from mcp_atlassian.servers.main import UserTokenMiddleware, main_mcp
 11 | 
 12 | 
 13 | @pytest.mark.anyio
 14 | async def test_run_server_stdio():
 15 |     """Test that main_mcp.run_async is called with stdio transport."""
 16 |     with patch.object(main_mcp, "run_async") as mock_run_async:
 17 |         mock_run_async.return_value = None
 18 |         await main_mcp.run_async(transport="stdio")
 19 |         mock_run_async.assert_called_once_with(transport="stdio")
 20 | 
 21 | 
 22 | @pytest.mark.anyio
 23 | async def test_run_server_sse():
 24 |     """Test that main_mcp.run_async is called with sse transport and correct port."""
 25 |     with patch.object(main_mcp, "run_async") as mock_run_async:
 26 |         mock_run_async.return_value = None
 27 |         test_port = 9000
 28 |         await main_mcp.run_async(transport="sse", port=test_port)
 29 |         mock_run_async.assert_called_once_with(transport="sse", port=test_port)
 30 | 
 31 | 
 32 | @pytest.mark.anyio
 33 | async def test_run_server_streamable_http():
 34 |     """Test that main_mcp.run_async is called with streamable-http transport and correct parameters."""
 35 |     with patch.object(main_mcp, "run_async") as mock_run_async:
 36 |         mock_run_async.return_value = None
 37 |         test_port = 9001
 38 |         test_host = "127.0.0.1"
 39 |         test_path = "/custom_mcp"
 40 |         await main_mcp.run_async(
 41 |             transport="streamable-http", port=test_port, host=test_host, path=test_path
 42 |         )
 43 |         mock_run_async.assert_called_once_with(
 44 |             transport="streamable-http", port=test_port, host=test_host, path=test_path
 45 |         )
 46 | 
 47 | 
 48 | @pytest.mark.anyio
 49 | async def test_run_server_invalid_transport():
 50 |     """Test that run_server raises ValueError for invalid transport."""
 51 |     # We don't need to patch run_async here as the error occurs before it's called
 52 |     with pytest.raises(ValueError) as excinfo:
 53 |         await main_mcp.run_async(transport="invalid")  # type: ignore
 54 | 
 55 |     assert "Unknown transport" in str(excinfo.value)
 56 |     assert "invalid" in str(excinfo.value)
 57 | 
 58 | 
 59 | @pytest.mark.anyio
 60 | async def test_health_check_endpoint():
 61 |     """Test the health check endpoint returns 200 and correct JSON response."""
 62 |     app = main_mcp.sse_app()
 63 |     transport = httpx.ASGITransport(app=app)
 64 |     async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
 65 |         response = await client.get("/healthz")
 66 | 
 67 |         assert response.status_code == 200
 68 |         assert response.json() == {"status": "ok"}
 69 | 
 70 | 
 71 | @pytest.mark.anyio
 72 | async def test_sse_app_health_check_endpoint():
 73 |     """Test the /healthz endpoint on the SSE app returns 200 and correct JSON response."""
 74 |     app = main_mcp.sse_app()
 75 |     transport = httpx.ASGITransport(app=app)
 76 |     async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
 77 |         response = await client.get("/healthz")
 78 |         assert response.status_code == 200
 79 |         assert response.json() == {"status": "ok"}
 80 | 
 81 | 
 82 | @pytest.mark.anyio
 83 | async def test_streamable_http_app_health_check_endpoint():
 84 |     """Test the /healthz endpoint on the Streamable HTTP app returns 200 and correct JSON response."""
 85 |     app = main_mcp.streamable_http_app()
 86 |     transport = httpx.ASGITransport(app=app)
 87 |     async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
 88 |         response = await client.get("/healthz")
 89 |         assert response.status_code == 200
 90 |         assert response.json() == {"status": "ok"}
 91 | 
 92 | 
 93 | class TestUserTokenMiddleware:
 94 |     """Tests for the UserTokenMiddleware class."""
 95 | 
 96 |     @pytest.fixture
 97 |     def middleware(self):
 98 |         """Create a UserTokenMiddleware instance for testing."""
 99 |         mock_app = AsyncMock()
100 |         # Create a mock MCP server to avoid warnings
101 |         mock_mcp_server = MagicMock()
102 |         mock_mcp_server.settings.streamable_http_path = "/mcp"
103 |         return UserTokenMiddleware(mock_app, mcp_server_ref=mock_mcp_server)
104 | 
105 |     @pytest.fixture
106 |     def mock_request(self):
107 |         """Create a mock request for testing."""
108 |         request = MagicMock(spec=Request)
109 |         request.url.path = "/mcp"
110 |         request.method = "POST"
111 |         request.headers = {}
112 |         # Create a real state object that can be modified
113 |         from types import SimpleNamespace
114 | 
115 |         request.state = SimpleNamespace()
116 |         return request
117 | 
118 |     @pytest.fixture
119 |     def mock_call_next(self):
120 |         """Create a mock call_next function."""
121 |         mock_response = JSONResponse({"test": "response"})
122 |         call_next = AsyncMock(return_value=mock_response)
123 |         return call_next
124 | 
125 |     @pytest.mark.anyio
126 |     async def test_cloud_id_header_extraction_success(
127 |         self, middleware, mock_request, mock_call_next
128 |     ):
129 |         """Test successful cloud ID header extraction."""
130 |         # Setup request with cloud ID header
131 |         mock_request.headers = {
132 |             "Authorization": "Bearer test-token",
133 |             "X-Atlassian-Cloud-Id": "test-cloud-id-123",
134 |         }
135 | 
136 |         result = await middleware.dispatch(mock_request, mock_call_next)
137 | 
138 |         # Verify cloud ID was extracted and stored in request state
139 |         assert hasattr(mock_request.state, "user_atlassian_cloud_id")
140 |         assert mock_request.state.user_atlassian_cloud_id == "test-cloud-id-123"
141 | 
142 |         # Verify the request was processed normally
143 |         mock_call_next.assert_called_once_with(mock_request)
144 |         assert result is not None
145 | 
```

--------------------------------------------------------------------------------
/tests/unit/confluence/test_custom_headers.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for Confluence custom headers functionality."""
  2 | 
  3 | import os
  4 | from unittest.mock import MagicMock, patch
  5 | 
  6 | from mcp_atlassian.confluence.client import ConfluenceClient
  7 | from mcp_atlassian.confluence.config import ConfluenceConfig
  8 | 
  9 | 
 10 | class TestConfluenceConfigCustomHeaders:
 11 |     """Test ConfluenceConfig parsing of custom headers."""
 12 | 
 13 |     def test_no_custom_headers(self):
 14 |         """Test ConfluenceConfig when no custom headers are configured."""
 15 |         with patch.dict(
 16 |             os.environ,
 17 |             {
 18 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
 19 |                 "CONFLUENCE_USERNAME": "test_user",
 20 |                 "CONFLUENCE_API_TOKEN": "test_token",
 21 |             },
 22 |             clear=True,
 23 |         ):
 24 |             config = ConfluenceConfig.from_env()
 25 |             assert config.custom_headers == {}
 26 | 
 27 |     def test_service_specific_headers_only(self):
 28 |         """Test ConfluenceConfig parsing of service-specific headers only."""
 29 |         with patch.dict(
 30 |             os.environ,
 31 |             {
 32 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
 33 |                 "CONFLUENCE_USERNAME": "test_user",
 34 |                 "CONFLUENCE_API_TOKEN": "test_token",
 35 |                 "CONFLUENCE_CUSTOM_HEADERS": "X-Confluence-Specific=confluence_value,X-Service=service_value",
 36 |             },
 37 |             clear=True,
 38 |         ):
 39 |             config = ConfluenceConfig.from_env()
 40 |             expected = {
 41 |                 "X-Confluence-Specific": "confluence_value",
 42 |                 "X-Service": "service_value",
 43 |             }
 44 |             assert config.custom_headers == expected
 45 | 
 46 |     def test_malformed_headers_are_ignored(self):
 47 |         """Test that malformed headers are ignored gracefully."""
 48 |         with patch.dict(
 49 |             os.environ,
 50 |             {
 51 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
 52 |                 "CONFLUENCE_USERNAME": "test_user",
 53 |                 "CONFLUENCE_API_TOKEN": "test_token",
 54 |                 "CONFLUENCE_CUSTOM_HEADERS": "malformed-header,X-Valid=valid_value,another-malformed",
 55 |             },
 56 |             clear=True,
 57 |         ):
 58 |             config = ConfluenceConfig.from_env()
 59 |             expected = {"X-Valid": "valid_value"}
 60 |             assert config.custom_headers == expected
 61 | 
 62 |     def test_empty_header_strings(self):
 63 |         """Test handling of empty header strings."""
 64 |         with patch.dict(
 65 |             os.environ,
 66 |             {
 67 |                 "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
 68 |                 "CONFLUENCE_USERNAME": "test_user",
 69 |                 "CONFLUENCE_API_TOKEN": "test_token",
 70 |                 "CONFLUENCE_CUSTOM_HEADERS": "   ",
 71 |             },
 72 |             clear=True,
 73 |         ):
 74 |             config = ConfluenceConfig.from_env()
 75 |             assert config.custom_headers == {}
 76 | 
 77 | 
 78 | class TestConfluenceClientCustomHeaders:
 79 |     """Test ConfluenceClient custom headers application."""
 80 | 
 81 |     def test_no_custom_headers_applied(self, monkeypatch):
 82 |         """Test that no headers are applied when none are configured."""
 83 |         # Mock Confluence and related dependencies
 84 |         mock_confluence = MagicMock()
 85 |         mock_session = MagicMock()
 86 |         mock_session.headers = {}
 87 |         mock_confluence._session = mock_session
 88 | 
 89 |         monkeypatch.setattr(
 90 |             "mcp_atlassian.confluence.client.Confluence",
 91 |             lambda **kwargs: mock_confluence,
 92 |         )
 93 |         monkeypatch.setattr(
 94 |             "mcp_atlassian.confluence.client.configure_ssl_verification",
 95 |             lambda **kwargs: None,
 96 |         )
 97 |         monkeypatch.setattr(
 98 |             "mcp_atlassian.preprocessing.confluence.ConfluencePreprocessor",
 99 |             lambda **kwargs: MagicMock(),
100 |         )
101 | 
102 |         config = ConfluenceConfig(
103 |             url="https://test.atlassian.net/wiki",
104 |             auth_type="basic",
105 |             username="test_user",
106 |             api_token="test_token",
107 |             custom_headers={},
108 |         )
109 | 
110 |         client = ConfluenceClient(config=config)
111 | 
112 |         # Verify no custom headers were applied
113 |         assert mock_session.headers == {}
114 | 
115 |     def test_custom_headers_applied_to_session(self, monkeypatch):
116 |         """Test that custom headers are applied to the Confluence session."""
117 |         # Mock Confluence and related dependencies
118 |         mock_confluence = MagicMock()
119 |         mock_session = MagicMock()
120 |         mock_session.headers = {}
121 |         mock_confluence._session = mock_session
122 | 
123 |         monkeypatch.setattr(
124 |             "mcp_atlassian.confluence.client.Confluence",
125 |             lambda **kwargs: mock_confluence,
126 |         )
127 |         monkeypatch.setattr(
128 |             "mcp_atlassian.confluence.client.configure_ssl_verification",
129 |             lambda **kwargs: None,
130 |         )
131 |         monkeypatch.setattr(
132 |             "mcp_atlassian.preprocessing.confluence.ConfluencePreprocessor",
133 |             lambda **kwargs: MagicMock(),
134 |         )
135 | 
136 |         custom_headers = {
137 |             "X-Corp-Auth": "token123",
138 |             "X-Dept": "engineering",
139 |             "User-Agent": "CustomConfluenceClient/1.0",
140 |         }
141 | 
142 |         config = ConfluenceConfig(
143 |             url="https://test.atlassian.net/wiki",
144 |             auth_type="basic",
145 |             username="test_user",
146 |             api_token="test_token",
147 |             custom_headers=custom_headers,
148 |         )
149 | 
150 |         client = ConfluenceClient(config=config)
151 | 
152 |         # Verify custom headers were applied to session
153 |         for header_name, header_value in custom_headers.items():
154 |             assert mock_session.headers[header_name] == header_value
155 | 
```

--------------------------------------------------------------------------------
/scripts/test_with_real_data.sh:
--------------------------------------------------------------------------------

```bash
  1 | #!/bin/bash
  2 | 
  3 | # Unified script for testing with real Atlassian data
  4 | # Supports testing models, API, or both
  5 | 
  6 | # Default settings
  7 | TEST_TYPE="all"  # Can be "all", "models", or "api"
  8 | VERBOSITY="-v"   # Verbosity level
  9 | RUN_WRITE_TESTS=false
 10 | FILTER=""        # Test filter using pytest's -k option
 11 | 
 12 | # Parse command line arguments
 13 | while [[ $# -gt 0 ]]; do
 14 |   case $1 in
 15 |     --models-only)
 16 |       TEST_TYPE="models"
 17 |       shift
 18 |       ;;
 19 |     --api-only)
 20 |       TEST_TYPE="api"
 21 |       shift
 22 |       ;;
 23 |     --all)
 24 |       TEST_TYPE="all"
 25 |       shift
 26 |       ;;
 27 |     --quiet)
 28 |       VERBOSITY=""
 29 |       shift
 30 |       ;;
 31 |     --verbose)
 32 |       VERBOSITY="-vv"
 33 |       shift
 34 |       ;;
 35 |     --with-write-tests)
 36 |       RUN_WRITE_TESTS=true
 37 |       shift
 38 |       ;;
 39 |     -k)
 40 |       FILTER="-k \"$2\""
 41 |       shift
 42 |       shift
 43 |       ;;
 44 |     --help)
 45 |       echo "Usage: $0 [options]"
 46 |       echo "Options:"
 47 |       echo "  --models-only          Test only Pydantic models"
 48 |       echo "  --api-only             Test only API integration"
 49 |       echo "  --all                  Test both models and API (default)"
 50 |       echo "  --quiet                Minimal output"
 51 |       echo "  --verbose              More detailed output"
 52 |       echo "  --with-write-tests     Include tests that modify data (including TextContent validation)"
 53 |       echo "  -k \"PATTERN\"         Only run tests matching the given pattern (uses pytest's -k option)"
 54 |       echo "  --help                 Show this help message"
 55 |       exit 0
 56 |       ;;
 57 |     *)
 58 |       echo "Unknown option: $1"
 59 |       echo "Use --help for usage information"
 60 |       exit 1
 61 |       ;;
 62 |   esac
 63 | done
 64 | 
 65 | # Check if .env file exists
 66 | if [ ! -f ".env" ]; then
 67 |     echo "Warning: .env file not found. Tests will be skipped if environment variables are not set."
 68 | else
 69 |     # Load environment variables from .env
 70 |     source .env
 71 | fi
 72 | 
 73 | # Set environment variable to enable real data testing
 74 | export USE_REAL_DATA=true
 75 | 
 76 | # Set specific test IDs for API validation tests
 77 | # These will be used if they're set, otherwise tests will be skipped
 78 | export JIRA_TEST_ISSUE_KEY="${JIRA_TEST_ISSUE_KEY:-}"
 79 | export JIRA_TEST_EPIC_KEY="${JIRA_TEST_EPIC_KEY:-}"
 80 | export CONFLUENCE_TEST_PAGE_ID="${CONFLUENCE_TEST_PAGE_ID:-}"
 81 | export JIRA_TEST_PROJECT_KEY="${JIRA_TEST_PROJECT_KEY:-}"
 82 | export CONFLUENCE_TEST_SPACE_KEY="${CONFLUENCE_TEST_SPACE_KEY:-}"
 83 | 
 84 | # Check required environment variables and warn if any are missing
 85 | required_vars=(
 86 |     "JIRA_URL"
 87 |     "JIRA_USERNAME"
 88 |     "JIRA_API_TOKEN"
 89 |     "CONFLUENCE_URL"
 90 |     "CONFLUENCE_USERNAME"
 91 |     "CONFLUENCE_API_TOKEN"
 92 | )
 93 | 
 94 | missing_vars=0
 95 | for var in "${required_vars[@]}"; do
 96 |     if [ -z "${!var}" ]; then
 97 |         echo "Warning: Environment variable $var is not set. Some tests will be skipped."
 98 |         missing_vars=$((missing_vars+1))
 99 |     fi
100 | done
101 | 
102 | if [ $missing_vars -gt 0 ]; then
103 |     echo "Found $missing_vars missing required variables. Tests requiring these variables will be skipped."
104 |     echo "You can set these in your .env file to run all tests."
105 | fi
106 | 
107 | # Function to run model tests
108 | run_model_tests() {
109 |     echo "Running Pydantic model tests with real data..."
110 |     echo ""
111 | 
112 |     echo "===== Base Model Tests ====="
113 |     uv run pytest tests/unit/models/test_base_models.py $VERBOSITY
114 | 
115 |     echo ""
116 |     echo "===== Jira Model Tests ====="
117 |     uv run pytest tests/unit/models/test_jira_models.py::TestRealJiraData $VERBOSITY
118 | 
119 |     echo ""
120 |     echo "===== Confluence Model Tests ====="
121 |     uv run pytest tests/unit/models/test_confluence_models.py::TestRealConfluenceData $VERBOSITY
122 | }
123 | 
124 | # Function to run API tests
125 | run_api_tests() {
126 |     echo ""
127 |     echo "===== API Read-Only Tests ====="
128 | 
129 |     # If a filter is provided, run all tests with that filter
130 |     if [[ -n "$FILTER" ]]; then
131 |         echo "Running tests with filter: $FILTER"
132 |         eval "uv run pytest tests/test_real_api_validation.py $VERBOSITY $FILTER"
133 |         return
134 |     fi
135 | 
136 |     # Otherwise run specific tests based on write/read only setting
137 |     # Run the read-only tests
138 |     uv run pytest tests/test_real_api_validation.py::test_jira_get_issue tests/test_real_api_validation.py::test_jira_get_issue_with_fields tests/test_real_api_validation.py::test_jira_get_epic_issues tests/test_real_api_validation.py::test_confluence_get_page_content $VERBOSITY
139 | 
140 |     if [[ "$RUN_WRITE_TESTS" == "true" ]]; then
141 |         echo ""
142 |         echo "===== API Write Operation Tests ====="
143 |         echo "WARNING: These tests will create and modify data in your Atlassian instance."
144 |         echo "Press Ctrl+C now to cancel, or wait 5 seconds to continue..."
145 |         sleep 5
146 | 
147 |         # Run the write operation tests
148 |         uv run pytest tests/test_real_api_validation.py::test_jira_create_issue tests/test_real_api_validation.py::test_jira_create_subtask tests/test_real_api_validation.py::test_jira_create_task_with_parent tests/test_real_api_validation.py::test_jira_add_comment tests/test_real_api_validation.py::test_confluence_create_page tests/test_real_api_validation.py::test_confluence_update_page tests/test_real_api_validation.py::test_jira_create_epic tests/test_real_api_validation.py::test_jira_create_epic_two_step $VERBOSITY
149 | 
150 |         # Run the skipped transition test if explicitly requested write tests
151 |         echo ""
152 |         echo "===== API Advanced Write Tests ====="
153 |         echo "Running tests for status transitions"
154 |         uv run pytest tests/test_real_api_validation.py::test_jira_transition_issue -v -k "test_jira_transition_issue"
155 |     fi
156 | }
157 | 
158 | # Run the appropriate tests based on the selected type
159 | case $TEST_TYPE in
160 |     "models")
161 |         run_model_tests
162 |         ;;
163 |     "api")
164 |         run_api_tests
165 |         ;;
166 |     "all")
167 |         run_model_tests
168 |         run_api_tests
169 |         ;;
170 | esac
171 | 
172 | echo ""
173 | echo "Testing completed. Check the output for any failures or skipped tests."
174 | 
```

--------------------------------------------------------------------------------
/tests/unit/utils/test_lifecycle.py:
--------------------------------------------------------------------------------

```python
  1 | """Tests for lifecycle management utilities."""
  2 | 
  3 | import signal
  4 | from unittest.mock import patch
  5 | 
  6 | from mcp_atlassian.utils.lifecycle import (
  7 |     _shutdown_event,
  8 |     ensure_clean_exit,
  9 |     setup_signal_handlers,
 10 | )
 11 | 
 12 | 
 13 | class TestSetupSignalHandlers:
 14 |     """Test signal handler setup functionality."""
 15 | 
 16 |     @patch("signal.signal")
 17 |     def test_setup_signal_handlers_all_platforms(self, mock_signal):
 18 |         """Test that signal handlers are registered for all platforms."""
 19 |         # Mock SIGPIPE as available
 20 |         mock_signal.side_effect = None
 21 | 
 22 |         setup_signal_handlers()
 23 | 
 24 |         # Check that SIGTERM and SIGINT handlers were registered
 25 |         assert any(call[0][0] == signal.SIGTERM for call in mock_signal.call_args_list)
 26 |         assert any(call[0][0] == signal.SIGINT for call in mock_signal.call_args_list)
 27 | 
 28 |         # Check that all handlers are callable
 29 |         for call in mock_signal.call_args_list:
 30 |             assert callable(call[0][1])
 31 | 
 32 |     @patch("signal.signal")
 33 |     def test_setup_signal_handlers_no_sigpipe(self, mock_signal):
 34 |         """Test signal handler setup when SIGPIPE is not available (Windows)."""
 35 | 
 36 |         # Mock SIGPIPE as not available
 37 |         def side_effect(sig, handler):
 38 |             if sig == signal.SIGPIPE:
 39 |                 raise AttributeError("SIGPIPE not available")
 40 |             return None
 41 | 
 42 |         mock_signal.side_effect = side_effect
 43 | 
 44 |         # This should not raise an exception
 45 |         setup_signal_handlers()
 46 | 
 47 |         # SIGTERM and SIGINT should still be registered
 48 |         assert any(call[0][0] == signal.SIGTERM for call in mock_signal.call_args_list)
 49 |         assert any(call[0][0] == signal.SIGINT for call in mock_signal.call_args_list)
 50 | 
 51 |     @patch("signal.signal")
 52 |     def test_signal_handler_function(self, mock_signal):
 53 |         """Test that the signal handler function works correctly."""
 54 |         handler = None
 55 | 
 56 |         # Capture the handler function
 57 |         def capture_handler(sig, func):
 58 |             nonlocal handler
 59 |             if sig == signal.SIGTERM:
 60 |                 handler = func
 61 | 
 62 |         mock_signal.side_effect = capture_handler
 63 | 
 64 |         # Clear the shutdown event before test
 65 |         _shutdown_event.clear()
 66 | 
 67 |         setup_signal_handlers()
 68 | 
 69 |         # Call the handler
 70 |         assert handler is not None
 71 |         handler(signal.SIGTERM, None)
 72 | 
 73 |         # Check shutdown event was set instead of calling sys.exit
 74 |         assert _shutdown_event.is_set()
 75 | 
 76 | 
 77 | class TestEnsureCleanExit:
 78 |     """Test the clean exit functionality."""
 79 | 
 80 |     @patch("sys.stderr")
 81 |     @patch("sys.stdout")
 82 |     def test_ensure_clean_exit(self, mock_stdout, mock_stderr):
 83 |         """Test that output streams are flushed on exit."""
 84 |         # Mock streams as open
 85 |         mock_stdout.closed = False
 86 |         mock_stderr.closed = False
 87 | 
 88 |         ensure_clean_exit()
 89 | 
 90 |         # Check both streams were flushed
 91 |         mock_stdout.flush.assert_called_once()
 92 |         mock_stderr.flush.assert_called_once()
 93 | 
 94 |     @patch("sys.stderr")
 95 |     @patch("sys.stdout")
 96 |     def test_ensure_clean_exit_closed_stdout(self, mock_stdout, mock_stderr):
 97 |         """Test that closed stdout is handled gracefully."""
 98 |         # Mock stdout as closed, stderr as open
 99 |         mock_stdout.closed = True
100 |         mock_stderr.closed = False
101 | 
102 |         ensure_clean_exit()
103 | 
104 |         # Check stdout was not flushed
105 |         mock_stdout.flush.assert_not_called()
106 |         # Check stderr was still flushed
107 |         mock_stderr.flush.assert_called_once()
108 | 
109 |     @patch("sys.stderr")
110 |     @patch("sys.stdout")
111 |     def test_ensure_clean_exit_closed_stderr(self, mock_stdout, mock_stderr):
112 |         """Test that closed stderr is handled gracefully."""
113 |         # Mock stderr as closed, stdout as open
114 |         mock_stdout.closed = False
115 |         mock_stderr.closed = True
116 | 
117 |         ensure_clean_exit()
118 | 
119 |         # Check stdout was flushed
120 |         mock_stdout.flush.assert_called_once()
121 |         # Check stderr was not flushed
122 |         mock_stderr.flush.assert_not_called()
123 | 
124 |     @patch("sys.stderr")
125 |     @patch("sys.stdout")
126 |     def test_ensure_clean_exit_both_closed(self, mock_stdout, mock_stderr):
127 |         """Test that both streams being closed is handled gracefully."""
128 |         # Mock both streams as closed
129 |         mock_stdout.closed = True
130 |         mock_stderr.closed = True
131 | 
132 |         ensure_clean_exit()
133 | 
134 |         # Check neither stream was flushed
135 |         mock_stdout.flush.assert_not_called()
136 |         mock_stderr.flush.assert_not_called()
137 | 
138 |     @patch("sys.stderr")
139 |     @patch("sys.stdout")
140 |     def test_ensure_clean_exit_flush_raises_value_error(self, mock_stdout, mock_stderr):
141 |         """Test that ValueError during flush is handled gracefully."""
142 |         # Mock streams as open but flush raises ValueError
143 |         mock_stdout.closed = False
144 |         mock_stderr.closed = False
145 |         mock_stdout.flush.side_effect = ValueError("I/O operation on closed file")
146 |         mock_stderr.flush.side_effect = ValueError("I/O operation on closed file")
147 | 
148 |         # Should not raise exception
149 |         ensure_clean_exit()
150 | 
151 |         # Check both streams had flush attempts
152 |         mock_stdout.flush.assert_called_once()
153 |         mock_stderr.flush.assert_called_once()
154 | 
155 |     @patch("sys.stderr")
156 |     @patch("sys.stdout")
157 |     def test_ensure_clean_exit_no_closed_attribute(self, mock_stdout, mock_stderr):
158 |         """Test handling of streams without 'closed' attribute."""
159 |         # Remove closed attribute to simulate non-standard streams
160 |         if hasattr(mock_stdout, "closed"):
161 |             delattr(mock_stdout, "closed")
162 |         if hasattr(mock_stderr, "closed"):
163 |             delattr(mock_stderr, "closed")
164 | 
165 |         # Should not raise exception
166 |         ensure_clean_exit()
167 | 
168 |         # Check neither stream was flushed (no closed attribute)
169 |         mock_stdout.flush.assert_not_called()
170 |         mock_stderr.flush.assert_not_called()
171 | 
```

--------------------------------------------------------------------------------
/tests/integration/test_stdin_monitoring_fix.py:
--------------------------------------------------------------------------------

```python
  1 | """Simple integration test to verify stdin monitoring fix for streamable-http transport.
  2 | 
  3 | This test verifies that the fix in PR #522 correctly disables stdin monitoring
  4 | for HTTP transports (SSE and streamable-http) to prevent hanging issues.
  5 | """
  6 | 
  7 | import os
  8 | import subprocess
  9 | import sys
 10 | import tempfile
 11 | from pathlib import Path
 12 | 
 13 | import pytest
 14 | 
 15 | 
 16 | @pytest.mark.integration
 17 | class TestStdinMonitoringFix:
 18 |     """Test that stdin monitoring is correctly disabled for HTTP transports."""
 19 | 
 20 |     def test_streamable_http_starts_without_hanging(self):
 21 |         """Test that streamable-http transport starts without stdin monitoring issues.
 22 | 
 23 |         This test creates a minimal script that would hang if stdin monitoring
 24 |         was enabled for HTTP transports, and verifies it runs successfully.
 25 |         """
 26 |         # Create a test script that simulates the issue
 27 |         with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
 28 |             f.write("""
 29 | import sys
 30 | import os
 31 | 
 32 | # The actual test: if stdin monitoring was incorrectly enabled for HTTP,
 33 | # closing stdin would cause issues. With the fix, it should work fine.
 34 | if __name__ == "__main__":
 35 |     # This simulates the scenario where stdin is closed (like in the bug report)
 36 |     # With the fix, HTTP transports won't monitor stdin, so this won't cause issues
 37 |     sys.stdin.close()
 38 | 
 39 |     # If we get here without hanging, the fix is working
 40 |     print("TEST_PASSED: No hanging with closed stdin")
 41 |     sys.exit(0)
 42 | """)
 43 |             test_script = f.name
 44 | 
 45 |         try:
 46 |             # Run the test script
 47 |             result = subprocess.run(
 48 |                 [sys.executable, test_script],
 49 |                 capture_output=True,
 50 |                 text=True,
 51 |                 timeout=5,  # Should complete quickly, timeout means hanging
 52 |             )
 53 | 
 54 |             # Check the output
 55 |             assert "TEST_PASSED" in result.stdout, (
 56 |                 f"Test failed. Output: {result.stdout}, Error: {result.stderr}"
 57 |             )
 58 |             assert result.returncode == 0, (
 59 |                 f"Script failed with code {result.returncode}"
 60 |             )
 61 | 
 62 |         except subprocess.TimeoutExpired:
 63 |             pytest.fail(
 64 |                 "Script timed out - stdin monitoring may still be active for HTTP transports"
 65 |             )
 66 |         finally:
 67 |             # Clean up
 68 |             os.unlink(test_script)
 69 | 
 70 |     def test_code_structure_validates_fix(self):
 71 |         """Validate that the code structure implements the fix correctly.
 72 | 
 73 |         This checks that the main entry point has the correct logic to disable
 74 |         stdin monitoring for HTTP transports.
 75 |         """
 76 |         # Read the main module source directly
 77 |         main_file = (
 78 |             Path(__file__).parent.parent.parent
 79 |             / "src"
 80 |             / "mcp_atlassian"
 81 |             / "__init__.py"
 82 |         )
 83 |         with open(main_file) as f:
 84 |             source = f.read()
 85 | 
 86 |         # Check for the key parts of the fix
 87 | 
 88 |         # 1. Different handling for stdio vs HTTP transports
 89 |         assert 'if final_transport == "stdio":' in source
 90 | 
 91 |         # 2. Comments explaining the fix
 92 |         assert (
 93 |             "# For stdio transport, don't monitor stdin as MCP server handles it internally"
 94 |             in source
 95 |         )
 96 |         assert (
 97 |             "# This prevents race conditions where both try to read from the same stdin"
 98 |             in source
 99 |         )
100 |         assert (
101 |             "# For HTTP transports (SSE, streamable-http), don't use stdin monitoring"
102 |             in source
103 |         )
104 |         assert (
105 |             "# as it causes premature shutdown when the client closes stdin" in source
106 |         )
107 |         assert "# The server should only rely on OS signals for shutdown" in source
108 | 
109 |         # 3. Proper conditional logic - look for the actual asyncio.run calls
110 |         # There should be two separate sections handling stdio vs HTTP
111 |         stdio_section = False
112 |         http_section = False
113 | 
114 |         lines = source.split("\n")
115 |         for i, line in enumerate(lines):
116 |             # Look for the stdio handling
117 |             if "# For stdio transport," in line and "monitor stdin" in line:
118 |                 # Next few lines should have the stdio-specific handling
119 |                 next_lines = "\n".join(lines[i : i + 5])
120 |                 if (
121 |                     'if final_transport == "stdio":' in next_lines
122 |                     and "asyncio.run" in next_lines
123 |                 ):
124 |                     stdio_section = True
125 | 
126 |             # Look for the HTTP handling
127 |             if "# For HTTP transports" in line and "stdin monitoring" in line:
128 |                 # Next few lines should have the HTTP-specific handling
129 |                 next_lines = "\n".join(lines[i : i + 10])
130 |                 if (
131 |                     "without stdin monitoring" in next_lines
132 |                     and "asyncio.run" in next_lines
133 |                 ):
134 |                     http_section = True
135 | 
136 |         assert stdio_section, "Could not find proper stdio transport handling"
137 |         assert http_section, "Could not find proper HTTP transport handling"
138 | 
139 |         print("Code structure validation passed - fix is properly implemented")
140 | 
141 |     def test_lifecycle_module_supports_http_transports(self):
142 |         """Test that the lifecycle module properly handles HTTP transports.
143 | 
144 |         This verifies that the lifecycle management doesn't interfere with
145 |         HTTP transport operation.
146 |         """
147 |         from mcp_atlassian.utils.lifecycle import (
148 |             ensure_clean_exit,
149 |             setup_signal_handlers,
150 |         )
151 | 
152 |         # These should work without issues for HTTP transports
153 |         try:
154 |             setup_signal_handlers()
155 |             ensure_clean_exit()
156 |             print("Lifecycle module works correctly for HTTP transports")
157 |         except Exception as e:
158 |             pytest.fail(f"Lifecycle module failed: {e}")
159 | 
```

--------------------------------------------------------------------------------
/tests/unit/confluence/test_spaces.py:
--------------------------------------------------------------------------------

```python
  1 | """Unit tests for the SpacesMixin class."""
  2 | 
  3 | from unittest.mock import patch
  4 | 
  5 | import pytest
  6 | import requests
  7 | from fixtures.confluence_mocks import MOCK_SPACES_RESPONSE
  8 | 
  9 | from mcp_atlassian.confluence.spaces import SpacesMixin
 10 | 
 11 | 
 12 | class TestSpacesMixin:
 13 |     """Tests for the SpacesMixin class."""
 14 | 
 15 |     @pytest.fixture
 16 |     def spaces_mixin(self, confluence_client):
 17 |         """Create a SpacesMixin instance for testing."""
 18 |         # SpacesMixin inherits from ConfluenceClient, so we need to create it properly
 19 |         with patch(
 20 |             "mcp_atlassian.confluence.spaces.ConfluenceClient.__init__"
 21 |         ) as mock_init:
 22 |             mock_init.return_value = None
 23 |             mixin = SpacesMixin()
 24 |             # Copy the necessary attributes from our mocked client
 25 |             mixin.confluence = confluence_client.confluence
 26 |             mixin.config = confluence_client.config
 27 |             mixin.preprocessor = confluence_client.preprocessor
 28 |             return mixin
 29 | 
 30 |     def test_get_spaces(self, spaces_mixin):
 31 |         """Test that get_spaces returns spaces from the Confluence client."""
 32 |         # Act
 33 |         result = spaces_mixin.get_spaces(start=10, limit=20)
 34 | 
 35 |         # Assert
 36 |         spaces_mixin.confluence.get_all_spaces.assert_called_once_with(
 37 |             start=10, limit=20
 38 |         )
 39 |         assert result == MOCK_SPACES_RESPONSE
 40 | 
 41 |     def test_get_user_contributed_spaces_success(self, spaces_mixin):
 42 |         """Test getting spaces that the user has contributed to."""
 43 |         # Arrange
 44 |         mock_result = {
 45 |             "results": [
 46 |                 {
 47 |                     "content": {"_expandable": {"space": "/rest/api/space/TEST"}},
 48 |                     "resultGlobalContainer": {
 49 |                         "title": "Test Space",
 50 |                         "displayUrl": "/spaces/TEST",
 51 |                     },
 52 |                 }
 53 |             ]
 54 |         }
 55 |         spaces_mixin.confluence.cql.return_value = mock_result
 56 | 
 57 |         # Act
 58 |         result = spaces_mixin.get_user_contributed_spaces(limit=100)
 59 | 
 60 |         # Assert
 61 |         spaces_mixin.confluence.cql.assert_called_once_with(
 62 |             cql="contributor = currentUser() order by lastmodified DESC", limit=100
 63 |         )
 64 |         assert result == {"TEST": {"key": "TEST", "name": "Test Space"}}
 65 | 
 66 |     def test_get_user_contributed_spaces_extraction_methods(self, spaces_mixin):
 67 |         """Test that the method extracts space keys from different result structures."""
 68 |         # Arrange - Test different extraction methods
 69 |         mock_results = {
 70 |             "results": [
 71 |                 # Case 1: Extract from resultGlobalContainer.displayUrl
 72 |                 {
 73 |                     "resultGlobalContainer": {
 74 |                         "title": "Space 1",
 75 |                         "displayUrl": "/spaces/SPACE1/pages",
 76 |                     }
 77 |                 },
 78 |                 # Case 2: Extract from content._expandable.space
 79 |                 {
 80 |                     "content": {"_expandable": {"space": "/rest/api/space/SPACE2"}},
 81 |                     "resultGlobalContainer": {"title": "Space 2"},
 82 |                 },
 83 |                 # Case 3: Extract from url
 84 |                 {
 85 |                     "url": "/spaces/SPACE3/pages/12345",
 86 |                     "resultGlobalContainer": {"title": "Space 3"},
 87 |                 },
 88 |             ]
 89 |         }
 90 |         spaces_mixin.confluence.cql.return_value = mock_results
 91 | 
 92 |         # Act
 93 |         result = spaces_mixin.get_user_contributed_spaces()
 94 | 
 95 |         # Assert
 96 |         assert "SPACE1" in result
 97 |         assert result["SPACE1"]["name"] == "Space 1"
 98 |         assert "SPACE2" in result
 99 |         assert result["SPACE2"]["name"] == "Space 2"
100 |         assert "SPACE3" in result
101 |         assert result["SPACE3"]["name"] == "Space 3"
102 | 
103 |     def test_get_user_contributed_spaces_with_duplicate_spaces(self, spaces_mixin):
104 |         """Test that duplicate spaces are deduplicated."""
105 |         # Arrange
106 |         mock_results = {
107 |             "results": [
108 |                 # Same space key appears multiple times
109 |                 {
110 |                     "resultGlobalContainer": {
111 |                         "title": "Space 1",
112 |                         "displayUrl": "/spaces/SPACE1",
113 |                     }
114 |                 },
115 |                 {
116 |                     "resultGlobalContainer": {
117 |                         "title": "Space 1",
118 |                         "displayUrl": "/spaces/SPACE1",
119 |                     }
120 |                 },
121 |                 {"content": {"_expandable": {"space": "/rest/api/space/SPACE1"}}},
122 |             ]
123 |         }
124 |         spaces_mixin.confluence.cql.return_value = mock_results
125 | 
126 |         # Act
127 |         result = spaces_mixin.get_user_contributed_spaces()
128 | 
129 |         # Assert
130 |         assert len(result) == 1
131 |         assert "SPACE1" in result
132 |         assert result["SPACE1"]["name"] == "Space 1"
133 | 
134 |     def test_get_user_contributed_spaces_api_error(self, spaces_mixin):
135 |         """Test handling of API errors."""
136 |         # Arrange
137 |         spaces_mixin.confluence.cql.side_effect = requests.RequestException("API Error")
138 | 
139 |         # Act
140 |         result = spaces_mixin.get_user_contributed_spaces()
141 | 
142 |         # Assert
143 |         assert result == {}
144 | 
145 |     def test_get_user_contributed_spaces_key_error(self, spaces_mixin):
146 |         """Test handling of KeyError when parsing results."""
147 |         # Arrange
148 |         spaces_mixin.confluence.cql.return_value = {"invalid_key": []}
149 | 
150 |         # Act
151 |         result = spaces_mixin.get_user_contributed_spaces()
152 | 
153 |         # Assert
154 |         assert result == {}
155 | 
156 |     def test_get_user_contributed_spaces_type_error(self, spaces_mixin):
157 |         """Test handling of TypeError when processing results."""
158 |         # Arrange
159 |         spaces_mixin.confluence.cql.return_value = (
160 |             None  # Will cause TypeError when iterating
161 |         )
162 | 
163 |         # Act
164 |         result = spaces_mixin.get_user_contributed_spaces()
165 | 
166 |         # Assert
167 |         assert result == {}
168 | 
```

--------------------------------------------------------------------------------
/tests/unit/confluence/test_config.py:
--------------------------------------------------------------------------------

```python
  1 | """Unit tests for the ConfluenceConfig class."""
  2 | 
  3 | import os
  4 | from unittest.mock import patch
  5 | 
  6 | import pytest
  7 | 
  8 | from mcp_atlassian.confluence.config import ConfluenceConfig
  9 | 
 10 | 
 11 | def test_from_env_success():
 12 |     """Test that from_env successfully creates a config from environment variables."""
 13 |     # Need to clear and reset the environment for this test
 14 |     with patch.dict(
 15 |         "os.environ",
 16 |         {
 17 |             "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
 18 |             "CONFLUENCE_USERNAME": "test_username",
 19 |             "CONFLUENCE_API_TOKEN": "test_token",
 20 |         },
 21 |         clear=True,  # Clear existing environment variables
 22 |     ):
 23 |         config = ConfluenceConfig.from_env()
 24 |         assert config.url == "https://test.atlassian.net/wiki"
 25 |         assert config.username == "test_username"
 26 |         assert config.api_token == "test_token"
 27 | 
 28 | 
 29 | def test_from_env_missing_url():
 30 |     """Test that from_env raises ValueError when URL is missing."""
 31 |     original_env = os.environ.copy()
 32 |     try:
 33 |         os.environ.clear()
 34 |         with pytest.raises(
 35 |             ValueError, match="Missing required CONFLUENCE_URL environment variable"
 36 |         ):
 37 |             ConfluenceConfig.from_env()
 38 |     finally:
 39 |         # Restore original environment
 40 |         os.environ.clear()
 41 |         os.environ.update(original_env)
 42 | 
 43 | 
 44 | def test_from_env_missing_cloud_auth():
 45 |     """Test that from_env raises ValueError when cloud auth credentials are missing."""
 46 |     with patch.dict(
 47 |         os.environ,
 48 |         {
 49 |             "CONFLUENCE_URL": "https://test.atlassian.net",  # Cloud URL
 50 |         },
 51 |         clear=True,
 52 |     ):
 53 |         with pytest.raises(
 54 |             ValueError,
 55 |             match="Cloud authentication requires CONFLUENCE_USERNAME and CONFLUENCE_API_TOKEN",
 56 |         ):
 57 |             ConfluenceConfig.from_env()
 58 | 
 59 | 
 60 | def test_from_env_missing_server_auth():
 61 |     """Test that from_env raises ValueError when server auth credentials are missing."""
 62 |     with patch.dict(
 63 |         os.environ,
 64 |         {
 65 |             "CONFLUENCE_URL": "https://confluence.example.com",  # Server URL
 66 |         },
 67 |         clear=True,
 68 |     ):
 69 |         with pytest.raises(
 70 |             ValueError,
 71 |             match="Server/Data Center authentication requires CONFLUENCE_PERSONAL_TOKEN",
 72 |         ):
 73 |             ConfluenceConfig.from_env()
 74 | 
 75 | 
 76 | def test_is_cloud():
 77 |     """Test that is_cloud property returns correct value."""
 78 |     # Arrange & Act - Cloud URL
 79 |     config = ConfluenceConfig(
 80 |         url="https://example.atlassian.net/wiki",
 81 |         auth_type="basic",
 82 |         username="test",
 83 |         api_token="test",
 84 |     )
 85 | 
 86 |     # Assert
 87 |     assert config.is_cloud is True
 88 | 
 89 |     # Arrange & Act - Server URL
 90 |     config = ConfluenceConfig(
 91 |         url="https://confluence.example.com",
 92 |         auth_type="pat",
 93 |         personal_token="test",
 94 |     )
 95 | 
 96 |     # Assert
 97 |     assert config.is_cloud is False
 98 | 
 99 |     # Arrange & Act - Localhost URL (Data Center/Server)
100 |     config = ConfluenceConfig(
101 |         url="http://localhost:8090",
102 |         auth_type="pat",
103 |         personal_token="test",
104 |     )
105 | 
106 |     # Assert
107 |     assert config.is_cloud is False
108 | 
109 |     # Arrange & Act - IP localhost URL (Data Center/Server)
110 |     config = ConfluenceConfig(
111 |         url="http://127.0.0.1:8090",
112 |         auth_type="pat",
113 |         personal_token="test",
114 |     )
115 | 
116 |     # Assert
117 |     assert config.is_cloud is False
118 | 
119 | 
120 | def test_from_env_proxy_settings():
121 |     """Test that from_env correctly loads proxy environment variables."""
122 |     with patch.dict(
123 |         os.environ,
124 |         {
125 |             "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
126 |             "CONFLUENCE_USERNAME": "test_username",
127 |             "CONFLUENCE_API_TOKEN": "test_token",
128 |             "HTTP_PROXY": "http://proxy.example.com:8080",
129 |             "HTTPS_PROXY": "https://proxy.example.com:8443",
130 |             "SOCKS_PROXY": "socks5://user:[email protected]:1080",
131 |             "NO_PROXY": "localhost,127.0.0.1",
132 |         },
133 |         clear=True,
134 |     ):
135 |         config = ConfluenceConfig.from_env()
136 |         assert config.http_proxy == "http://proxy.example.com:8080"
137 |         assert config.https_proxy == "https://proxy.example.com:8443"
138 |         assert config.socks_proxy == "socks5://user:[email protected]:1080"
139 |         assert config.no_proxy == "localhost,127.0.0.1"
140 | 
141 |     # Service-specific overrides
142 |     with patch.dict(
143 |         os.environ,
144 |         {
145 |             "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
146 |             "CONFLUENCE_USERNAME": "test_username",
147 |             "CONFLUENCE_API_TOKEN": "test_token",
148 |             "CONFLUENCE_HTTP_PROXY": "http://confluence-proxy.example.com:8080",
149 |             "CONFLUENCE_HTTPS_PROXY": "https://confluence-proxy.example.com:8443",
150 |             "CONFLUENCE_SOCKS_PROXY": "socks5://user:[email protected]:1080",
151 |             "CONFLUENCE_NO_PROXY": "localhost,127.0.0.1,.internal.example.com",
152 |         },
153 |         clear=True,
154 |     ):
155 |         config = ConfluenceConfig.from_env()
156 |         assert config.http_proxy == "http://confluence-proxy.example.com:8080"
157 |         assert config.https_proxy == "https://confluence-proxy.example.com:8443"
158 |         assert (
159 |             config.socks_proxy == "socks5://user:[email protected]:1080"
160 |         )
161 |         assert config.no_proxy == "localhost,127.0.0.1,.internal.example.com"
162 | 
163 | 
164 | def test_is_cloud_oauth_with_cloud_id():
165 |     """Test that is_cloud returns True for OAuth with cloud_id regardless of URL."""
166 |     from mcp_atlassian.utils.oauth import BYOAccessTokenOAuthConfig
167 | 
168 |     # OAuth with cloud_id and no URL - should be Cloud
169 |     oauth_config = BYOAccessTokenOAuthConfig(
170 |         cloud_id="test-cloud-id", access_token="test-token"
171 |     )
172 |     config = ConfluenceConfig(
173 |         url=None,  # URL can be None in Multi-Cloud OAuth mode
174 |         auth_type="oauth",
175 |         oauth_config=oauth_config,
176 |     )
177 |     assert config.is_cloud is True
178 | 
179 |     # OAuth with cloud_id and server URL - should still be Cloud
180 |     config = ConfluenceConfig(
181 |         url="https://confluence.example.com",  # Server-like URL
182 |         auth_type="oauth",
183 |         oauth_config=oauth_config,
184 |     )
185 |     assert config.is_cloud is True
186 | 
```
Page 2/13FirstPrevNextLast