This is page 1 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
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | # Version control
2 | .git
3 | .gitignore
4 | .github
5 |
6 | # Virtual environments
7 | .venv*
8 | venv*
9 | env*
10 |
11 | # Python bytecode/cache
12 | __pycache__/
13 | *.py[cod]
14 | *$py.class
15 | .pytest_cache
16 | .coverage
17 | .mypy_cache
18 | .ruff_cache
19 |
20 | # Build artifacts
21 | dist/
22 | build/
23 | *.egg-info/
24 |
25 | # IDE and editor files
26 | .vscode/
27 | .idea/
28 | *.swp
29 | *.swo
30 | .DS_Store
31 |
32 | # Environment variables and secrets
33 | .env*
34 | *.env
35 | *.key
36 |
37 | # Logs
38 | *.log
39 |
40 | # Docker related
41 | Dockerfile*
42 | docker-compose*
43 | .dockerignore
44 |
45 | # Documentation
46 | docs/
47 | *.md
48 | !README.md
49 |
50 | # Temporal and backup files
51 | *.tmp
52 | *.bak
53 | *.backup
54 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # Environments
30 | .env
31 | .env.*
32 | .venv
33 | env/
34 | venv/
35 | ENV/
36 | env.bak/
37 | venv.bak/
38 |
39 | # uv/uvx specific
40 | .uv/
41 | .uvx/
42 | .venv.uv/
43 |
44 | # IDEs and editors
45 | .idea/
46 | .vscode/
47 | *.swp
48 | *.swo
49 | *~
50 |
51 | # OS specific
52 | .DS_Store
53 | Thumbs.db
54 |
55 | # Debug and local development
56 | debug/
57 | local/
58 |
59 | # Credentials
60 | .pypirc
61 |
62 | # Pytest
63 | .pytest_cache/
64 | .coverage/
65 | .coverage
66 |
67 | # Pre-commit
68 | .pre-commit-config.yaml.cache
69 |
70 | # debug
71 | playground/
72 |
73 | # cursor
74 | .cursor*
75 |
76 | # Claude
77 | .claude/
78 |
```
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
```yaml
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.5.0
4 | hooks:
5 | - id: trailing-whitespace
6 | - id: end-of-file-fixer
7 | - id: check-yaml
8 | - id: check-added-large-files
9 | - id: check-toml
10 | - id: debug-statements
11 |
12 | - repo: https://github.com/astral-sh/ruff-pre-commit
13 | rev: v0.9.7
14 | hooks:
15 | - id: ruff-format
16 | - id: ruff
17 | args: [
18 | "--fix",
19 | "--exit-non-zero-on-fix",
20 | "--ignore=BLE001,EM102,FBT001,FBT002,E501,F841,S112,S113,B904"
21 | ]
22 |
23 | - repo: https://github.com/pre-commit/mirrors-mypy
24 | rev: v1.8.0
25 | hooks:
26 | - id: mypy
27 | # TODO: Fix these type errors in future PRs:
28 | # - Union-attr errors in server.py (None checks needed)
29 | # - Index error in jira_mocks.py (tests/fixtures/jira_mocks.py:421)
30 | # - Assignment type errors in jira.py (str to int assignments)
31 | # - Unreachable statements in preprocessing.py and jira.py
32 | args: [
33 | "--ignore-missing-imports",
34 | "--no-strict-optional",
35 | "--disable-error-code=index",
36 | "--disable-error-code=unreachable",
37 | "--disable-error-code=assignment",
38 | "--disable-error-code=arg-type",
39 | "--disable-error-code=return-value",
40 | "--disable-error-code=has-type",
41 | "--disable-error-code=no-any-return",
42 | "--disable-error-code=misc",
43 | "--disable-error-code=var-annotated",
44 | "--disable-error-code=no-untyped-def",
45 | "--disable-error-code=annotation-unchecked",
46 | ]
47 | additional_dependencies: [
48 | 'types-beautifulsoup4',
49 | 'types-requests',
50 | 'types-setuptools',
51 | 'types-urllib3',
52 | 'types-cachetools',
53 | 'atlassian-python-api',
54 | 'beautifulsoup4',
55 | 'httpx',
56 | 'python-dotenv',
57 | 'markdownify',
58 | 'python-dateutil',
59 | 'types-python-dateutil',
60 | ]
61 |
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # MCP-ATLASSIAN CONFIGURATION EXAMPLE
2 | # -------------------------------------
3 | # Copy this file to .env and fill in your details.
4 | # - Do not use double quotes for string values in this file, unless the value itself contains spaces.
5 |
6 | # =============================================
7 | # ESSENTIAL ATLASSIAN INSTANCE URLS
8 | # =============================================
9 | # REQUIRED: Replace with your Atlassian instance URLs.
10 | # Example Cloud: https://your-company.atlassian.net
11 | # Example Server/DC: https://jira.your-internal-domain.com
12 | JIRA_URL=https://your-company.atlassian.net
13 | # Example Cloud: https://your-company.atlassian.net/wiki
14 | # Example Server/DC: https://confluence.your-internal-domain.com
15 | CONFLUENCE_URL=https://your-company.atlassian.net/wiki
16 |
17 | # =============================================
18 | # AUTHENTICATION: CHOOSE ONE METHOD PER PRODUCT (Jira/Confluence)
19 | # =============================================
20 | # mcp-atlassian will attempt to auto-detect the auth method based on the credentials you provide below.
21 | # Precedence for auto-detection:
22 | # 1. Username/API Token (Basic Auth) - Recommended for Cloud
23 | # 2. Personal Access Token (if URL is Server/DC and PAT is set)
24 | # 3. OAuth BYOT (if ATLASSIAN_OAUTH_CLOUD_ID and ATLASSIAN_OAUTH_ACCESS_TOKEN are set)
25 | # 4. OAuth Standard (if OAuth Client ID/Secret are set)
26 |
27 | # --- METHOD 1: API TOKEN (Recommended for Atlassian Cloud) ---
28 | # Get API tokens from: https://id.atlassian.com/manage-profile/security/api-tokens
29 | # This is the simplest and most reliable authentication method for Cloud deployments.
30 | #[email protected]
31 | #JIRA_API_TOKEN=your_jira_api_token
32 |
33 | #[email protected]
34 | #CONFLUENCE_API_TOKEN=your_confluence_api_token
35 |
36 | # --- METHOD 2: PERSONAL ACCESS TOKEN (PAT) (Server / Data Center - Recommended) ---
37 | # Create PATs in your Jira/Confluence profile settings (usually under "Personal Access Tokens").
38 | #JIRA_PERSONAL_TOKEN=your_jira_personal_access_token
39 |
40 | #CONFLUENCE_PERSONAL_TOKEN=your_confluence_personal_access_token
41 |
42 | # --- METHOD 3: USERNAME/PASSWORD (Server / Data Center - Uses Basic Authentication) ---
43 | #JIRA_USERNAME=your_server_dc_username
44 | #JIRA_API_TOKEN=your_jira_server_dc_password # For Server/DC Basic Auth, API_TOKEN holds the actual password
45 |
46 | #CONFLUENCE_USERNAME=your_server_dc_username
47 | #CONFLUENCE_API_TOKEN=your_confluence_server_dc_password
48 |
49 | # --- METHOD 4: OAUTH 2.0 (Advanced - Atlassian Cloud Only) ---
50 | # OAuth 2.0 provides enhanced security but is more complex to set up.
51 | # For most users, Method 1 (API Token) is simpler and sufficient.
52 | # 1. Create an OAuth 2.0 (3LO) app in Atlassian Developer Console:
53 | # https://developer.atlassian.com/console/myapps/
54 | # 2. Set the Callback/Redirect URI in your app (e.g., http://localhost:8080/callback).
55 | # 3. Grant necessary scopes (see ATLASSIAN_OAUTH_SCOPE below).
56 | # 4. Run 'mcp-atlassian --oauth-setup -v' (or 'uvx mcp-atlassian@latest --oauth-setup -v').
57 | # This wizard will guide you through authorization and provide your ATLASSIAN_OAUTH_CLOUD_ID.
58 | # Tokens are stored securely (keyring or a local file in ~/.mcp-atlassian/).
59 |
60 | # Required for --oauth-setup and for the server to use OAuth:
61 | #ATLASSIAN_OAUTH_CLIENT_ID=your_oauth_client_id
62 | #ATLASSIAN_OAUTH_CLIENT_SECRET=your_oauth_client_secret
63 | #ATLASSIAN_OAUTH_REDIRECT_URI=http://localhost:8080/callback # Must match your app's redirect URI
64 | #ATLASSIAN_OAUTH_SCOPE=read:jira-work write:jira-work read:confluence-space.summary read:confluence-content.all write:confluence-content offline_access # IMPORTANT: 'offline_access' is crucial for refresh tokens
65 |
66 | # Required for the server AFTER running --oauth-setup (this ID is printed by the setup wizard):
67 | #ATLASSIAN_OAUTH_CLOUD_ID=your_atlassian_cloud_id_from_oauth_setup
68 |
69 | # --- METHOD 5: BRING YOUR OWN TOKEN (BYOT) OAUTH (Advanced - Atlassian Cloud Only) ---
70 | # Use this method when you have an externally managed OAuth access token.
71 | # This is useful for integration with larger systems (e.g., MCP Gateway) that manage OAuth tokens.
72 | # No token refresh will be performed - the external system is responsible for token management.
73 | #ATLASSIAN_OAUTH_CLOUD_ID=your_atlassian_cloud_id
74 | #ATLASSIAN_OAUTH_ACCESS_TOKEN=your_pre_existing_oauth_access_token
75 |
76 | # =============================================
77 | # SERVER/DATA CENTER SPECIFIC SETTINGS
78 | # =============================================
79 | # Only applicable if your JIRA_URL/CONFLUENCE_URL points to a Server/DC instance (not *.atlassian.net).
80 | # Default is true. Set to false if using self-signed certificates (not recommended for production environments).
81 | #JIRA_SSL_VERIFY=true
82 | #CONFLUENCE_SSL_VERIFY=true
83 |
84 |
85 | # =============================================
86 | # OPTIONAL CONFIGURATION
87 | # =============================================
88 |
89 | # --- General Server Settings ---
90 | # Transport mode for the MCP server. Default is 'stdio'.
91 | # Options: stdio, sse
92 | #TRANSPORT=stdio
93 | # Port for 'sse' transport. Default is 8000.
94 | #PORT=8000
95 | # Host for 'sse' transport. Default is '0.0.0.0'.
96 | #HOST=0.0.0.0
97 |
98 | # --- Read-Only Mode ---
99 | # Disables all write operations (create, update, delete). Default is false.
100 | #READ_ONLY_MODE=false
101 |
102 | # --- Logging Verbosity ---
103 | # MCP_VERBOSE=true # Enables INFO level logging (equivalent to 'mcp-atlassian -v')
104 | # MCP_VERY_VERBOSE=true # Enables DEBUG level logging (equivalent to 'mcp-atlassian -vv')
105 | # MCP_LOGGING_STDOUT=true # Enables logging to stdout (logging.StreamHandler defaults to stderr)
106 | # Default logging level is WARNING (minimal output).
107 |
108 | # --- Tool Filtering ---
109 | # Comma-separated list of tool names to enable. If not set, all tools are enabled
110 | # (subject to read-only mode and configured services).
111 | # Example: ENABLED_TOOLS=confluence_search,jira_get_issue
112 | #ENABLED_TOOLS=
113 |
114 | # --- Content Filtering ---
115 | # Optional: Comma-separated list of Confluence space keys to limit searches and other operations to.
116 | #CONFLUENCE_SPACES_FILTER=DEV,TEAM,DOC
117 | # Optional: Comma-separated list of Jira project keys to limit searches and other operations to.
118 | #JIRA_PROJECTS_FILTER=PROJ,DEVOPS
119 |
120 | # --- Proxy Configuration (Advanced) ---
121 | # Global proxy settings (applies to both Jira and Confluence unless overridden by service-specific proxy settings below).
122 | #HTTP_PROXY=http://proxy.example.com:8080
123 | #HTTPS_PROXY=https://user:[email protected]:8443 # Credentials can be included
124 | #SOCKS_PROXY=socks5://proxy.example.com:1080 # Requires 'requests[socks]' to be installed
125 | #NO_PROXY=localhost,127.0.0.1,.internal.example.com # Comma-separated list of hosts/domains to bypass proxy
126 |
127 | # Jira-specific proxy settings (these override global proxy settings for Jira requests).
128 | #JIRA_HTTP_PROXY=http://jira-proxy.example.com:8080
129 | #JIRA_HTTPS_PROXY=https://jira-proxy.example.com:8443
130 | #JIRA_SOCKS_PROXY=socks5://jira-proxy.example.com:1080
131 | #JIRA_NO_PROXY=localhost,127.0.0.1,.internal.jira.com
132 |
133 | # Confluence-specific proxy settings (these override global proxy settings for Confluence requests).
134 | #CONFLUENCE_HTTP_PROXY=http://confluence-proxy.example.com:8080
135 | #CONFLUENCE_HTTPS_PROXY=https://confluence-proxy.example.com:8443
136 | #CONFLUENCE_SOCKS_PROXY=socks5://confluence-proxy.example.com:1080
137 | #CONFLUENCE_NO_PROXY=localhost,127.0.0.1,.internal.confluence.com
138 |
139 | # --- Custom HTTP Headers (Advanced) ---
140 | # Jira-specific custom headers.
141 | #JIRA_CUSTOM_HEADERS=X-Jira-Service=mcp-integration,X-Custom-Auth=jira-token,X-Forwarded-User=service-account
142 |
143 | # Confluence-specific custom headers.
144 | #CONFLUENCE_CUSTOM_HEADERS=X-Confluence-Service=mcp-integration,X-Custom-Auth=confluence-token,X-ALB-Token=secret-token
145 |
```
--------------------------------------------------------------------------------
/tests/integration/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Integration Tests
2 |
3 | This directory contains integration tests for the MCP Atlassian project. These tests validate the interaction between different components and services.
4 |
5 | ## Test Categories
6 |
7 | ### 1. Authentication Integration (`test_authentication.py`)
8 | Tests various authentication flows including OAuth, Basic Auth, and PAT tokens.
9 |
10 | - **OAuth Token Refresh**: Validates token refresh on expiration
11 | - **Basic Auth**: Tests username/password authentication for both services
12 | - **PAT Tokens**: Tests Personal Access Token authentication
13 | - **Fallback Patterns**: Tests authentication fallback (OAuth → Basic → PAT)
14 | - **Mixed Scenarios**: Tests different authentication combinations
15 |
16 | ### 2. Cross-Service Integration (`test_cross_service.py`)
17 | Tests integration between Jira and Confluence services.
18 |
19 | - **User Resolution**: Consistent user handling across services
20 | - **Shared Authentication**: Auth context sharing between services
21 | - **Error Handling**: Service isolation during failures
22 | - **Configuration Sharing**: SSL and proxy settings consistency
23 | - **Service Discovery**: Dynamic service availability detection
24 |
25 | ### 3. MCP Protocol Integration (`test_mcp_protocol.py`)
26 | Tests the FastMCP server implementation and tool management.
27 |
28 | - **Tool Discovery**: Dynamic tool listing based on configuration
29 | - **Tool Filtering**: Read-only mode and enabled tools filtering
30 | - **Middleware**: Authentication token extraction and validation
31 | - **Concurrent Execution**: Parallel tool execution support
32 | - **Error Propagation**: Proper error handling through the stack
33 |
34 | ### 4. Content Processing Integration (`test_content_processing.py`)
35 | Tests HTML/Markdown conversion and content preprocessing.
36 |
37 | - **Roundtrip Conversion**: HTML ↔ Markdown accuracy
38 | - **Macro Preservation**: Confluence macro handling
39 | - **Performance**: Large content processing (>1MB)
40 | - **Edge Cases**: Empty content, malformed HTML, Unicode
41 | - **Cross-Platform**: Content sharing between services
42 |
43 | ### 5. SSL Verification (`test_ssl_verification.py`)
44 | Tests SSL certificate handling and verification.
45 |
46 | - **SSL Configuration**: Enable/disable verification
47 | - **Custom CA Bundles**: Support for custom certificates
48 | - **Multiple Domains**: SSL adapter mounting for various domains
49 | - **Error Handling**: Certificate validation failures
50 |
51 | ### 6. Proxy Configuration (`test_proxy.py`)
52 | Tests HTTP/HTTPS/SOCKS proxy support.
53 |
54 | - **Proxy Types**: HTTP, HTTPS, and SOCKS5 proxies
55 | - **Authentication**: Proxy credentials in URLs
56 | - **NO_PROXY**: Bypass patterns for internal domains
57 | - **Environment Variables**: Proxy configuration from environment
58 | - **Mixed Configuration**: Proxy + SSL settings
59 |
60 | ### 7. Real API Tests (`test_real_api.py`)
61 | Tests with actual Atlassian APIs (requires `--use-real-data` flag).
62 |
63 | - **Complete Lifecycles**: Create/update/delete workflows
64 | - **Attachments**: File upload/download operations
65 | - **Search Operations**: JQL and CQL queries
66 | - **Bulk Operations**: Multiple item creation
67 | - **Rate Limiting**: API throttling behavior
68 | - **Cross-Service Linking**: Jira-Confluence integration
69 |
70 | ## Running Integration Tests
71 |
72 | ### Basic Execution
73 | ```bash
74 | # Run all integration tests (mocked)
75 | uv run pytest tests/integration/ --integration
76 |
77 | # Run specific test file
78 | uv run pytest tests/integration/test_authentication.py --integration
79 |
80 | # Run with coverage
81 | uv run pytest tests/integration/ --integration --cov=src/mcp_atlassian
82 | ```
83 |
84 | ### Real API Testing
85 | ```bash
86 | # Run tests against real Atlassian APIs
87 | uv run pytest tests/integration/test_real_api.py --integration --use-real-data
88 |
89 | # Required environment variables for real API tests:
90 | export JIRA_URL=https://your-domain.atlassian.net
91 | export [email protected]
92 | export JIRA_API_TOKEN=your-api-token
93 | export JIRA_TEST_PROJECT_KEY=TEST
94 |
95 | export CONFLUENCE_URL=https://your-domain.atlassian.net/wiki
96 | export [email protected]
97 | export CONFLUENCE_API_TOKEN=your-api-token
98 | export CONFLUENCE_TEST_SPACE_KEY=TEST
99 | ```
100 |
101 | ### Test Markers
102 | - `@pytest.mark.integration` - All integration tests
103 | - `@pytest.mark.anyio` - Async tests supporting multiple backends
104 |
105 | ## Environment Setup
106 |
107 | ### For Mocked Tests
108 | No special setup required. Tests use the utilities from `tests/utils/` for mocking.
109 |
110 | ### For Real API Tests
111 | 1. Create a test project in Jira (e.g., "TEST")
112 | 2. Create a test space in Confluence (e.g., "TEST")
113 | 3. Generate API tokens from your Atlassian account
114 | 4. Set environment variables as shown above
115 | 5. Ensure your account has permissions to create/delete in test areas
116 |
117 | ## Test Data Management
118 |
119 | ### Automatic Cleanup
120 | Real API tests implement automatic cleanup using pytest fixtures:
121 | - Created issues are tracked and deleted after each test
122 | - Created pages are tracked and deleted after each test
123 | - Attachments are cleaned up with their parent items
124 |
125 | ### Manual Cleanup
126 | If tests fail and leave data behind:
127 | ```python
128 | # Use JQL to find test issues
129 | project = TEST AND summary ~ "Integration Test*"
130 |
131 | # Use CQL to find test pages
132 | space = TEST AND title ~ "Integration Test*"
133 | ```
134 |
135 | ## Writing New Integration Tests
136 |
137 | ### Best Practices
138 | 1. **Use Test Utilities**: Leverage helpers from `tests/utils/`
139 | 2. **Mark Appropriately**: Use `@pytest.mark.integration`
140 | 3. **Mock by Default**: Only use real APIs with explicit flag
141 | 4. **Clean Up**: Always clean up created test data
142 | 5. **Unique Identifiers**: Use UUIDs to avoid conflicts
143 | 6. **Error Handling**: Test both success and failure paths
144 |
145 | ### Example Test Structure
146 | ```python
147 | import pytest
148 | from tests.utils.base import BaseAuthTest
149 | from tests.utils.mocks import MockEnvironment
150 |
151 | @pytest.mark.integration
152 | class TestNewIntegration(BaseAuthTest):
153 | def test_feature(self):
154 | with MockEnvironment.basic_auth_env():
155 | # Test implementation
156 | pass
157 | ```
158 |
159 | ## Troubleshooting
160 |
161 | ### Common Issues
162 |
163 | 1. **SSL Errors**: Set `JIRA_SSL_VERIFY=false` or `CONFLUENCE_SSL_VERIFY=false`
164 | 2. **Proxy Issues**: Check `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` settings
165 | 3. **Rate Limiting**: Add delays between requests or reduce test frequency
166 | 4. **Permission Errors**: Ensure test user has appropriate permissions
167 | 5. **Cleanup Failures**: Manually delete test data using JQL/CQL queries
168 |
169 | ### Debug Mode
170 | ```bash
171 | # Run with verbose output
172 | uv run pytest tests/integration/ --integration -v
173 |
174 | # Run with debug logging
175 | uv run pytest tests/integration/ --integration --log-cli-level=DEBUG
176 | ```
177 |
178 | ## CI/CD Integration
179 |
180 | ### GitHub Actions Example
181 | ```yaml
182 | - name: Run Integration Tests
183 | env:
184 | JIRA_URL: ${{ secrets.JIRA_URL }}
185 | JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }}
186 | JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
187 | run: |
188 | uv run pytest tests/integration/ --integration
189 | ```
190 |
191 | ### Skip Patterns
192 | - Integration tests are skipped by default without `--integration` flag
193 | - Real API tests require both `--integration` and `--use-real-data` flags
194 | - Tests skip gracefully when required environment variables are missing
195 |
```
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Atlassian Test Fixtures Documentation
2 |
3 | This document describes the enhanced test fixture system implemented for the MCP Atlassian project.
4 |
5 | ## Overview
6 |
7 | The test fixture system has been significantly improved to provide:
8 |
9 | - **Session-scoped fixtures** for expensive operations
10 | - **Factory-based fixtures** for customizable test data
11 | - **Better fixture composition** and reusability
12 | - **Backward compatibility** with existing tests
13 | - **Integration with test utilities** framework
14 |
15 | ## Architecture
16 |
17 | ```
18 | tests/
19 | ├── conftest.py # Root fixtures with session-scoped data
20 | ├── unit/
21 | │ ├── jira/conftest.py # Jira-specific fixtures
22 | │ ├── confluence/conftest.py # Confluence-specific fixtures
23 | │ └── models/conftest.py # Model testing fixtures
24 | ├── utils/ # Test utilities framework
25 | │ ├── factories.py # Data factories
26 | │ ├── mocks.py # Mock utilities
27 | │ ├── base.py # Base test classes
28 | │ └── assertions.py # Custom assertions
29 | └── fixtures/ # Legacy mock data
30 | ├── jira_mocks.py # Static Jira mock data
31 | └── confluence_mocks.py # Static Confluence mock data
32 | ```
33 |
34 | ## Key Features
35 |
36 | ### 1. Session-Scoped Fixtures
37 |
38 | These fixtures are computed once per test session to improve performance:
39 |
40 | - `session_auth_configs`: Authentication configuration templates
41 | - `session_mock_data`: Mock data templates for API responses
42 | - `session_jira_field_definitions`: Jira field definitions
43 | - `session_jira_projects`: Jira project data
44 | - `session_confluence_spaces`: Confluence space definitions
45 |
46 | ```python
47 | # Example usage
48 | def test_with_session_data(session_jira_field_definitions):
49 | # Uses cached field definitions, computed once per session
50 | assert len(session_jira_field_definitions) > 0
51 | ```
52 |
53 | ### 2. Factory-Based Fixtures
54 |
55 | These fixtures return factory functions for creating customizable test data:
56 |
57 | - `make_jira_issue`: Create Jira issues with custom properties
58 | - `make_confluence_page`: Create Confluence pages with custom properties
59 | - `make_auth_config`: Create authentication configurations
60 | - `make_api_error`: Create API error responses
61 |
62 | ```python
63 | # Example usage
64 | def test_custom_issue(make_jira_issue):
65 | issue = make_jira_issue(
66 | key="CUSTOM-123",
67 | fields={"priority": {"name": "High"}}
68 | )
69 | assert issue["key"] == "CUSTOM-123"
70 | assert issue["fields"]["priority"]["name"] == "High"
71 | ```
72 |
73 | ### 3. Environment Management
74 |
75 | Enhanced environment fixtures for testing different authentication scenarios:
76 |
77 | - `clean_environment`: No authentication variables
78 | - `oauth_environment`: OAuth setup
79 | - `basic_auth_environment`: Basic auth setup
80 | - `parametrized_auth_env`: Parameterized auth testing
81 |
82 | ```python
83 | # Example usage
84 | @pytest.mark.parametrize("parametrized_auth_env",
85 | ["oauth", "basic_auth"], indirect=True)
86 | def test_auth_scenarios(parametrized_auth_env):
87 | # Test runs once for OAuth and once for basic auth
88 | pass
89 | ```
90 |
91 | ### 4. Enhanced Mock Clients
92 |
93 | Improved mock clients with better integration:
94 |
95 | - `mock_jira_client`: Pre-configured mock Jira client
96 | - `mock_confluence_client`: Pre-configured mock Confluence client
97 | - `enhanced_mock_jira_client`: Factory-integrated Jira client
98 | - `enhanced_mock_confluence_client`: Factory-integrated Confluence client
99 |
100 | ### 5. Specialized Data Fixtures
101 |
102 | Domain-specific fixtures for complex testing scenarios:
103 |
104 | - `make_jira_issue_with_worklog`: Issues with worklog data
105 | - `make_jira_search_results`: JQL search results
106 | - `make_confluence_page_with_content`: Pages with rich content
107 | - `make_confluence_search_results`: CQL search results
108 |
109 | ## Migration Guide
110 |
111 | ### For New Tests
112 |
113 | Use the enhanced factory-based fixtures:
114 |
115 | ```python
116 | def test_new_functionality(make_jira_issue, make_confluence_page):
117 | # Create custom test data
118 | issue = make_jira_issue(key="NEW-123")
119 | page = make_confluence_page(title="New Test Page")
120 |
121 | # Test your functionality
122 | assert issue["key"] == "NEW-123"
123 | assert page["title"] == "New Test Page"
124 | ```
125 |
126 | ### For Existing Tests
127 |
128 | Existing tests continue to work without changes due to backward compatibility:
129 |
130 | ```python
131 | def test_existing_functionality(jira_issue_data, confluence_page_data):
132 | # These fixtures still work as before
133 | assert jira_issue_data["key"] == "TEST-123"
134 | assert confluence_page_data["title"] == "Test Page"
135 | ```
136 |
137 | ### Performance Testing
138 |
139 | Use large dataset fixtures for performance tests:
140 |
141 | ```python
142 | def test_performance(large_jira_dataset, large_confluence_dataset):
143 | # 100 issues and pages for performance testing
144 | assert len(large_jira_dataset) == 100
145 | assert len(large_confluence_dataset) == 100
146 | ```
147 |
148 | ## Best Practices
149 |
150 | ### 1. Choose the Right Fixture
151 |
152 | - Use **factory fixtures** for customizable data
153 | - Use **session-scoped fixtures** for static, expensive data
154 | - Use **legacy fixtures** only for backward compatibility
155 |
156 | ### 2. Session-Scoped Data
157 |
158 | Take advantage of session-scoped fixtures for data that doesn't change:
159 |
160 | ```python
161 | # Good: Uses session-scoped data
162 | def test_field_parsing(session_jira_field_definitions):
163 | parser = FieldParser(session_jira_field_definitions)
164 | assert parser.is_valid()
165 |
166 | # Avoid: Creates new data every time
167 | def test_field_parsing():
168 | fields = create_field_definitions() # Expensive operation
169 | parser = FieldParser(fields)
170 | assert parser.is_valid()
171 | ```
172 |
173 | ### 3. Factory Customization
174 |
175 | Use factories to create exactly the data you need:
176 |
177 | ```python
178 | # Good: Creates minimal required data
179 | def test_issue_key_validation(make_jira_issue):
180 | issue = make_jira_issue(key="VALID-123")
181 | assert validate_key(issue["key"])
182 |
183 | # Avoid: Uses complex data when simple would do
184 | def test_issue_key_validation(complete_jira_issue_data):
185 | assert validate_key(complete_jira_issue_data["key"])
186 | ```
187 |
188 | ### 4. Environment Testing
189 |
190 | Use parametrized fixtures for testing multiple scenarios:
191 |
192 | ```python
193 | @pytest.mark.parametrize("parametrized_auth_env",
194 | ["oauth", "basic_auth", "clean"], indirect=True)
195 | def test_auth_detection(parametrized_auth_env):
196 | # Test with different auth environments
197 | detector = AuthDetector()
198 | auth_type = detector.detect_auth_type()
199 | assert auth_type in ["oauth", "basic", None]
200 | ```
201 |
202 | ## Backward Compatibility
203 |
204 | All existing tests continue to work without modification. The enhanced fixtures:
205 |
206 | 1. **Maintain existing interfaces**: Old fixture names and return types unchanged
207 | 2. **Preserve mock data**: Original mock responses still available
208 | 3. **Support gradual migration**: Teams can adopt new fixtures incrementally
209 |
210 | ## Performance Improvements
211 |
212 | The enhanced fixture system provides significant performance improvements:
213 |
214 | 1. **Session-scoped caching**: Expensive data created once per session
215 | 2. **Lazy loading**: Data only created when needed
216 | 3. **Efficient factories**: Minimal object creation overhead
217 | 4. **Reduced duplication**: Shared fixtures across test modules
218 |
219 | ## Examples
220 |
221 | ### Basic Usage
222 |
223 | ```python
224 | def test_jira_issue_creation(make_jira_issue):
225 | # Create a custom issue
226 | issue = make_jira_issue(
227 | key="TEST-456",
228 | fields={"summary": "Custom test issue"}
229 | )
230 |
231 | # Test the issue
232 | model = JiraIssue.from_dict(issue)
233 | assert model.key == "TEST-456"
234 | assert model.summary == "Custom test issue"
235 | ```
236 |
237 | ### Advanced Usage
238 |
239 | ```python
240 | def test_complex_workflow(
241 | make_jira_issue_with_worklog,
242 | make_confluence_page_with_content,
243 | oauth_environment
244 | ):
245 | # Create issue with worklog
246 | issue = make_jira_issue_with_worklog(
247 | key="WORKFLOW-123",
248 | worklog_hours=8,
249 | worklog_comment="Development work"
250 | )
251 |
252 | # Create page with content
253 | page = make_confluence_page_with_content(
254 | title="Workflow Documentation",
255 | content="<h1>Workflow</h1><p>Process documentation</p>",
256 | labels=["workflow", "documentation"]
257 | )
258 |
259 | # Test workflow with OAuth environment
260 | workflow = ComplexWorkflow(issue, page)
261 | result = workflow.execute()
262 |
263 | assert result.success
264 | assert result.issue_key == "WORKFLOW-123"
265 | assert "Workflow Documentation" in result.documentation
266 | ```
267 |
268 | ### Integration Testing
269 |
270 | ```python
271 | def test_real_api_integration(
272 | jira_integration_client,
273 | confluence_integration_client,
274 | use_real_jira_data,
275 | use_real_confluence_data
276 | ):
277 | if not use_real_jira_data:
278 | pytest.skip("Real Jira data not available")
279 |
280 | if not use_real_confluence_data:
281 | pytest.skip("Real Confluence data not available")
282 |
283 | # Test with real API clients
284 | issues = jira_integration_client.search_issues("project = TEST")
285 | pages = confluence_integration_client.get_space_pages("TEST")
286 |
287 | assert len(issues) >= 0
288 | assert len(pages) >= 0
289 | ```
290 |
291 | ## Conclusion
292 |
293 | The enhanced fixture system provides a powerful, flexible, and efficient foundation for testing the MCP Atlassian project. It maintains backward compatibility while offering significant improvements in performance, reusability, and developer experience.
294 |
295 | Key benefits:
296 |
297 | - ⚡ **Faster test execution** through session-scoped caching
298 | - 🔧 **More flexible test data** through factory functions
299 | - 🔄 **Better reusability** across test modules
300 | - 📈 **Improved maintainability** with clear separation of concerns
301 | - 🛡️ **Backward compatibility** with existing tests
302 |
303 | For questions or suggestions about the fixture system, please refer to the test utilities documentation in `tests/utils/`.
304 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Atlassian
2 |
3 | 
4 | 
5 | 
6 | [](https://github.com/sooperset/mcp-atlassian/actions/workflows/tests.yml)
7 | 
8 |
9 | Model Context Protocol (MCP) server for Atlassian products (Confluence and Jira). This integration supports both Confluence & Jira Cloud and Server/Data Center deployments.
10 |
11 | ## Example Usage
12 |
13 | Ask your AI assistant to:
14 |
15 | - **📝 Automatic Jira Updates** - "Update Jira from our meeting notes"
16 | - **🔍 AI-Powered Confluence Search** - "Find our OKR guide in Confluence and summarize it"
17 | - **🐛 Smart Jira Issue Filtering** - "Show me urgent bugs in PROJ project from last week"
18 | - **📄 Content Creation & Management** - "Create a tech design doc for XYZ feature"
19 |
20 | ### Feature Demo
21 |
22 | https://github.com/user-attachments/assets/35303504-14c6-4ae4-913b-7c25ea511c3e
23 |
24 | <details> <summary>Confluence Demo</summary>
25 |
26 | https://github.com/user-attachments/assets/7fe9c488-ad0c-4876-9b54-120b666bb785
27 |
28 | </details>
29 |
30 | ### Compatibility
31 |
32 | | Product | Deployment Type | Support Status |
33 | |----------------|--------------------|-----------------------------|
34 | | **Confluence** | Cloud | ✅ Fully supported |
35 | | **Confluence** | Server/Data Center | ✅ Supported (version 6.0+) |
36 | | **Jira** | Cloud | ✅ Fully supported |
37 | | **Jira** | Server/Data Center | ✅ Supported (version 8.14+) |
38 |
39 | ## Quick Start Guide
40 |
41 | ### 🔐 1. Authentication Setup
42 |
43 | MCP Atlassian supports three authentication methods:
44 |
45 | #### A. API Token Authentication (Cloud) - **Recommended**
46 |
47 | 1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
48 | 2. Click **Create API token**, name it
49 | 3. Copy the token immediately
50 |
51 | #### B. Personal Access Token (Server/Data Center)
52 |
53 | 1. Go to your profile (avatar) → **Profile** → **Personal Access Tokens**
54 | 2. Click **Create token**, name it, set expiry
55 | 3. Copy the token immediately
56 |
57 | #### C. OAuth 2.0 Authentication (Cloud) - **Advanced**
58 |
59 | > [!NOTE]
60 | > OAuth 2.0 is more complex to set up but provides enhanced security features. For most users, API Token authentication (Method A) is simpler and sufficient.
61 |
62 | 1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)
63 | 2. Create an "OAuth 2.0 (3LO) integration" app
64 | 3. Configure **Permissions** (scopes) for Jira/Confluence
65 | 4. Set **Callback URL** (e.g., `http://localhost:8080/callback`)
66 | 5. Run setup wizard:
67 | ```bash
68 | docker run --rm -i \
69 | -p 8080:8080 \
70 | -v "${HOME}/.mcp-atlassian:/home/app/.mcp-atlassian" \
71 | ghcr.io/sooperset/mcp-atlassian:latest --oauth-setup -v
72 | ```
73 | 6. Follow prompts for `Client ID`, `Secret`, `URI`, and `Scope`
74 | 7. Complete browser authorization
75 | 8. Add obtained credentials to `.env` or IDE config:
76 | - `ATLASSIAN_OAUTH_CLOUD_ID` (from wizard)
77 | - `ATLASSIAN_OAUTH_CLIENT_ID`
78 | - `ATLASSIAN_OAUTH_CLIENT_SECRET`
79 | - `ATLASSIAN_OAUTH_REDIRECT_URI`
80 | - `ATLASSIAN_OAUTH_SCOPE`
81 |
82 | > [!IMPORTANT]
83 | > For the standard OAuth flow described above, include `offline_access` in your scope (e.g., `read:jira-work write:jira-work offline_access`). This allows the server to refresh the access token automatically.
84 |
85 | <details>
86 | <summary>Alternative: Using a Pre-existing OAuth Access Token (BYOT)</summary>
87 |
88 | If you are running mcp-atlassian part of a larger system that manages Atlassian OAuth 2.0 access tokens externally (e.g., through a central identity provider or another application), you can provide an access token directly to this MCP server. This method bypasses the interactive setup wizard and the server's internal token management (including refresh capabilities).
89 |
90 | **Requirements:**
91 | - A valid Atlassian OAuth 2.0 Access Token with the necessary scopes for the intended operations.
92 | - The corresponding `ATLASSIAN_OAUTH_CLOUD_ID` for your Atlassian instance.
93 |
94 | **Configuration:**
95 | To use this method, set the following environment variables (or use the corresponding command-line flags when starting the server):
96 | - `ATLASSIAN_OAUTH_CLOUD_ID`: Your Atlassian Cloud ID. (CLI: `--oauth-cloud-id`)
97 | - `ATLASSIAN_OAUTH_ACCESS_TOKEN`: Your pre-existing OAuth 2.0 access token. (CLI: `--oauth-access-token`)
98 |
99 | **Important Considerations for BYOT:**
100 | - **Token Lifecycle Management:** When using BYOT, the MCP server **does not** handle token refresh. The responsibility for obtaining, refreshing (before expiry), and revoking the access token lies entirely with you or the external system providing the token.
101 | - **Unused Variables:** The standard OAuth client variables (`ATLASSIAN_OAUTH_CLIENT_ID`, `ATLASSIAN_OAUTH_CLIENT_SECRET`, `ATLASSIAN_OAUTH_REDIRECT_URI`, `ATLASSIAN_OAUTH_SCOPE`) are **not** used and can be omitted when configuring for BYOT.
102 | - **No Setup Wizard:** The `--oauth-setup` wizard is not applicable and should not be used for this approach.
103 | - **No Token Cache Volume:** The Docker volume mount for token storage (e.g., `-v "${HOME}/.mcp-atlassian:/home/app/.mcp-atlassian"`) is also not necessary if you are exclusively using the BYOT method, as no tokens are stored or managed by this server.
104 | - **Scope:** The provided access token must already have the necessary permissions (scopes) for the Jira/Confluence operations you intend to perform.
105 |
106 | This option is useful in scenarios where OAuth credential management is centralized or handled by other infrastructure components.
107 | </details>
108 |
109 | > [!TIP]
110 | > **Multi-Cloud OAuth Support**: If you're building a multi-tenant application where users provide their own OAuth tokens, see the [Multi-Cloud OAuth Support](#multi-cloud-oauth-support) section for minimal configuration setup.
111 |
112 | ### 📦 2. Installation
113 |
114 | MCP Atlassian is distributed as a Docker image. This is the recommended way to run the server, especially for IDE integration. Ensure you have Docker installed.
115 |
116 | ```bash
117 | # Pull Pre-built Image
118 | docker pull ghcr.io/sooperset/mcp-atlassian:latest
119 | ```
120 |
121 | ## 🛠️ IDE Integration
122 |
123 | MCP Atlassian is designed to be used with AI assistants through IDE integration.
124 |
125 | > [!TIP]
126 | > **For Claude Desktop**: Locate and edit the configuration file directly:
127 | > - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
128 | > - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
129 | > - **Linux**: `~/.config/Claude/claude_desktop_config.json`
130 | >
131 | > **For Cursor**: Open Settings → MCP → + Add new global MCP server
132 |
133 | ### ⚙️ Configuration Methods
134 |
135 | There are two main approaches to configure the Docker container:
136 |
137 | 1. **Passing Variables Directly** (shown in examples below)
138 | 2. **Using an Environment File** with `--env-file` flag (shown in collapsible sections)
139 |
140 | > [!NOTE]
141 | > Common environment variables include:
142 | >
143 | > - `CONFLUENCE_SPACES_FILTER`: Filter by space keys (e.g., "DEV,TEAM,DOC")
144 | > - `JIRA_PROJECTS_FILTER`: Filter by project keys (e.g., "PROJ,DEV,SUPPORT")
145 | > - `READ_ONLY_MODE`: Set to "true" to disable write operations
146 | > - `MCP_VERBOSE`: Set to "true" for more detailed logging
147 | > - `MCP_LOGGING_STDOUT`: Set to "true" to log to stdout instead of stderr
148 | > - `ENABLED_TOOLS`: Comma-separated list of tool names to enable (e.g., "confluence_search,jira_get_issue")
149 | >
150 | > See the [.env.example](https://github.com/sooperset/mcp-atlassian/blob/main/.env.example) file for all available options.
151 |
152 |
153 | ### 📝 Configuration Examples
154 |
155 | **Method 1 (Passing Variables Directly):**
156 | ```json
157 | {
158 | "mcpServers": {
159 | "mcp-atlassian": {
160 | "command": "docker",
161 | "args": [
162 | "run",
163 | "-i",
164 | "--rm",
165 | "-e", "CONFLUENCE_URL",
166 | "-e", "CONFLUENCE_USERNAME",
167 | "-e", "CONFLUENCE_API_TOKEN",
168 | "-e", "JIRA_URL",
169 | "-e", "JIRA_USERNAME",
170 | "-e", "JIRA_API_TOKEN",
171 | "ghcr.io/sooperset/mcp-atlassian:latest"
172 | ],
173 | "env": {
174 | "CONFLUENCE_URL": "https://your-company.atlassian.net/wiki",
175 | "CONFLUENCE_USERNAME": "[email protected]",
176 | "CONFLUENCE_API_TOKEN": "your_confluence_api_token",
177 | "JIRA_URL": "https://your-company.atlassian.net",
178 | "JIRA_USERNAME": "[email protected]",
179 | "JIRA_API_TOKEN": "your_jira_api_token"
180 | }
181 | }
182 | }
183 | }
184 | ```
185 |
186 | <details>
187 | <summary>Alternative: Using Environment File</summary>
188 |
189 | ```json
190 | {
191 | "mcpServers": {
192 | "mcp-atlassian": {
193 | "command": "docker",
194 | "args": [
195 | "run",
196 | "--rm",
197 | "-i",
198 | "--env-file",
199 | "/path/to/your/mcp-atlassian.env",
200 | "ghcr.io/sooperset/mcp-atlassian:latest"
201 | ]
202 | }
203 | }
204 | }
205 | ```
206 | </details>
207 |
208 | <details>
209 | <summary>Server/Data Center Configuration</summary>
210 |
211 | For Server/Data Center deployments, use direct variable passing:
212 |
213 | ```json
214 | {
215 | "mcpServers": {
216 | "mcp-atlassian": {
217 | "command": "docker",
218 | "args": [
219 | "run",
220 | "--rm",
221 | "-i",
222 | "-e", "CONFLUENCE_URL",
223 | "-e", "CONFLUENCE_PERSONAL_TOKEN",
224 | "-e", "CONFLUENCE_SSL_VERIFY",
225 | "-e", "JIRA_URL",
226 | "-e", "JIRA_PERSONAL_TOKEN",
227 | "-e", "JIRA_SSL_VERIFY",
228 | "ghcr.io/sooperset/mcp-atlassian:latest"
229 | ],
230 | "env": {
231 | "CONFLUENCE_URL": "https://confluence.your-company.com",
232 | "CONFLUENCE_PERSONAL_TOKEN": "your_confluence_pat",
233 | "CONFLUENCE_SSL_VERIFY": "false",
234 | "JIRA_URL": "https://jira.your-company.com",
235 | "JIRA_PERSONAL_TOKEN": "your_jira_pat",
236 | "JIRA_SSL_VERIFY": "false"
237 | }
238 | }
239 | }
240 | }
241 | ```
242 |
243 | > [!NOTE]
244 | > Set `CONFLUENCE_SSL_VERIFY` and `JIRA_SSL_VERIFY` to "false" only if you have self-signed certificates.
245 |
246 | </details>
247 |
248 | <details>
249 | <summary>OAuth 2.0 Configuration (Cloud Only)</summary>
250 | <a name="oauth-20-configuration-example-cloud-only"></a>
251 |
252 | These examples show how to configure `mcp-atlassian` in your IDE (like Cursor or Claude Desktop) when using OAuth 2.0 for Atlassian Cloud.
253 |
254 | **Example for Standard OAuth 2.0 Flow (using Setup Wizard):**
255 |
256 | This configuration is for when you use the server's built-in OAuth client and have completed the [OAuth setup wizard](#c-oauth-20-authentication-cloud---advanced).
257 |
258 | ```json
259 | {
260 | "mcpServers": {
261 | "mcp-atlassian": {
262 | "command": "docker",
263 | "args": [
264 | "run",
265 | "--rm",
266 | "-i",
267 | "-v", "<path_to_your_home>/.mcp-atlassian:/home/app/.mcp-atlassian",
268 | "-e", "JIRA_URL",
269 | "-e", "CONFLUENCE_URL",
270 | "-e", "ATLASSIAN_OAUTH_CLIENT_ID",
271 | "-e", "ATLASSIAN_OAUTH_CLIENT_SECRET",
272 | "-e", "ATLASSIAN_OAUTH_REDIRECT_URI",
273 | "-e", "ATLASSIAN_OAUTH_SCOPE",
274 | "-e", "ATLASSIAN_OAUTH_CLOUD_ID",
275 | "ghcr.io/sooperset/mcp-atlassian:latest"
276 | ],
277 | "env": {
278 | "JIRA_URL": "https://your-company.atlassian.net",
279 | "CONFLUENCE_URL": "https://your-company.atlassian.net/wiki",
280 | "ATLASSIAN_OAUTH_CLIENT_ID": "YOUR_OAUTH_APP_CLIENT_ID",
281 | "ATLASSIAN_OAUTH_CLIENT_SECRET": "YOUR_OAUTH_APP_CLIENT_SECRET",
282 | "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080/callback",
283 | "ATLASSIAN_OAUTH_SCOPE": "read:jira-work write:jira-work read:confluence-content.all write:confluence-content offline_access",
284 | "ATLASSIAN_OAUTH_CLOUD_ID": "YOUR_CLOUD_ID_FROM_SETUP_WIZARD"
285 | }
286 | }
287 | }
288 | }
289 | ```
290 |
291 | > [!NOTE]
292 | > - For the Standard Flow:
293 | > - `ATLASSIAN_OAUTH_CLOUD_ID` is obtained from the `--oauth-setup` wizard output or is known for your instance.
294 | > - Other `ATLASSIAN_OAUTH_*` client variables are from your OAuth app in the Atlassian Developer Console.
295 | > - `JIRA_URL` and `CONFLUENCE_URL` for your Cloud instances are always required.
296 | > - The volume mount (`-v .../.mcp-atlassian:/home/app/.mcp-atlassian`) is crucial for persisting the OAuth tokens obtained by the wizard, enabling automatic refresh.
297 |
298 | **Example for Pre-existing Access Token (BYOT - Bring Your Own Token):**
299 |
300 | This configuration is for when you are providing your own externally managed OAuth 2.0 access token.
301 |
302 | ```json
303 | {
304 | "mcpServers": {
305 | "mcp-atlassian": {
306 | "command": "docker",
307 | "args": [
308 | "run",
309 | "--rm",
310 | "-i",
311 | "-e", "JIRA_URL",
312 | "-e", "CONFLUENCE_URL",
313 | "-e", "ATLASSIAN_OAUTH_CLOUD_ID",
314 | "-e", "ATLASSIAN_OAUTH_ACCESS_TOKEN",
315 | "ghcr.io/sooperset/mcp-atlassian:latest"
316 | ],
317 | "env": {
318 | "JIRA_URL": "https://your-company.atlassian.net",
319 | "CONFLUENCE_URL": "https://your-company.atlassian.net/wiki",
320 | "ATLASSIAN_OAUTH_CLOUD_ID": "YOUR_KNOWN_CLOUD_ID",
321 | "ATLASSIAN_OAUTH_ACCESS_TOKEN": "YOUR_PRE_EXISTING_OAUTH_ACCESS_TOKEN"
322 | }
323 | }
324 | }
325 | }
326 | ```
327 |
328 | > [!NOTE]
329 | > - For the BYOT Method:
330 | > - You primarily need `JIRA_URL`, `CONFLUENCE_URL`, `ATLASSIAN_OAUTH_CLOUD_ID`, and `ATLASSIAN_OAUTH_ACCESS_TOKEN`.
331 | > - Standard OAuth client variables (`ATLASSIAN_OAUTH_CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPE`) are **not** used.
332 | > - Token lifecycle (e.g., refreshing the token before it expires and restarting mcp-atlassian) is your responsibility, as the server will not refresh BYOT tokens.
333 |
334 | </details>
335 |
336 | <details>
337 | <summary>Proxy Configuration</summary>
338 |
339 | MCP Atlassian supports routing API requests through standard HTTP/HTTPS/SOCKS proxies. Configure using environment variables:
340 |
341 | - Supports standard `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `SOCKS_PROXY`.
342 | - Service-specific overrides are available (e.g., `JIRA_HTTPS_PROXY`, `CONFLUENCE_NO_PROXY`).
343 | - Service-specific variables override global ones for that service.
344 |
345 | Add the relevant proxy variables to the `args` (using `-e`) and `env` sections of your MCP configuration:
346 |
347 | ```json
348 | {
349 | "mcpServers": {
350 | "mcp-atlassian": {
351 | "command": "docker",
352 | "args": [
353 | "run",
354 | "-i",
355 | "--rm",
356 | "-e", "... existing Confluence/Jira vars",
357 | "-e", "HTTP_PROXY",
358 | "-e", "HTTPS_PROXY",
359 | "-e", "NO_PROXY",
360 | "ghcr.io/sooperset/mcp-atlassian:latest"
361 | ],
362 | "env": {
363 | "... existing Confluence/Jira vars": "...",
364 | "HTTP_PROXY": "http://proxy.internal:8080",
365 | "HTTPS_PROXY": "http://proxy.internal:8080",
366 | "NO_PROXY": "localhost,.your-company.com"
367 | }
368 | }
369 | }
370 | }
371 | ```
372 |
373 | Credentials in proxy URLs are masked in logs. If you set `NO_PROXY`, it will be respected for requests to matching hosts.
374 |
375 | </details>
376 | <details>
377 | <summary>Custom HTTP Headers Configuration</summary>
378 |
379 | MCP Atlassian supports adding custom HTTP headers to all API requests. This feature is particularly useful in corporate environments where additional headers are required for security, authentication, or routing purposes.
380 |
381 | Custom headers are configured using environment variables with comma-separated key=value pairs:
382 |
383 | ```json
384 | {
385 | "mcpServers": {
386 | "mcp-atlassian": {
387 | "command": "docker",
388 | "args": [
389 | "run",
390 | "-i",
391 | "--rm",
392 | "-e", "CONFLUENCE_URL",
393 | "-e", "CONFLUENCE_USERNAME",
394 | "-e", "CONFLUENCE_API_TOKEN",
395 | "-e", "CONFLUENCE_CUSTOM_HEADERS",
396 | "-e", "JIRA_URL",
397 | "-e", "JIRA_USERNAME",
398 | "-e", "JIRA_API_TOKEN",
399 | "-e", "JIRA_CUSTOM_HEADERS",
400 | "ghcr.io/sooperset/mcp-atlassian:latest"
401 | ],
402 | "env": {
403 | "CONFLUENCE_URL": "https://your-company.atlassian.net/wiki",
404 | "CONFLUENCE_USERNAME": "[email protected]",
405 | "CONFLUENCE_API_TOKEN": "your_confluence_api_token",
406 | "CONFLUENCE_CUSTOM_HEADERS": "X-Confluence-Service=mcp-integration,X-Custom-Auth=confluence-token,X-ALB-Token=secret-token",
407 | "JIRA_URL": "https://your-company.atlassian.net",
408 | "JIRA_USERNAME": "[email protected]",
409 | "JIRA_API_TOKEN": "your_jira_api_token",
410 | "JIRA_CUSTOM_HEADERS": "X-Forwarded-User=service-account,X-Company-Service=mcp-atlassian,X-Jira-Client=mcp-integration"
411 | }
412 | }
413 | }
414 | }
415 | ```
416 |
417 | **Security Considerations:**
418 |
419 | - Custom header values are masked in debug logs to protect sensitive information
420 | - Ensure custom headers don't conflict with standard HTTP or Atlassian API headers
421 | - Avoid including sensitive authentication tokens in custom headers if already using basic auth or OAuth
422 | - Headers are sent with every API request - verify they don't interfere with API functionality
423 |
424 | </details>
425 |
426 |
427 | <details>
428 | <summary>Multi-Cloud OAuth Support</summary>
429 |
430 | MCP Atlassian supports multi-cloud OAuth scenarios where each user connects to their own Atlassian cloud instance. This is useful for multi-tenant applications, chatbots, or services where users provide their own OAuth tokens.
431 |
432 | **Minimal OAuth Configuration:**
433 |
434 | 1. Enable minimal OAuth mode (no client credentials required):
435 | ```bash
436 | docker run -e ATLASSIAN_OAUTH_ENABLE=true -p 9000:9000 \
437 | ghcr.io/sooperset/mcp-atlassian:latest \
438 | --transport streamable-http --port 9000
439 | ```
440 |
441 | 2. Users provide authentication via HTTP headers:
442 | - `Authorization: Bearer <user_oauth_token>`
443 | - `X-Atlassian-Cloud-Id: <user_cloud_id>`
444 |
445 | **Example Integration (Python):**
446 | ```python
447 | import asyncio
448 | from mcp.client.streamable_http import streamablehttp_client
449 | from mcp import ClientSession
450 |
451 | user_token = "user-specific-oauth-token"
452 | user_cloud_id = "user-specific-cloud-id"
453 |
454 | async def main():
455 | # Connect to streamable HTTP server with custom headers
456 | async with streamablehttp_client(
457 | "http://localhost:9000/mcp",
458 | headers={
459 | "Authorization": f"Bearer {user_token}",
460 | "X-Atlassian-Cloud-Id": user_cloud_id
461 | }
462 | ) as (read_stream, write_stream, _):
463 | # Create a session using the client streams
464 | async with ClientSession(read_stream, write_stream) as session:
465 | # Initialize the connection
466 | await session.initialize()
467 |
468 | # Example: Get a Jira issue
469 | result = await session.call_tool(
470 | "jira_get_issue",
471 | {"issue_key": "PROJ-123"}
472 | )
473 | print(result)
474 |
475 | asyncio.run(main())
476 | ```
477 |
478 | **Configuration Notes:**
479 | - Each request can use a different cloud instance via the `X-Atlassian-Cloud-Id` header
480 | - User tokens are isolated per request - no cross-tenant data leakage
481 | - Falls back to global `ATLASSIAN_OAUTH_CLOUD_ID` if header not provided
482 | - Compatible with standard OAuth 2.0 bearer token authentication
483 |
484 | </details>
485 |
486 | <details> <summary>Single Service Configurations</summary>
487 |
488 | **For Confluence Cloud only:**
489 |
490 | ```json
491 | {
492 | "mcpServers": {
493 | "mcp-atlassian": {
494 | "command": "docker",
495 | "args": [
496 | "run",
497 | "--rm",
498 | "-i",
499 | "-e", "CONFLUENCE_URL",
500 | "-e", "CONFLUENCE_USERNAME",
501 | "-e", "CONFLUENCE_API_TOKEN",
502 | "ghcr.io/sooperset/mcp-atlassian:latest"
503 | ],
504 | "env": {
505 | "CONFLUENCE_URL": "https://your-company.atlassian.net/wiki",
506 | "CONFLUENCE_USERNAME": "[email protected]",
507 | "CONFLUENCE_API_TOKEN": "your_api_token"
508 | }
509 | }
510 | }
511 | }
512 | ```
513 |
514 | For Confluence Server/DC, use:
515 | ```json
516 | {
517 | "mcpServers": {
518 | "mcp-atlassian": {
519 | "command": "docker",
520 | "args": [
521 | "run",
522 | "--rm",
523 | "-i",
524 | "-e", "CONFLUENCE_URL",
525 | "-e", "CONFLUENCE_PERSONAL_TOKEN",
526 | "ghcr.io/sooperset/mcp-atlassian:latest"
527 | ],
528 | "env": {
529 | "CONFLUENCE_URL": "https://confluence.your-company.com",
530 | "CONFLUENCE_PERSONAL_TOKEN": "your_personal_token"
531 | }
532 | }
533 | }
534 | }
535 | ```
536 |
537 | **For Jira Cloud only:**
538 |
539 | ```json
540 | {
541 | "mcpServers": {
542 | "mcp-atlassian": {
543 | "command": "docker",
544 | "args": [
545 | "run",
546 | "--rm",
547 | "-i",
548 | "-e", "JIRA_URL",
549 | "-e", "JIRA_USERNAME",
550 | "-e", "JIRA_API_TOKEN",
551 | "ghcr.io/sooperset/mcp-atlassian:latest"
552 | ],
553 | "env": {
554 | "JIRA_URL": "https://your-company.atlassian.net",
555 | "JIRA_USERNAME": "[email protected]",
556 | "JIRA_API_TOKEN": "your_api_token"
557 | }
558 | }
559 | }
560 | }
561 | ```
562 |
563 | For Jira Server/DC, use:
564 | ```json
565 | {
566 | "mcpServers": {
567 | "mcp-atlassian": {
568 | "command": "docker",
569 | "args": [
570 | "run",
571 | "--rm",
572 | "-i",
573 | "-e", "JIRA_URL",
574 | "-e", "JIRA_PERSONAL_TOKEN",
575 | "ghcr.io/sooperset/mcp-atlassian:latest"
576 | ],
577 | "env": {
578 | "JIRA_URL": "https://jira.your-company.com",
579 | "JIRA_PERSONAL_TOKEN": "your_personal_token"
580 | }
581 | }
582 | }
583 | }
584 | ```
585 |
586 | </details>
587 |
588 | ### 👥 HTTP Transport Configuration
589 |
590 | Instead of using `stdio`, you can run the server as a persistent HTTP service using either:
591 | - `sse` (Server-Sent Events) transport at `/sse` endpoint
592 | - `streamable-http` transport at `/mcp` endpoint
593 |
594 | Both transport types support single-user and multi-user authentication:
595 |
596 | **Authentication Options:**
597 | - **Single-User**: Use server-level authentication configured via environment variables
598 | - **Multi-User**: Each user provides their own authentication:
599 | - Cloud: OAuth 2.0 Bearer tokens
600 | - Server/Data Center: Personal Access Tokens (PATs)
601 |
602 | <details> <summary>Basic HTTP Transport Setup</summary>
603 |
604 | 1. Start the server with your chosen transport:
605 |
606 | ```bash
607 | # For SSE transport
608 | docker run --rm -p 9000:9000 \
609 | --env-file /path/to/your/.env \
610 | ghcr.io/sooperset/mcp-atlassian:latest \
611 | --transport sse --port 9000 -vv
612 |
613 | # OR for streamable-http transport
614 | docker run --rm -p 9000:9000 \
615 | --env-file /path/to/your/.env \
616 | ghcr.io/sooperset/mcp-atlassian:latest \
617 | --transport streamable-http --port 9000 -vv
618 | ```
619 |
620 | 2. Configure your IDE (single-user example):
621 |
622 | **SSE Transport Example:**
623 | ```json
624 | {
625 | "mcpServers": {
626 | "mcp-atlassian-http": {
627 | "url": "http://localhost:9000/sse"
628 | }
629 | }
630 | }
631 | ```
632 |
633 | **Streamable-HTTP Transport Example:**
634 | ```json
635 | {
636 | "mcpServers": {
637 | "mcp-atlassian-service": {
638 | "url": "http://localhost:9000/mcp"
639 | }
640 | }
641 | }
642 | ```
643 | </details>
644 |
645 | <details> <summary>Multi-User Authentication Setup</summary>
646 |
647 | Here's a complete example of setting up multi-user authentication with streamable-HTTP transport:
648 |
649 | 1. First, run the OAuth setup wizard to configure the server's OAuth credentials:
650 | ```bash
651 | docker run --rm -i \
652 | -p 8080:8080 \
653 | -v "${HOME}/.mcp-atlassian:/home/app/.mcp-atlassian" \
654 | ghcr.io/sooperset/mcp-atlassian:latest --oauth-setup -v
655 | ```
656 |
657 | 2. Start the server with streamable-HTTP transport:
658 | ```bash
659 | docker run --rm -p 9000:9000 \
660 | --env-file /path/to/your/.env \
661 | ghcr.io/sooperset/mcp-atlassian:latest \
662 | --transport streamable-http --port 9000 -vv
663 | ```
664 |
665 | 3. Configure your IDE's MCP settings:
666 |
667 | **Choose the appropriate Authorization method for your Atlassian deployment:**
668 |
669 | - **Cloud (OAuth 2.0):** Use this if your organization is on Atlassian Cloud and you have an OAuth access token for each user.
670 | - **Server/Data Center (PAT):** Use this if you are on Atlassian Server or Data Center and each user has a Personal Access Token (PAT).
671 |
672 | **Cloud (OAuth 2.0) Example:**
673 | ```json
674 | {
675 | "mcpServers": {
676 | "mcp-atlassian-service": {
677 | "url": "http://localhost:9000/mcp",
678 | "headers": {
679 | "Authorization": "Bearer <USER_OAUTH_ACCESS_TOKEN>"
680 | }
681 | }
682 | }
683 | }
684 | ```
685 |
686 | **Server/Data Center (PAT) Example:**
687 | ```json
688 | {
689 | "mcpServers": {
690 | "mcp-atlassian-service": {
691 | "url": "http://localhost:9000/mcp",
692 | "headers": {
693 | "Authorization": "Token <USER_PERSONAL_ACCESS_TOKEN>"
694 | }
695 | }
696 | }
697 | }
698 | ```
699 |
700 | 4. Required environment variables in `.env`:
701 | ```bash
702 | JIRA_URL=https://your-company.atlassian.net
703 | CONFLUENCE_URL=https://your-company.atlassian.net/wiki
704 | ATLASSIAN_OAUTH_CLIENT_ID=your_oauth_app_client_id
705 | ATLASSIAN_OAUTH_CLIENT_SECRET=your_oauth_app_client_secret
706 | ATLASSIAN_OAUTH_REDIRECT_URI=http://localhost:8080/callback
707 | ATLASSIAN_OAUTH_SCOPE=read:jira-work write:jira-work read:confluence-content.all write:confluence-content offline_access
708 | ATLASSIAN_OAUTH_CLOUD_ID=your_cloud_id_from_setup_wizard
709 | ```
710 |
711 | > [!NOTE]
712 | > - The server should have its own fallback authentication configured (e.g., via environment variables for API token, PAT, or its own OAuth setup using --oauth-setup). This is used if a request doesn't include user-specific authentication.
713 | > - **OAuth**: Each user needs their own OAuth access token from your Atlassian OAuth app.
714 | > - **PAT**: Each user provides their own Personal Access Token.
715 | > - **Multi-Cloud**: For OAuth users, optionally include `X-Atlassian-Cloud-Id` header to specify which Atlassian cloud instance to use
716 | > - The server will use the user's token for API calls when provided, falling back to server auth if not
717 | > - User tokens should have appropriate scopes for their needed operations
718 |
719 | </details>
720 |
721 | ## Tools
722 |
723 | ### Key Tools
724 |
725 | #### Jira Tools
726 |
727 | - `jira_get_issue`: Get details of a specific issue
728 | - `jira_search`: Search issues using JQL
729 | - `jira_create_issue`: Create a new issue
730 | - `jira_update_issue`: Update an existing issue
731 | - `jira_transition_issue`: Transition an issue to a new status
732 | - `jira_add_comment`: Add a comment to an issue
733 |
734 | #### Confluence Tools
735 |
736 | - `confluence_search`: Search Confluence content using CQL
737 | - `confluence_get_page`: Get content of a specific page
738 | - `confluence_create_page`: Create a new page
739 | - `confluence_update_page`: Update an existing page
740 |
741 | <details> <summary>View All Tools</summary>
742 |
743 | | Operation | Jira Tools | Confluence Tools |
744 | |-----------|-------------------------------------|--------------------------------|
745 | | **Read** | `jira_search` | `confluence_search` |
746 | | | `jira_get_issue` | `confluence_get_page` |
747 | | | `jira_get_all_projects` | `confluence_get_page_children` |
748 | | | `jira_get_project_issues` | `confluence_get_comments` |
749 | | | `jira_get_worklog` | `confluence_get_labels` |
750 | | | `jira_get_transitions` | `confluence_search_user` |
751 | | | `jira_search_fields` | |
752 | | | `jira_get_agile_boards` | |
753 | | | `jira_get_board_issues` | |
754 | | | `jira_get_sprints_from_board` | |
755 | | | `jira_get_sprint_issues` | |
756 | | | `jira_get_issue_link_types` | |
757 | | | `jira_batch_get_changelogs`* | |
758 | | | `jira_get_user_profile` | |
759 | | | `jira_download_attachments` | |
760 | | | `jira_get_project_versions` | |
761 | | **Write** | `jira_create_issue` | `confluence_create_page` |
762 | | | `jira_update_issue` | `confluence_update_page` |
763 | | | `jira_delete_issue` | `confluence_delete_page` |
764 | | | `jira_batch_create_issues` | `confluence_add_label` |
765 | | | `jira_add_comment` | `confluence_add_comment` |
766 | | | `jira_transition_issue` | |
767 | | | `jira_add_worklog` | |
768 | | | `jira_link_to_epic` | |
769 | | | `jira_create_sprint` | |
770 | | | `jira_update_sprint` | |
771 | | | `jira_create_issue_link` | |
772 | | | `jira_remove_issue_link` | |
773 | | | `jira_create_version` | |
774 | | | `jira_batch_create_versions` | |
775 |
776 | </details>
777 |
778 | *Tool only available on Jira Cloud
779 |
780 | </details>
781 |
782 | ### Tool Filtering and Access Control
783 |
784 | The server provides two ways to control tool access:
785 |
786 | 1. **Tool Filtering**: Use `--enabled-tools` flag or `ENABLED_TOOLS` environment variable to specify which tools should be available:
787 |
788 | ```bash
789 | # Via environment variable
790 | ENABLED_TOOLS="confluence_search,jira_get_issue,jira_search"
791 |
792 | # Or via command line flag
793 | docker run ... --enabled-tools "confluence_search,jira_get_issue,jira_search" ...
794 | ```
795 |
796 | 2. **Read/Write Control**: Tools are categorized as read or write operations. When `READ_ONLY_MODE` is enabled, only read operations are available regardless of `ENABLED_TOOLS` setting.
797 |
798 | ## Troubleshooting & Debugging
799 |
800 | ### Common Issues
801 |
802 | - **Authentication Failures**:
803 | - For Cloud: Check your API tokens (not your account password)
804 | - For Server/Data Center: Verify your personal access token is valid and not expired
805 | - For older Confluence servers: Some older versions require basic authentication with `CONFLUENCE_USERNAME` and `CONFLUENCE_API_TOKEN` (where token is your password)
806 | - **SSL Certificate Issues**: If using Server/Data Center and encounter SSL errors, set `CONFLUENCE_SSL_VERIFY=false` or `JIRA_SSL_VERIFY=false`
807 | - **Permission Errors**: Ensure your Atlassian account has sufficient permissions to access the spaces/projects
808 | - **Custom Headers Issues**: See the ["Debugging Custom Headers"](#debugging-custom-headers) section below to analyze and resolve issues with custom headers
809 |
810 | ### Debugging Custom Headers
811 |
812 | To verify custom headers are being applied correctly:
813 |
814 | 1. **Enable Debug Logging**: Set `MCP_VERY_VERBOSE=true` to see detailed request logs
815 | ```bash
816 | # In your .env file or environment
817 | MCP_VERY_VERBOSE=true
818 | MCP_LOGGING_STDOUT=true
819 | ```
820 |
821 | 2. **Check Header Parsing**: Custom headers appear in logs with masked values for security:
822 | ```
823 | DEBUG Custom headers applied: {'X-Forwarded-User': '***', 'X-ALB-Token': '***'}
824 | ```
825 |
826 | 3. **Verify Service-Specific Headers**: Check logs to confirm the right headers are being used:
827 | ```
828 | DEBUG Jira request headers: service-specific headers applied
829 | DEBUG Confluence request headers: service-specific headers applied
830 | ```
831 |
832 | 4. **Test Header Format**: Ensure your header string format is correct:
833 | ```bash
834 | # Correct format
835 | JIRA_CUSTOM_HEADERS=X-Custom=value1,X-Other=value2
836 | CONFLUENCE_CUSTOM_HEADERS=X-Custom=value1,X-Other=value2
837 |
838 | # Incorrect formats (will be ignored)
839 | JIRA_CUSTOM_HEADERS="X-Custom=value1,X-Other=value2" # Extra quotes
840 | JIRA_CUSTOM_HEADERS=X-Custom: value1,X-Other: value2 # Colon instead of equals
841 | JIRA_CUSTOM_HEADERS=X-Custom = value1 # Spaces around equals
842 | ```
843 |
844 | **Security Note**: Header values containing sensitive information (tokens, passwords) are automatically masked in logs to prevent accidental exposure.
845 |
846 | ### Debugging Tools
847 |
848 | ```bash
849 | # Using MCP Inspector for testing
850 | npx @modelcontextprotocol/inspector uvx mcp-atlassian ...
851 |
852 | # For local development version
853 | npx @modelcontextprotocol/inspector uv --directory /path/to/your/mcp-atlassian run mcp-atlassian ...
854 |
855 | # View logs
856 | # macOS
857 | tail -n 20 -f ~/Library/Logs/Claude/mcp*.log
858 | # Windows
859 | type %APPDATA%\Claude\logs\mcp*.log | more
860 | ```
861 |
862 | ## Security
863 |
864 | - Never share API tokens
865 | - Keep .env files secure and private
866 | - See [SECURITY.md](SECURITY.md) for best practices
867 |
868 | ## Contributing
869 |
870 | We welcome contributions to MCP Atlassian! If you'd like to contribute:
871 |
872 | 1. Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide for detailed development setup instructions.
873 | 2. Make changes and submit a pull request.
874 |
875 | We use pre-commit hooks for code quality and follow semantic versioning for releases.
876 |
877 | ## License
878 |
879 | Licensed under MIT - see [LICENSE](LICENSE) file. This is not an official Atlassian product.
880 |
```
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
```markdown
1 | # Security Policy
2 |
3 | ## Reporting Issues
4 |
5 | Please report security vulnerabilities to [security contact].
6 |
7 | ## Best Practices
8 |
9 | 1. **API Tokens**
10 | - Never commit tokens to version control
11 | - Rotate tokens regularly
12 | - Use minimal required permissions
13 |
14 | 2. **Environment Variables**
15 | - Keep .env files secure and private
16 | - Use separate tokens for development/production
17 |
18 | 3. **Access Control**
19 | - Regularly audit Confluence space access
20 | - Follow principle of least privilege
21 |
22 | 4. **OAuth Client Credentials**
23 | - Never share your client secret publicly
24 | - Be aware that printing client secrets to console output poses a security risk
25 | - Console output can be logged, screen-captured, or viewed by others with access to your environment
26 | - If client secrets are exposed, regenerate them immediately in your Atlassian developer console
27 | - Consider using environment variables or secure credential storage instead of direct console output
28 |
```
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
```markdown
1 | # AGENTS
2 |
3 | > **Audience**: LLM-driven engineering agents
4 |
5 | This file provides guidance for autonomous coding agents working inside the **MCP Atlassian** repository.
6 |
7 | ---
8 |
9 | ## Repository map
10 |
11 | | Path | Purpose |
12 | | --- | --- |
13 | | `src/mcp_atlassian/` | Library source code (Python ≥ 3.10) |
14 | | ` ├─ jira/` | Jira client, mixins, and operations |
15 | | ` ├─ confluence/` | Confluence client, mixins, and operations |
16 | | ` ├─ models/` | Pydantic data models for API responses |
17 | | ` ├─ servers/` | FastMCP server implementations |
18 | | ` └─ utils/` | Shared utilities (auth, logging, SSL) |
19 | | `tests/` | Pytest test suite with fixtures |
20 | | `scripts/` | OAuth setup and testing scripts |
21 |
22 | ---
23 |
24 | ## Mandatory dev workflow
25 |
26 | ```bash
27 | uv sync --frozen --all-extras --dev # install dependencies
28 | pre-commit install # setup hooks
29 | pre-commit run --all-files # Ruff + Prettier + Pyright
30 | uv run pytest # run full test suite
31 | ```
32 |
33 | *Tests must pass* and *lint/typing must be clean* before committing.
34 |
35 | ---
36 |
37 | ## Core MCP patterns
38 |
39 | **Tool naming**: `{service}_{action}` (e.g., `jira_create_issue`)
40 |
41 | **Architecture**:
42 | - **Mixins**: Functionality split into focused mixins extending base clients
43 | - **Models**: All data structures extend `ApiModel` base class
44 | - **Auth**: Supports API tokens, PAT tokens, and OAuth 2.0
45 |
46 | ---
47 |
48 | ## Development rules
49 |
50 | 1. **Package management**: ONLY use `uv`, NEVER `pip`
51 | 2. **Branching**: NEVER work on `main`, always create feature branches
52 | 3. **Type safety**: All functions require type hints
53 | 4. **Testing**: New features need tests, bug fixes need regression tests
54 | 5. **Commits**: Use trailers for attribution, never mention tools/AI
55 |
56 | ---
57 |
58 | ## Code conventions
59 |
60 | * **Language**: Python ≥ 3.10
61 | * **Line length**: 88 characters maximum
62 | * **Imports**: Absolute imports, sorted by ruff
63 | * **Naming**: `snake_case` functions, `PascalCase` classes
64 | * **Docstrings**: Google-style for all public APIs
65 | * **Error handling**: Specific exceptions only
66 |
67 | ---
68 |
69 | ## Development guidelines
70 |
71 | 1. Do what has been asked; nothing more, nothing less
72 | 2. NEVER create files unless absolutely necessary
73 | 3. Always prefer editing existing files
74 | 4. Follow established patterns and maintain consistency
75 | 5. Run `pre-commit run --all-files` before committing
76 | 6. Fix bugs immediately when reported
77 |
78 | ---
79 |
80 | ## Quick reference
81 |
82 | ```bash
83 | # Running the server
84 | uv run mcp-atlassian # Start server
85 | uv run mcp-atlassian --oauth-setup # OAuth wizard
86 | uv run mcp-atlassian -v # Verbose mode
87 |
88 | # Git workflow
89 | git checkout -b feature/description # New feature
90 | git checkout -b fix/issue-description # Bug fix
91 | git commit --trailer "Reported-by:<name>" # Attribution
92 | git commit --trailer "Github-Issue:#<number>" # Issue reference
93 | ```
94 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Contributing to MCP Atlassian
2 |
3 | Thank you for your interest in contributing to MCP Atlassian! This document provides guidelines and instructions for contributing to this project.
4 |
5 | ## Development Setup
6 |
7 | 1. Make sure you have Python 3.10+ installed
8 | 1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
9 | 1. Fork the repository
10 | 1. Clone your fork: `git clone https://github.com/YOUR-USERNAME/mcp-atlassian.git`
11 | 1. Add the upstream remote: `git remote add upstream https://github.com/sooperset/mcp-atlassian.git`
12 | 1. Install dependencies:
13 |
14 | ```sh
15 | uv sync
16 | uv sync --frozen --all-extras --dev
17 | ```
18 |
19 | 1. Activate the virtual environment:
20 |
21 | __macOS and Linux__:
22 |
23 | ```sh
24 | source .venv/bin/activate
25 | ```
26 |
27 | __Windows__:
28 |
29 | ```powershell
30 | .venv\Scripts\activate.ps1
31 | ```
32 |
33 | 1. Set up pre-commit hooks:
34 |
35 | ```sh
36 | pre-commit install
37 | ```
38 |
39 | 1. Set up environment variables (copy from .env.example):
40 |
41 | ```bash
42 | cp .env.example .env
43 | ```
44 |
45 | ## Development Setup with local VSCode devcontainer
46 |
47 | 1. Clone your fork: `git clone https://github.com/YOUR-USERNAME/mcp-atlassian.git`
48 | 1. Add the upstream remote: `git remote add upstream https://github.com/sooperset/mcp-atlassian.git`
49 | 1. Open the project with VSCode and open with devcontainer
50 | 1. Add this bit of config to your `.vscode/settings.json`:
51 |
52 | ```json
53 | {
54 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
55 | "[python]": {
56 | "editor.defaultFormatter": "charliermarsh.ruff",
57 | "editor.formatOnSave": true
58 | }
59 | }
60 | ```
61 |
62 | ## Development Workflow
63 |
64 | 1. Create a feature or fix branch:
65 |
66 | ```sh
67 | git checkout -b feature/your-feature-name
68 | # or
69 | git checkout -b fix/issue-description
70 | ```
71 |
72 | 1. Make your changes
73 |
74 | 1. Ensure tests pass:
75 |
76 | ```sh
77 | uv run pytest
78 |
79 | # With coverage
80 | uv run pytest --cov=mcp_atlassian
81 | ```
82 |
83 | 1. Run code quality checks using pre-commit:
84 |
85 | ```bash
86 | pre-commit run --all-files
87 | ```
88 |
89 | 1. Commit your changes with clear, concise commit messages referencing issues when applicable
90 |
91 | 1. Submit a pull request to the main branch
92 |
93 | ## Code Style
94 |
95 | - Run `pre-commit run --all-files` before committing
96 | - Code quality tools (managed by pre-commit):
97 | - `ruff` for formatting and linting (88 char line limit)
98 | - `pyright` for type checking (preferred over mypy)
99 | - `prettier` for YAML/JSON formatting
100 | - Additional checks for trailing whitespace, file endings, YAML/TOML validity
101 | - Follow type annotation patterns:
102 | - `type[T]` for class types
103 | - Union types with pipe syntax: `str | None`
104 | - Standard collection types with subscripts: `list[str]`, `dict[str, Any]`
105 | - Add docstrings to all public modules, functions, classes, and methods using Google-style format:
106 |
107 | ```python
108 | def function_name(param1: str, param2: int) -> bool:
109 | """Summary of function purpose.
110 |
111 | More detailed description if needed.
112 |
113 | Args:
114 | param1: Description of param1
115 | param2: Description of param2
116 |
117 | Returns:
118 | Description of return value
119 |
120 | Raises:
121 | ValueError: When and why this exception is raised
122 | """
123 | ```
124 |
125 | ## Pull Request Process
126 |
127 | 1. Fill out the PR template with a description of your changes
128 | 2. Ensure all CI checks pass
129 | 3. Request review from maintainers
130 | 4. Address review feedback if requested
131 |
132 | ## Release Process
133 |
134 | Releases follow semantic versioning:
135 | - **MAJOR** version for incompatible API changes
136 | - **MINOR** version for backwards-compatible functionality additions
137 | - **PATCH** version for backwards-compatible bug fixes
138 |
139 | ---
140 |
141 | Thank you for contributing to MCP Atlassian!
142 |
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/tests/fixtures/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/tests/unit/models/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/tests/unit/servers/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/tests/unit/confluence/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Unit tests for the Confluence module."""
2 |
```
--------------------------------------------------------------------------------
/tests/utils/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Test utilities for MCP Atlassian test suite."""
2 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Unit tests for the MCP Atlassian utils module."""
2 |
```
--------------------------------------------------------------------------------
/.devcontainer/post-create.sh:
--------------------------------------------------------------------------------
```bash
1 | #! /bin/bash
2 | set -xe
3 |
4 | uv venv
5 | source .venv/bin/activate
6 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/servers/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """MCP Atlassian Servers Package."""
2 |
3 | from .main import main_mcp
4 |
5 | __all__ = ["main_mcp"]
6 |
```
--------------------------------------------------------------------------------
/.devcontainer/post-start.sh:
--------------------------------------------------------------------------------
```bash
1 | #! /bin/bash
2 |
3 | set -xe
4 |
5 | source .venv/bin/activate
6 |
7 | uv sync --frozen --all-extras --dev
8 | pre-commit install
9 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/exceptions.py:
--------------------------------------------------------------------------------
```python
1 | class MCPAtlassianAuthenticationError(Exception):
2 | """Raised when Atlassian API authentication fails (401/403)."""
3 |
4 | pass
5 |
```
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM "mcr.microsoft.com/devcontainers/python:3.10"
2 | RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/usr/local/bin" sh
3 | ENV UV_LINK_MODE=copy
4 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/jira/constants.py:
--------------------------------------------------------------------------------
```python
1 | """Constants specific to Jira operations."""
2 |
3 | # Set of default fields returned by Jira read operations when no specific fields are requested.
4 | DEFAULT_READ_JIRA_FIELDS: set[str] = {
5 | "summary",
6 | "description",
7 | "status",
8 | "assignee",
9 | "reporter",
10 | "labels",
11 | "priority",
12 | "created",
13 | "updated",
14 | "issuetype",
15 | }
16 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
```yaml
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: "\U0001F4AC Ask a Question or Discuss"
4 | url: https://github.com/sooperset/mcp-atlassian/discussions # GitHub Discussions 링크 (활성화되어 있다면)
5 | about: Please ask and answer questions here, or start a general discussion.
6 | - name: "\U0001F4DA Read the Documentation"
7 | url: https://github.com/sooperset/mcp-atlassian/blob/main/README.md
8 | about: Check the README for setup and usage instructions.
9 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/preprocessing/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Preprocessing modules for handling text conversion between different formats."""
2 |
3 | # Re-export the TextPreprocessor and other utilities
4 | # Backward compatibility
5 | from .base import BasePreprocessor
6 | from .base import BasePreprocessor as TextPreprocessor
7 | from .confluence import ConfluencePreprocessor
8 | from .jira import JiraPreprocessor
9 |
10 | __all__ = [
11 | "BasePreprocessor",
12 | "ConfluencePreprocessor",
13 | "JiraPreprocessor",
14 | "TextPreprocessor", # For backwards compatibility
15 | ]
16 |
```
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Lint
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 | push:
7 | branches: [ main ]
8 |
9 | jobs:
10 | lint:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - name: Set up Python
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version: '3.10'
19 | cache: 'pip'
20 |
21 | - name: Install dependencies
22 | run: |
23 | python -m pip install --upgrade pip
24 | pip install .[dev]
25 |
26 | - name: Run pre-commit
27 | uses: pre-commit/[email protected]
28 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/io.py:
--------------------------------------------------------------------------------
```python
1 | """I/O utility functions for MCP Atlassian."""
2 |
3 | from mcp_atlassian.utils.env import is_env_extended_truthy
4 |
5 |
6 | def is_read_only_mode() -> bool:
7 | """Check if the server is running in read-only mode.
8 |
9 | Read-only mode prevents all write operations (create, update, delete)
10 | while allowing all read operations. This is useful for working with
11 | production Atlassian instances where you want to prevent accidental
12 | modifications.
13 |
14 | Returns:
15 | True if read-only mode is enabled, False otherwise
16 | """
17 | return is_env_extended_truthy("READ_ONLY_MODE", "false")
18 |
```
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
```markdown
1 | <!-- Thank you for your contribution! Please provide a brief summary. -->
2 |
3 | ## Description
4 |
5 | <!-- What does this PR do? Why is it needed? -->
6 | <!-- Link related issues: Fixes #<issue_number> -->
7 |
8 | Fixes: #
9 |
10 | ## Changes
11 |
12 | <!-- Briefly list the key changes made. -->
13 |
14 | -
15 | -
16 | -
17 |
18 | ## Testing
19 |
20 | <!-- How did you test these changes? (e.g., unit tests, integration tests, manual checks) -->
21 |
22 | - [ ] Unit tests added/updated
23 | - [ ] Integration tests passed
24 | - [ ] Manual checks performed: `[briefly describe]`
25 |
26 | ## Checklist
27 |
28 | - [ ] Code follows project style guidelines (linting passes).
29 | - [ ] Tests added/updated for changes.
30 | - [ ] All tests pass locally.
31 | - [ ] Documentation updated (if needed).
32 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/servers/context.py:
--------------------------------------------------------------------------------
```python
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from typing import TYPE_CHECKING
5 |
6 | if TYPE_CHECKING:
7 | from mcp_atlassian.confluence.config import ConfluenceConfig
8 | from mcp_atlassian.jira.config import JiraConfig
9 |
10 |
11 | @dataclass(frozen=True)
12 | class MainAppContext:
13 | """
14 | Context holding fully configured Jira and Confluence configurations
15 | loaded from environment variables at server startup.
16 | These configurations include any global/default authentication details.
17 | """
18 |
19 | full_jira_config: JiraConfig | None = None
20 | full_confluence_config: ConfluenceConfig | None = None
21 | read_only: bool = False
22 | enabled_tools: list[str] | None = None
23 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Confluence API integration module.
2 |
3 | This module provides access to Confluence content through the Model Context Protocol.
4 | """
5 |
6 | from .client import ConfluenceClient
7 | from .comments import CommentsMixin
8 | from .config import ConfluenceConfig
9 | from .labels import LabelsMixin
10 | from .pages import PagesMixin
11 | from .search import SearchMixin
12 | from .spaces import SpacesMixin
13 | from .users import UsersMixin
14 |
15 |
16 | class ConfluenceFetcher(
17 | SearchMixin, SpacesMixin, PagesMixin, CommentsMixin, LabelsMixin, UsersMixin
18 | ):
19 | """Main entry point for Confluence operations, providing backward compatibility.
20 |
21 | This class combines functionality from various mixins to maintain the same
22 | API as the original ConfluenceFetcher class.
23 | """
24 |
25 | pass
26 |
27 |
28 | __all__ = ["ConfluenceFetcher", "ConfluenceConfig", "ConfluenceClient"]
29 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/constants.py:
--------------------------------------------------------------------------------
```python
1 | """Constants specific to Confluence and CQL."""
2 |
3 | # Based on https://developer.atlassian.com/cloud/confluence/cql-functions/#reserved-words
4 | # List might need refinement based on actual parser behavior
5 | # Using lowercase for case-insensitive matching
6 | RESERVED_CQL_WORDS = {
7 | "after",
8 | "and",
9 | "as",
10 | "avg",
11 | "before",
12 | "begin",
13 | "by",
14 | "commit",
15 | "contains",
16 | "count",
17 | "distinct",
18 | "else",
19 | "empty",
20 | "end",
21 | "explain",
22 | "from",
23 | "having",
24 | "if",
25 | "in",
26 | "inner",
27 | "insert",
28 | "into",
29 | "is",
30 | "isnull",
31 | "left",
32 | "like",
33 | "limit",
34 | "max",
35 | "min",
36 | "not",
37 | "null",
38 | "or",
39 | "order",
40 | "outer",
41 | "right",
42 | "select",
43 | "sum",
44 | "then",
45 | "was",
46 | "where",
47 | "update",
48 | }
49 |
50 | # Add other Confluence-specific constants here if needed in the future.
51 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/date.py:
--------------------------------------------------------------------------------
```python
1 | """Utility functions for date operations."""
2 |
3 | import logging
4 | from datetime import datetime, timezone
5 |
6 | import dateutil.parser
7 |
8 | logger = logging.getLogger("mcp-atlassian")
9 |
10 |
11 | def parse_date(date_str: str | int | None) -> datetime | None:
12 | """
13 | Parse a date string from any format to a datetime object for type consistency.
14 |
15 | The input string `date_str` accepts:
16 | - None
17 | - Epoch timestamp (only contains digits and is in milliseconds)
18 | - Other formats supported by `dateutil.parser` (ISO 8601, RFC 3339, etc.)
19 |
20 | Args:
21 | date_str: Date string
22 |
23 | Returns:
24 | Parsed date string or None if date_str is None / empty string
25 | """
26 |
27 | if not date_str:
28 | return None
29 | if isinstance(date_str, int) or date_str.isdigit():
30 | return datetime.fromtimestamp(int(date_str) / 1000, tz=timezone.utc)
31 | return dateutil.parser.parse(date_str)
32 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Utility functions for the MCP Atlassian integration.
3 | This package provides various utility functions used throughout the codebase.
4 | """
5 |
6 | from .date import parse_date
7 | from .io import is_read_only_mode
8 |
9 | # Export lifecycle utilities
10 | from .lifecycle import (
11 | ensure_clean_exit,
12 | setup_signal_handlers,
13 | )
14 | from .logging import setup_logging
15 |
16 | # Export OAuth utilities
17 | from .oauth import OAuthConfig, configure_oauth_session
18 | from .ssl import SSLIgnoreAdapter, configure_ssl_verification
19 | from .urls import is_atlassian_cloud_url
20 |
21 | # Export all utility functions for backward compatibility
22 | __all__ = [
23 | "SSLIgnoreAdapter",
24 | "configure_ssl_verification",
25 | "is_atlassian_cloud_url",
26 | "is_read_only_mode",
27 | "setup_logging",
28 | "parse_date",
29 | "parse_iso8601_date",
30 | "OAuthConfig",
31 | "configure_oauth_session",
32 | "setup_signal_handlers",
33 | "ensure_clean_exit",
34 | ]
35 |
```
--------------------------------------------------------------------------------
/tests/integration/conftest.py:
--------------------------------------------------------------------------------
```python
1 | """Configuration for integration tests."""
2 |
3 | import pytest
4 |
5 |
6 | def pytest_configure(config):
7 | """Add integration marker."""
8 | config.addinivalue_line(
9 | "markers", "integration: mark test as requiring integration with real services"
10 | )
11 |
12 |
13 | def pytest_collection_modifyitems(config, items):
14 | """Skip integration tests unless explicitly requested."""
15 | if not config.getoption("--integration", default=False):
16 | # Skip integration tests by default
17 | skip_integration = pytest.mark.skip(reason="Need --integration option to run")
18 | for item in items:
19 | if "integration" in item.keywords:
20 | item.add_marker(skip_integration)
21 |
22 |
23 | def pytest_addoption(parser):
24 | """Add integration option to pytest."""
25 | parser.addoption(
26 | "--integration",
27 | action="store_true",
28 | default=False,
29 | help="run integration tests",
30 | )
31 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_decorators.py:
--------------------------------------------------------------------------------
```python
1 | from unittest.mock import MagicMock
2 |
3 | import pytest
4 |
5 | from mcp_atlassian.utils.decorators import check_write_access
6 |
7 |
8 | class DummyContext:
9 | def __init__(self, read_only):
10 | self.request_context = MagicMock()
11 | self.request_context.lifespan_context = {
12 | "app_lifespan_context": MagicMock(read_only=read_only)
13 | }
14 |
15 |
16 | @pytest.mark.asyncio
17 | async def test_check_write_access_blocks_in_read_only():
18 | @check_write_access
19 | async def dummy_tool(ctx, x):
20 | return x * 2
21 |
22 | ctx = DummyContext(read_only=True)
23 | with pytest.raises(ValueError) as exc:
24 | await dummy_tool(ctx, 3)
25 | assert "read-only mode" in str(exc.value)
26 |
27 |
28 | @pytest.mark.asyncio
29 | async def test_check_write_access_allows_in_writable():
30 | @check_write_access
31 | async def dummy_tool(ctx, x):
32 | return x * 2
33 |
34 | ctx = DummyContext(read_only=False)
35 | result = await dummy_tool(ctx, 4)
36 | assert result == 8
37 |
```
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
```yaml
1 | # .github/workflows/tests.yml
2 |
3 | name: Run Tests
4 |
5 | on:
6 | push:
7 | branches: [ main ]
8 | pull_request:
9 | branches: [ main ]
10 |
11 | jobs:
12 | test:
13 | name: Run pytest on Python ${{ matrix.python-version }}
14 | runs-on: ubuntu-latest
15 | strategy:
16 | fail-fast: false
17 | matrix:
18 | python-version: ["3.10", "3.11", "3.12"]
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Set up Python ${{ matrix.python-version }}
25 | uses: actions/setup-python@v5
26 | with:
27 | python-version: ${{ matrix.python-version }}
28 |
29 | - name: Install uv
30 | uses: astral-sh/setup-uv@v5
31 | with:
32 | version: "0.6.10"
33 | cache: true
34 |
35 | - name: Install dependencies
36 | run: uv sync --frozen --all-extras --dev
37 |
38 | - name: Run tests with pytest
39 | # Add -v for verbose output, helpful in CI
40 | # Add basic coverage reporting to terminal logs
41 | # Skip real API validation tests as they require credentials
42 | run: uv run pytest -v -k "not test_real_api_validation" --cov=src/mcp_atlassian --cov-report=term-missing
43 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/urls.py:
--------------------------------------------------------------------------------
```python
1 | """URL-related utility functions for MCP Atlassian."""
2 |
3 | import re
4 | from urllib.parse import urlparse
5 |
6 |
7 | def is_atlassian_cloud_url(url: str) -> bool:
8 | """Determine if a URL belongs to Atlassian Cloud or Server/Data Center.
9 |
10 | Args:
11 | url: The URL to check
12 |
13 | Returns:
14 | True if the URL is for an Atlassian Cloud instance, False for Server/Data Center
15 | """
16 | # Localhost and IP-based URLs are always Server/Data Center
17 | if url is None or not url:
18 | return False
19 |
20 | parsed_url = urlparse(url)
21 | hostname = parsed_url.hostname or ""
22 |
23 | # Check for localhost or IP address
24 | if (
25 | hostname == "localhost"
26 | or re.match(r"^127\.", hostname)
27 | or re.match(r"^192\.168\.", hostname)
28 | or re.match(r"^10\.", hostname)
29 | or re.match(r"^172\.(1[6-9]|2[0-9]|3[0-1])\.", hostname)
30 | ):
31 | return False
32 |
33 | # The standard check for Atlassian cloud domains
34 | return (
35 | ".atlassian.net" in hostname
36 | or ".jira.com" in hostname
37 | or ".jira-dev.com" in hostname
38 | or "api.atlassian.com" in hostname
39 | )
40 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Confluence data models for the MCP Atlassian integration.
3 | This package provides Pydantic models for Confluence API data structures,
4 | organized by entity type.
5 |
6 | Key models:
7 | - ConfluencePage: Complete model for Confluence page content and metadata
8 | - ConfluenceSpace: Space information and settings
9 | - ConfluenceUser: User account details
10 | - ConfluenceSearchResult: Container for Confluence search (CQL) results
11 | - ConfluenceComment: Page and inline comments
12 | - ConfluenceVersion: Content versioning information
13 | """
14 |
15 | from .comment import ConfluenceComment
16 | from .common import ConfluenceAttachment, ConfluenceUser
17 | from .label import ConfluenceLabel
18 | from .page import ConfluencePage, ConfluenceVersion
19 | from .search import ConfluenceSearchResult
20 | from .space import ConfluenceSpace
21 | from .user_search import ConfluenceUserSearchResult, ConfluenceUserSearchResults
22 |
23 | __all__ = [
24 | "ConfluenceUser",
25 | "ConfluenceAttachment",
26 | "ConfluenceSpace",
27 | "ConfluenceVersion",
28 | "ConfluenceComment",
29 | "ConfluenceLabel",
30 | "ConfluencePage",
31 | "ConfluenceSearchResult",
32 | "ConfluenceUserSearchResult",
33 | "ConfluenceUserSearchResults",
34 | ]
35 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/constants.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Constants and default values for model conversions.
3 |
4 | This module centralizes all default values and fallbacks used when
5 | converting API responses to models, eliminating "magic strings" in
6 | the codebase and providing a single source of truth for defaults.
7 | """
8 |
9 | #
10 | # Common defaults
11 | #
12 | EMPTY_STRING = ""
13 | UNKNOWN = "Unknown"
14 | UNASSIGNED = "Unassigned"
15 | NONE_VALUE = "None"
16 |
17 | #
18 | # Jira defaults
19 | #
20 | JIRA_DEFAULT_ID = "0"
21 | JIRA_DEFAULT_KEY = "UNKNOWN-0"
22 |
23 | # Status defaults
24 | JIRA_DEFAULT_STATUS = {
25 | "name": UNKNOWN,
26 | "id": JIRA_DEFAULT_ID,
27 | }
28 |
29 | # Priority defaults
30 | JIRA_DEFAULT_PRIORITY = {
31 | "name": NONE_VALUE,
32 | "id": JIRA_DEFAULT_ID,
33 | }
34 |
35 | # Issue type defaults
36 | JIRA_DEFAULT_ISSUE_TYPE = {
37 | "name": UNKNOWN,
38 | "id": JIRA_DEFAULT_ID,
39 | }
40 |
41 | # Project defaults
42 | JIRA_DEFAULT_PROJECT = JIRA_DEFAULT_ID
43 |
44 | #
45 | # Confluence defaults
46 | #
47 | CONFLUENCE_DEFAULT_ID = "0"
48 |
49 | # Space defaults
50 | CONFLUENCE_DEFAULT_SPACE = {
51 | "key": EMPTY_STRING,
52 | "name": UNKNOWN,
53 | "id": CONFLUENCE_DEFAULT_ID,
54 | }
55 |
56 | # Version defaults
57 | CONFLUENCE_DEFAULT_VERSION = {
58 | "number": 0,
59 | "when": EMPTY_STRING,
60 | }
61 |
62 | # Date/Time defaults
63 | DEFAULT_TIMESTAMP = "1970-01-01T00:00:00.000+0000"
64 |
```
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
```json
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python
3 | {
4 | "name": "Python 3 Project",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | //"image": "mcr.microsoft.com/devcontainers/python:3",
7 | "build": {
8 | "dockerfile": "Dockerfile"
9 | },
10 | "containerEnv": {
11 | "PYTHONPATH": "./src"
12 | },
13 | // Features to add to the dev container. More info: https://containers.dev/features.
14 | "features": {
15 | "ghcr.io/devcontainers/features/node:1": {}
16 | },
17 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
18 | "forwardPorts": [
19 | 3000
20 | ],
21 | // Use 'postCreateCommand' to run commands after the container is created.
22 | "postCreateCommand": ".devcontainer/post-create.sh",
23 | "postStartCommand": ".devcontainer/post-start.sh",
24 | // Configure tool-specific properties.
25 | "customizations": {
26 | "vscode": {
27 | "extensions": [
28 | "ms-python.mypy-type-checker",
29 | "charliermarsh.ruff"
30 | ]
31 | }
32 | }
33 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
34 | // "remoteUser": "root"
35 | }
36 |
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | # .github/workflows/publish.yml
2 | name: Publish MCP-Atlassian to PyPI
3 |
4 | on:
5 | release:
6 | types: [published] # Triggers when a GitHub Release is published
7 | workflow_dispatch: # Allows manual triggering
8 |
9 | jobs:
10 | pypi-publish:
11 | name: Upload release to PyPI
12 | runs-on: ubuntu-latest
13 | environment:
14 | name: pypi
15 | url: https://pypi.org/p/mcp-atlassian # Link to your PyPI package
16 | permissions:
17 | id-token: write # Necessary for PyPI's trusted publishing
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 | with:
23 | fetch-depth: 0 # Required for uv-dynamic-versioning to get tags
24 |
25 | - name: Set up Python
26 | uses: actions/setup-python@v5
27 | with:
28 | python-version: "3.10" # Or your minimum supported Python
29 |
30 | - name: Install uv
31 | uses: astral-sh/setup-uv@v3
32 | with:
33 | enable-cache: true
34 |
35 | - name: Build package
36 | run: uv build
37 |
38 | - name: Publish package to PyPI
39 | run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} dist/*
40 | # If using trusted publishing (recommended), remove --token and configure it in PyPI:
41 | # run: uv publish dist/*
42 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Jira data models for the MCP Atlassian integration.
3 |
4 | This package provides Pydantic models for Jira API data structures,
5 | organized by entity type for better maintainability and clarity.
6 | """
7 |
8 | from .agile import JiraBoard, JiraSprint
9 | from .comment import JiraComment
10 | from .common import (
11 | JiraAttachment,
12 | JiraIssueType,
13 | JiraPriority,
14 | JiraResolution,
15 | JiraStatus,
16 | JiraStatusCategory,
17 | JiraTimetracking,
18 | JiraUser,
19 | )
20 | from .issue import JiraIssue
21 | from .link import (
22 | JiraIssueLink,
23 | JiraIssueLinkType,
24 | JiraLinkedIssue,
25 | JiraLinkedIssueFields,
26 | )
27 | from .project import JiraProject
28 | from .search import JiraSearchResult
29 | from .workflow import JiraTransition
30 | from .worklog import JiraWorklog
31 |
32 | __all__ = [
33 | # Common models
34 | "JiraUser",
35 | "JiraStatusCategory",
36 | "JiraStatus",
37 | "JiraIssueType",
38 | "JiraPriority",
39 | "JiraAttachment",
40 | "JiraResolution",
41 | "JiraTimetracking",
42 | # Entity-specific models
43 | "JiraComment",
44 | "JiraWorklog",
45 | "JiraProject",
46 | "JiraTransition",
47 | "JiraBoard",
48 | "JiraSprint",
49 | "JiraIssue",
50 | "JiraSearchResult",
51 | "JiraIssueLinkType",
52 | "JiraIssueLink",
53 | "JiraLinkedIssue",
54 | "JiraLinkedIssueFields",
55 | ]
56 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_date.py:
--------------------------------------------------------------------------------
```python
1 | "Tests for the date utility functions."
2 |
3 | import pytest
4 |
5 | from mcp_atlassian.utils import parse_date
6 |
7 |
8 | def test_parse_date_invalid_input():
9 | """Test that parse_date returns an empty string for invalid dates."""
10 | with pytest.raises(ValueError):
11 | parse_date("invalid")
12 |
13 |
14 | def test_parse_date_valid():
15 | """Test that parse_date returns the correct date for valid dates."""
16 | assert str(parse_date("2021-01-01")) == "2021-01-01 00:00:00"
17 |
18 |
19 | def test_parse_date_epoch_as_str():
20 | """Test that parse_date returns the correct date for epoch timestamps as str."""
21 | assert str(parse_date("1612156800000")) == "2021-02-01 05:20:00+00:00"
22 |
23 |
24 | def test_parse_date_epoch_as_int():
25 | """Test that parse_date returns the correct date for epoch timestamps as int."""
26 | assert str(parse_date(1612156800000)) == "2021-02-01 05:20:00+00:00"
27 |
28 |
29 | def test_parse_date_iso8601():
30 | """Test that parse_date returns the correct date for ISO 8601."""
31 | assert str(parse_date("2021-01-01T00:00:00Z")) == "2021-01-01 00:00:00+00:00"
32 |
33 |
34 | def test_parse_date_rfc3339():
35 | """Test that parse_date returns the correct date for RFC 3339."""
36 | assert (
37 | str(parse_date("1937-01-01T12:00:27.87+00:20"))
38 | == "1937-01-01 12:00:27.870000+00:20"
39 | )
40 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/version.py:
--------------------------------------------------------------------------------
```python
1 | from typing import Any
2 |
3 | from ..base import ApiModel
4 |
5 |
6 | class JiraVersion(ApiModel):
7 | """
8 | Model representing a Jira project version (fix version).
9 | """
10 |
11 | id: str
12 | name: str
13 | description: str | None = None
14 | startDate: str | None = None # noqa: N815
15 | releaseDate: str | None = None # noqa: N815
16 | released: bool = False
17 | archived: bool = False
18 |
19 | @classmethod
20 | def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraVersion":
21 | """Create JiraVersion from API response."""
22 | return cls(
23 | id=str(data.get("id", "")),
24 | name=str(data.get("name", "")),
25 | description=data.get("description"),
26 | startDate=data.get("startDate"),
27 | releaseDate=data.get("releaseDate"),
28 | released=bool(data.get("released", False)),
29 | archived=bool(data.get("archived", False)),
30 | )
31 |
32 | def to_simplified_dict(self) -> dict[str, Any]:
33 | """Convert to simple dict for API output."""
34 | result = {
35 | "id": self.id,
36 | "name": self.name,
37 | "released": self.released,
38 | "archived": self.archived,
39 | }
40 | if self.description is not None:
41 | result["description"] = self.description
42 | if self.startDate is not None:
43 | result["startDate"] = self.startDate
44 | if self.releaseDate is not None:
45 | result["releaseDate"] = self.releaseDate
46 | return result
47 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/label.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Confluence label models.
3 | This module provides Pydantic models for Confluence page labels.
4 | """
5 |
6 | import logging
7 | from typing import Any
8 |
9 | from ..base import ApiModel
10 | from ..constants import (
11 | CONFLUENCE_DEFAULT_ID,
12 | EMPTY_STRING,
13 | )
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class ConfluenceLabel(ApiModel):
19 | """
20 | Model representing a Confluence label.
21 | """
22 |
23 | id: str = CONFLUENCE_DEFAULT_ID
24 | name: str = EMPTY_STRING
25 | prefix: str = "global"
26 | label: str = EMPTY_STRING
27 | type: str = "label"
28 |
29 | @classmethod
30 | def from_api_response(
31 | cls, data: dict[str, Any], **kwargs: Any
32 | ) -> "ConfluenceLabel":
33 | """
34 | Create a ConfluenceLabel from a Confluence API response.
35 |
36 | Args:
37 | data: The label data from the Confluence API
38 |
39 | Returns:
40 | A ConfluenceLabel instance
41 | """
42 | if not data:
43 | return cls()
44 |
45 | return cls(
46 | id=str(data.get("id", CONFLUENCE_DEFAULT_ID)),
47 | name=data.get("name", EMPTY_STRING),
48 | prefix=data.get("prefix", "global"),
49 | label=data.get("label", EMPTY_STRING),
50 | type=data.get("type", "label"),
51 | )
52 |
53 | def to_simplified_dict(self) -> dict[str, Any]:
54 | """Convert to simplified dictionary for API response."""
55 | result = {
56 | "id": self.id,
57 | "name": self.name,
58 | "prefix": self.prefix,
59 | "label": self.label,
60 | }
61 |
62 | return result
63 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/space.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Confluence space models.
3 | This module provides Pydantic models for Confluence spaces.
4 | """
5 |
6 | import logging
7 | from typing import Any
8 |
9 | from ..base import ApiModel
10 | from ..constants import CONFLUENCE_DEFAULT_ID, EMPTY_STRING, UNKNOWN
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | class ConfluenceSpace(ApiModel):
16 | """
17 | Model representing a Confluence space.
18 | """
19 |
20 | id: str = CONFLUENCE_DEFAULT_ID
21 | key: str = EMPTY_STRING
22 | name: str = UNKNOWN
23 | type: str = "global" # "global", "personal", etc.
24 | status: str = "current" # "current", "archived", etc.
25 |
26 | @classmethod
27 | def from_api_response(
28 | cls, data: dict[str, Any], **kwargs: Any
29 | ) -> "ConfluenceSpace":
30 | """
31 | Create a ConfluenceSpace from a Confluence API response.
32 |
33 | Args:
34 | data: The space data from the Confluence API
35 |
36 | Returns:
37 | A ConfluenceSpace instance
38 | """
39 | if not data:
40 | return cls()
41 |
42 | return cls(
43 | id=str(data.get("id", CONFLUENCE_DEFAULT_ID)),
44 | key=data.get("key", EMPTY_STRING),
45 | name=data.get("name", UNKNOWN),
46 | type=data.get("type", "global"),
47 | status=data.get("status", "current"),
48 | )
49 |
50 | def to_simplified_dict(self) -> dict[str, Any]:
51 | """Convert to simplified dictionary for API response."""
52 | return {
53 | "key": self.key,
54 | "name": self.name,
55 | "type": self.type,
56 | "status": self.status,
57 | }
58 |
```
--------------------------------------------------------------------------------
/tests/unit/jira/test_constants.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for Jira constants.
2 |
3 | Focused tests for Jira constants, validating correct values and business logic.
4 | """
5 |
6 | from mcp_atlassian.jira.constants import DEFAULT_READ_JIRA_FIELDS
7 |
8 |
9 | class TestDefaultReadJiraFields:
10 | """Test suite for DEFAULT_READ_JIRA_FIELDS constant."""
11 |
12 | def test_type_and_structure(self):
13 | """Test that DEFAULT_READ_JIRA_FIELDS is a set of strings."""
14 | assert isinstance(DEFAULT_READ_JIRA_FIELDS, set)
15 | assert all(isinstance(field, str) for field in DEFAULT_READ_JIRA_FIELDS)
16 | assert len(DEFAULT_READ_JIRA_FIELDS) == 10
17 |
18 | def test_contains_expected_jira_fields(self):
19 | """Test that DEFAULT_READ_JIRA_FIELDS contains the correct Jira fields."""
20 | expected_fields = {
21 | "summary",
22 | "description",
23 | "status",
24 | "assignee",
25 | "reporter",
26 | "labels",
27 | "priority",
28 | "created",
29 | "updated",
30 | "issuetype",
31 | }
32 | assert DEFAULT_READ_JIRA_FIELDS == expected_fields
33 |
34 | def test_essential_fields_present(self):
35 | """Test that essential Jira fields are included."""
36 | essential_fields = {"summary", "status", "issuetype"}
37 | assert essential_fields.issubset(DEFAULT_READ_JIRA_FIELDS)
38 |
39 | def test_field_format_validity(self):
40 | """Test that field names are valid for API usage."""
41 | for field in DEFAULT_READ_JIRA_FIELDS:
42 | # Fields should be non-empty, lowercase, no spaces
43 | assert field and field.islower()
44 | assert " " not in field
45 | assert not field.startswith("_")
46 | assert not field.endswith("_")
47 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: "\U0001F680 Feature Request"
2 | description: Suggest an idea or enhancement for mcp-atlassian
3 | title: "[Feature]: "
4 | labels: ["enhancement"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for suggesting an idea! Please describe your proposal clearly.
10 | - type: textarea
11 | id: problem
12 | attributes:
13 | label: Is your feature request related to a problem? Please describe.
14 | description: A clear and concise description of what the problem is.
15 | placeholder: "e.g., I'm always frustrated when [...] because [...]"
16 | validations:
17 | required: true
18 | - type: textarea
19 | id: solution
20 | attributes:
21 | label: Describe the solution you'd like
22 | description: A clear and concise description of what you want to happen. How should the feature work?
23 | placeholder: "Add a new tool `confluence_move_page` that takes `page_id` and `target_parent_id` arguments..."
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: alternatives
28 | attributes:
29 | label: Describe alternatives you've considered
30 | description: A clear and concise description of any alternative solutions or features you've considered.
31 | placeholder: "I considered modifying the `confluence_update_page` tool, but..."
32 | - type: textarea
33 | id: use-case
34 | attributes:
35 | label: Use Case
36 | description: How would this feature benefit users? Who is the target audience?
37 | placeholder: "This would allow AI agents to automatically organize documentation..."
38 | - type: textarea
39 | id: additional-context
40 | attributes:
41 | label: Additional Context
42 | description: Add any other context, mockups, or links about the feature request here.
43 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Use a Python image with uv pre-installed
2 | FROM ghcr.io/astral-sh/uv:python3.10-alpine AS uv
3 |
4 | # Install the project into `/app`
5 | WORKDIR /app
6 |
7 | # Enable bytecode compilation
8 | ENV UV_COMPILE_BYTECODE=1
9 |
10 | # Copy from the cache instead of linking since it's a mounted volume
11 | ENV UV_LINK_MODE=copy
12 |
13 | # Generate proper TOML lockfile first
14 | RUN --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
15 | --mount=type=bind,source=README.md,target=README.md \
16 | uv lock
17 |
18 | # Install the project's dependencies using the lockfile
19 | RUN --mount=type=cache,target=/root/.cache/uv \
20 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
21 | --mount=type=bind,source=uv.lock,target=uv.lock \
22 | uv sync --frozen --no-install-project --no-dev --no-editable
23 |
24 | # Then, add the rest of the project source code and install it
25 | ADD . /app
26 | RUN --mount=type=cache,target=/root/.cache/uv \
27 | --mount=type=bind,source=uv.lock,target=uv.lock \
28 | uv sync --frozen --no-dev --no-editable
29 |
30 | # Remove unnecessary files from the virtual environment before copying
31 | RUN find /app/.venv -name '__pycache__' -type d -exec rm -rf {} + && \
32 | find /app/.venv -name '*.pyc' -delete && \
33 | find /app/.venv -name '*.pyo' -delete && \
34 | echo "Cleaned up .venv"
35 |
36 | # Final stage
37 | FROM python:3.10-alpine
38 |
39 | # Create a non-root user 'app'
40 | RUN adduser -D -h /home/app -s /bin/sh app
41 | WORKDIR /app
42 | USER app
43 |
44 | COPY --from=uv --chown=app:app /app/.venv /app/.venv
45 |
46 | # Place executables in the environment at the front of the path
47 | ENV PATH="/app/.venv/bin:$PATH"
48 |
49 | # For minimal OAuth setup without environment variables, use:
50 | # docker run -e ATLASSIAN_OAUTH_ENABLE=true -p 8000:8000 your-image
51 | # Then provide authentication via headers:
52 | # Authorization: Bearer <your_oauth_token>
53 | # X-Atlassian-Cloud-Id: <your_cloud_id>
54 |
55 | ENTRYPOINT ["mcp-atlassian"]
56 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_logging.py:
--------------------------------------------------------------------------------
```python
1 | import io
2 | import logging
3 |
4 | from mcp_atlassian.utils.logging import setup_logging
5 |
6 |
7 | def test_setup_logging_default_level():
8 | """Test setup_logging with default WARNING level"""
9 | logger = setup_logging()
10 |
11 | # Check logger level is set to WARNING
12 | assert logger.level == logging.WARNING
13 |
14 | # Check root logger is configured
15 | root_logger = logging.getLogger()
16 | assert root_logger.level == logging.WARNING
17 |
18 | # Verify handler and formatter
19 | assert len(root_logger.handlers) == 1
20 | handler = root_logger.handlers[0]
21 | assert isinstance(handler, logging.Handler)
22 | assert handler.formatter._fmt == "%(levelname)s - %(name)s - %(message)s"
23 |
24 |
25 | def test_setup_logging_custom_level():
26 | """Test setup_logging with custom DEBUG level"""
27 | logger = setup_logging(logging.DEBUG)
28 |
29 | # Check logger level is set to DEBUG
30 | assert logger.level == logging.DEBUG
31 |
32 | # Check root logger is configured
33 | root_logger = logging.getLogger()
34 | assert root_logger.level == logging.DEBUG
35 |
36 |
37 | def test_setup_logging_removes_existing_handlers():
38 | """Test that setup_logging removes existing handlers"""
39 | # Add a test handler
40 | root_logger = logging.getLogger()
41 | test_handler = logging.StreamHandler()
42 | root_logger.addHandler(test_handler)
43 | initial_handler_count = len(root_logger.handlers)
44 |
45 | # Setup logging should remove existing handler
46 | setup_logging()
47 |
48 | # Verify only one handler remains
49 | assert len(root_logger.handlers) == 1
50 | assert test_handler not in root_logger.handlers
51 |
52 |
53 | def test_setup_logging_logger_name():
54 | """Test that setup_logging creates logger with correct name"""
55 | logger = setup_logging()
56 | assert logger.name == "mcp-atlassian"
57 |
58 |
59 | def test_setup_logging_logging_stream():
60 | """Test that setup_logging uses the correct stream"""
61 | stream = io.StringIO()
62 | logger = setup_logging(logging.DEBUG, stream)
63 | logger.debug("test")
64 | assert stream.getvalue() == f"DEBUG - {logger.name} - test\n"
65 |
```
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: 'Manage Stale Issues and PRs'
2 |
3 | on:
4 | schedule:
5 | # Runs daily at midnight UTC
6 | - cron: '0 0 * * *'
7 | workflow_dispatch:
8 |
9 | permissions:
10 | issues: write
11 | pull-requests: write
12 |
13 | jobs:
14 | stale:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/stale@v9
18 | with:
19 | # === Core Timing Settings ===
20 | days-before-stale: 14 # Start with 2 weeks
21 | days-before-close: -1 # Disable automatic closing initially
22 |
23 | # === Labels ===
24 | stale-issue-label: 'stale'
25 | stale-pr-label: 'stale'
26 |
27 | # === Bot Messages ===
28 | stale-issue-message: >
29 | This issue has been automatically marked as stale because it has not had
30 | recent activity for 14 days. It will be closed if no further activity occurs.
31 | Please leave a comment or remove the 'stale' label if you believe this issue is still relevant.
32 | Thank you for your contributions!
33 | stale-pr-message: >
34 | This pull request has been automatically marked as stale because it has not had
35 | recent activity for 14 days. It will be closed if no further activity occurs.
36 | Please leave a comment or remove the 'stale' label if you believe this PR is still relevant.
37 | Thank you for your contributions!
38 |
39 | # === Exemptions ===
40 | exempt-issue-labels: 'pinned,security,good first issue,help wanted,bug,enhancement,feature request,documentation,awaiting-user-feedback,needs-investigation'
41 | exempt-pr-labels: 'pinned,security,work-in-progress,awaiting-review,do-not-merge'
42 | exempt-all-milestones: true
43 | exempt-all-assignees: false # Consider if most issues should have assignees
44 | exempt-draft-pr: true
45 |
46 | # === Behavior Control ===
47 | remove-stale-when-updated: true
48 | operations-per-run: 30
49 | debug-only: false
50 | delete-branch: false
51 |
52 | # --- Statistics ---
53 | enable-statistics: true
54 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/jira/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Jira API module for mcp_atlassian.
2 |
3 | This module provides various Jira API client implementations.
4 | """
5 |
6 | # flake8: noqa
7 |
8 | # Re-export the Jira class for backward compatibility
9 | from atlassian.jira import Jira
10 |
11 | from .client import JiraClient
12 | from .comments import CommentsMixin
13 | from .config import JiraConfig
14 | from .epics import EpicsMixin
15 | from .fields import FieldsMixin
16 | from .formatting import FormattingMixin
17 | from .issues import IssuesMixin
18 | from .links import LinksMixin
19 | from .projects import ProjectsMixin
20 | from .search import SearchMixin
21 | from .sprints import SprintsMixin
22 | from .transitions import TransitionsMixin
23 | from .users import UsersMixin
24 | from .worklog import WorklogMixin
25 | from .boards import BoardsMixin
26 | from .attachments import AttachmentsMixin
27 |
28 |
29 | class JiraFetcher(
30 | ProjectsMixin,
31 | FieldsMixin,
32 | FormattingMixin,
33 | TransitionsMixin,
34 | WorklogMixin,
35 | EpicsMixin,
36 | CommentsMixin,
37 | SearchMixin,
38 | IssuesMixin,
39 | UsersMixin,
40 | BoardsMixin,
41 | SprintsMixin,
42 | AttachmentsMixin,
43 | LinksMixin,
44 | ):
45 | """
46 | The main Jira client class providing access to all Jira operations.
47 |
48 | This class inherits from multiple mixins that provide specific functionality:
49 | - ProjectsMixin: Project-related operations
50 | - FieldsMixin: Field-related operations
51 | - FormattingMixin: Content formatting utilities
52 | - TransitionsMixin: Issue transition operations
53 | - WorklogMixin: Worklog operations
54 | - EpicsMixin: Epic operations
55 | - CommentsMixin: Comment operations
56 | - SearchMixin: Search operations
57 | - IssuesMixin: Issue operations
58 | - UsersMixin: User operations
59 | - BoardsMixin: Board operations
60 | - SprintsMixin: Sprint operations
61 | - AttachmentsMixin: Attachment download operations
62 | - LinksMixin: Issue link operations
63 |
64 | The class structure is designed to maintain backward compatibility while
65 | improving code organization and maintainability.
66 | """
67 |
68 | pass
69 |
70 |
71 | __all__ = ["JiraFetcher", "JiraConfig", "JiraClient", "Jira"]
72 |
```
--------------------------------------------------------------------------------
/tests/utils/base.py:
--------------------------------------------------------------------------------
```python
1 | """Base test classes and utilities for MCP Atlassian tests."""
2 |
3 | from unittest.mock import AsyncMock, MagicMock
4 |
5 | import pytest
6 |
7 |
8 | class BaseMixinTest:
9 | """Base class for mixin tests with common setup patterns."""
10 |
11 | @pytest.fixture
12 | def mock_config(self):
13 | """Mock configuration for testing."""
14 | return MagicMock()
15 |
16 | @pytest.fixture
17 | def mock_client(self):
18 | """Mock client with common methods."""
19 | client = MagicMock()
20 | # Add common client methods
21 | client.get = AsyncMock()
22 | client.post = AsyncMock()
23 | client.put = AsyncMock()
24 | client.delete = AsyncMock()
25 | return client
26 |
27 |
28 | class BaseAuthTest:
29 | """Base class for authentication-related tests."""
30 |
31 | @pytest.fixture
32 | def oauth_env_vars(self):
33 | """Standard OAuth environment variables."""
34 | return {
35 | "ATLASSIAN_OAUTH_CLIENT_ID": "test-client-id",
36 | "ATLASSIAN_OAUTH_CLIENT_SECRET": "test-client-secret",
37 | "ATLASSIAN_OAUTH_REDIRECT_URI": "http://localhost:8080/callback",
38 | "ATLASSIAN_OAUTH_SCOPE": "read:jira-work write:jira-work",
39 | "ATLASSIAN_OAUTH_CLOUD_ID": "test-cloud-id",
40 | }
41 |
42 | @pytest.fixture
43 | def basic_auth_env_vars(self):
44 | """Standard basic auth environment variables."""
45 | return {
46 | "JIRA_URL": "https://test.atlassian.net",
47 | "JIRA_USERNAME": "[email protected]",
48 | "JIRA_API_TOKEN": "test-token",
49 | "CONFLUENCE_URL": "https://test.atlassian.net/wiki",
50 | "CONFLUENCE_USERNAME": "[email protected]",
51 | "CONFLUENCE_API_TOKEN": "test-token",
52 | }
53 |
54 |
55 | class BaseServerTest:
56 | """Base class for server-related tests."""
57 |
58 | @pytest.fixture
59 | def mock_request(self):
60 | """Mock FastMCP request object."""
61 | request = MagicMock()
62 | request.state = MagicMock()
63 | return request
64 |
65 | @pytest.fixture
66 | def mock_context(self):
67 | """Mock FastMCP context object."""
68 | return MagicMock()
69 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/tools.py:
--------------------------------------------------------------------------------
```python
1 | """Tool-related utility functions for MCP Atlassian."""
2 |
3 | import logging
4 | import os
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | def get_enabled_tools() -> list[str] | None:
10 | """Get the list of enabled tools from environment variable.
11 |
12 | This function reads and parses the ENABLED_TOOLS environment variable
13 | to determine which tools should be available in the server.
14 |
15 | The environment variable should contain a comma-separated list of tool names.
16 | Whitespace around tool names is stripped.
17 |
18 | Returns:
19 | List of enabled tool names if ENABLED_TOOLS is set and non-empty,
20 | None if ENABLED_TOOLS is not set or empty after stripping whitespace.
21 |
22 | Examples:
23 | ENABLED_TOOLS="tool1,tool2" -> ["tool1", "tool2"]
24 | ENABLED_TOOLS="tool1, tool2 , tool3" -> ["tool1", "tool2", "tool3"]
25 | ENABLED_TOOLS="" -> None
26 | ENABLED_TOOLS not set -> None
27 | ENABLED_TOOLS=" , " -> None
28 | """
29 | enabled_tools_str = os.getenv("ENABLED_TOOLS")
30 | if not enabled_tools_str:
31 | logger.debug("ENABLED_TOOLS environment variable not set or empty.")
32 | return None
33 |
34 | # Split by comma and strip whitespace
35 | tools = [tool.strip() for tool in enabled_tools_str.split(",")]
36 | # Filter out empty strings
37 | tools = [tool for tool in tools if tool]
38 |
39 | logger.debug(f"Parsed enabled tools from environment: {tools}")
40 |
41 | return tools if tools else None
42 |
43 |
44 | def should_include_tool(tool_name: str, enabled_tools: list[str] | None) -> bool:
45 | """Check if a tool should be included based on the enabled tools list.
46 |
47 | Args:
48 | tool_name: The name of the tool to check.
49 | enabled_tools: List of enabled tool names, or None to include all tools.
50 |
51 | Returns:
52 | True if the tool should be included, False otherwise.
53 | """
54 | if enabled_tools is None:
55 | logger.debug(
56 | f"Including tool '{tool_name}' because enabled_tools filter is None."
57 | )
58 | return True
59 | should_include = tool_name in enabled_tools
60 | logger.debug(
61 | f"Tool '{tool_name}' included: {should_include} (based on enabled_tools: {enabled_tools})"
62 | )
63 | return should_include
64 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/search.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Confluence search result models.
3 | This module provides Pydantic models for Confluence search (CQL) results.
4 | """
5 |
6 | import logging
7 | from typing import Any
8 |
9 | from pydantic import Field, model_validator
10 |
11 | from ..base import ApiModel, TimestampMixin
12 |
13 | # Import other necessary models using relative imports
14 | from .page import ConfluencePage
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 |
19 | class ConfluenceSearchResult(ApiModel, TimestampMixin):
20 | """
21 | Model representing a Confluence search (CQL) result.
22 | """
23 |
24 | total_size: int = 0
25 | start: int = 0
26 | limit: int = 0
27 | results: list[ConfluencePage] = Field(default_factory=list)
28 | cql_query: str | None = None
29 | search_duration: int | None = None
30 |
31 | @classmethod
32 | def from_api_response(
33 | cls, data: dict[str, Any], **kwargs: Any
34 | ) -> "ConfluenceSearchResult":
35 | """
36 | Create a ConfluenceSearchResult from a Confluence API response.
37 |
38 | Args:
39 | data: The search result data from the Confluence API
40 | **kwargs: Additional context parameters, including:
41 | - base_url: Base URL for constructing page URLs
42 | - is_cloud: Whether this is a cloud instance (affects URL format)
43 |
44 | Returns:
45 | A ConfluenceSearchResult instance
46 | """
47 | if not data:
48 | return cls()
49 |
50 | # Convert search results to ConfluencePage models
51 | results = []
52 | for item in data.get("results", []):
53 | # In Confluence search, the content is nested inside the result item
54 | if content := item.get("content"):
55 | results.append(ConfluencePage.from_api_response(content, **kwargs))
56 |
57 | return cls(
58 | total_size=data.get("totalSize", 0),
59 | start=data.get("start", 0),
60 | limit=data.get("limit", 0),
61 | results=results,
62 | cql_query=data.get("cqlQuery"),
63 | search_duration=data.get("searchDuration"),
64 | )
65 |
66 | @model_validator(mode="after")
67 | def validate_search_result(self) -> "ConfluenceSearchResult":
68 | """Validate the search result and log warnings if needed."""
69 | if self.total_size > 0 and not self.results:
70 | logger.warning(
71 | "Search found %d pages but no content data was returned",
72 | self.total_size,
73 | )
74 | return self
75 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_io.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the I/O utilities module."""
2 |
3 | import os
4 | from unittest.mock import patch
5 |
6 | from mcp_atlassian.utils.io import is_read_only_mode
7 |
8 |
9 | def test_is_read_only_mode_default():
10 | """Test that is_read_only_mode returns False by default."""
11 | # Arrange - Make sure READ_ONLY_MODE is not set
12 | with patch.dict(os.environ, clear=True):
13 | # Act
14 | result = is_read_only_mode()
15 |
16 | # Assert
17 | assert result is False
18 |
19 |
20 | def test_is_read_only_mode_true():
21 | """Test that is_read_only_mode returns True when environment variable is set to true."""
22 | # Arrange - Set READ_ONLY_MODE to true
23 | with patch.dict(os.environ, {"READ_ONLY_MODE": "true"}):
24 | # Act
25 | result = is_read_only_mode()
26 |
27 | # Assert
28 | assert result is True
29 |
30 |
31 | def test_is_read_only_mode_yes():
32 | """Test that is_read_only_mode returns True when environment variable is set to yes."""
33 | # Arrange - Set READ_ONLY_MODE to yes
34 | with patch.dict(os.environ, {"READ_ONLY_MODE": "yes"}):
35 | # Act
36 | result = is_read_only_mode()
37 |
38 | # Assert
39 | assert result is True
40 |
41 |
42 | def test_is_read_only_mode_one():
43 | """Test that is_read_only_mode returns True when environment variable is set to 1."""
44 | # Arrange - Set READ_ONLY_MODE to 1
45 | with patch.dict(os.environ, {"READ_ONLY_MODE": "1"}):
46 | # Act
47 | result = is_read_only_mode()
48 |
49 | # Assert
50 | assert result is True
51 |
52 |
53 | def test_is_read_only_mode_on():
54 | """Test that is_read_only_mode returns True when environment variable is set to on."""
55 | # Arrange - Set READ_ONLY_MODE to on
56 | with patch.dict(os.environ, {"READ_ONLY_MODE": "on"}):
57 | # Act
58 | result = is_read_only_mode()
59 |
60 | # Assert
61 | assert result is True
62 |
63 |
64 | def test_is_read_only_mode_uppercase():
65 | """Test that is_read_only_mode is case-insensitive."""
66 | # Arrange - Set READ_ONLY_MODE to TRUE (uppercase)
67 | with patch.dict(os.environ, {"READ_ONLY_MODE": "TRUE"}):
68 | # Act
69 | result = is_read_only_mode()
70 |
71 | # Assert
72 | assert result is True
73 |
74 |
75 | def test_is_read_only_mode_false():
76 | """Test that is_read_only_mode returns False when environment variable is set to false."""
77 | # Arrange - Set READ_ONLY_MODE to false
78 | with patch.dict(os.environ, {"READ_ONLY_MODE": "false"}):
79 | # Act
80 | result = is_read_only_mode()
81 |
82 | # Assert
83 | assert result is False
84 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Pydantic models for Jira and Confluence API responses.
3 |
4 | This package provides type-safe models for working with Atlassian API data,
5 | including conversion methods from API responses to structured models and
6 | simplified dictionaries for API responses.
7 | """
8 |
9 | # Re-export models for easier imports
10 | from .base import ApiModel, TimestampMixin
11 |
12 | # Confluence models (Import from the new structure)
13 | from .confluence import (
14 | ConfluenceAttachment,
15 | ConfluenceComment,
16 | ConfluenceLabel,
17 | ConfluencePage,
18 | ConfluenceSearchResult,
19 | ConfluenceSpace,
20 | ConfluenceUser,
21 | ConfluenceVersion,
22 | )
23 | from .constants import ( # noqa: F401 - Keep constants available
24 | CONFLUENCE_DEFAULT_ID,
25 | CONFLUENCE_DEFAULT_SPACE,
26 | CONFLUENCE_DEFAULT_VERSION,
27 | DEFAULT_TIMESTAMP,
28 | EMPTY_STRING,
29 | JIRA_DEFAULT_ID,
30 | JIRA_DEFAULT_ISSUE_TYPE,
31 | JIRA_DEFAULT_KEY,
32 | JIRA_DEFAULT_PRIORITY,
33 | JIRA_DEFAULT_PROJECT,
34 | JIRA_DEFAULT_STATUS,
35 | NONE_VALUE,
36 | UNASSIGNED,
37 | UNKNOWN,
38 | )
39 |
40 | # Jira models (Keep existing imports)
41 | from .jira import (
42 | JiraAttachment,
43 | JiraBoard,
44 | JiraComment,
45 | JiraIssue,
46 | JiraIssueType,
47 | JiraPriority,
48 | JiraProject,
49 | JiraResolution,
50 | JiraSearchResult,
51 | JiraSprint,
52 | JiraStatus,
53 | JiraStatusCategory,
54 | JiraTimetracking,
55 | JiraTransition,
56 | JiraUser,
57 | JiraWorklog,
58 | )
59 |
60 | # Additional models will be added as they are implemented
61 |
62 | __all__ = [
63 | # Base models
64 | "ApiModel",
65 | "TimestampMixin",
66 | # Constants
67 | "CONFLUENCE_DEFAULT_ID",
68 | "CONFLUENCE_DEFAULT_SPACE",
69 | "CONFLUENCE_DEFAULT_VERSION",
70 | "DEFAULT_TIMESTAMP",
71 | "EMPTY_STRING",
72 | "JIRA_DEFAULT_ID",
73 | "JIRA_DEFAULT_ISSUE_TYPE",
74 | "JIRA_DEFAULT_KEY",
75 | "JIRA_DEFAULT_PRIORITY",
76 | "JIRA_DEFAULT_PROJECT",
77 | "JIRA_DEFAULT_STATUS",
78 | "NONE_VALUE",
79 | "UNASSIGNED",
80 | "UNKNOWN",
81 | # Jira models
82 | "JiraUser",
83 | "JiraStatus",
84 | "JiraStatusCategory",
85 | "JiraIssueType",
86 | "JiraPriority",
87 | "JiraComment",
88 | "JiraIssue",
89 | "JiraProject",
90 | "JiraResolution",
91 | "JiraTransition",
92 | "JiraWorklog",
93 | "JiraSearchResult",
94 | "JiraAttachment",
95 | "JiraTimetracking",
96 | "JiraBoard",
97 | "JiraSprint",
98 | # Confluence models
99 | "ConfluenceUser",
100 | "ConfluenceSpace",
101 | "ConfluencePage",
102 | "ConfluenceComment",
103 | "ConfluenceLabel",
104 | "ConfluenceVersion",
105 | "ConfluenceSearchResult",
106 | "ConfluenceAttachment",
107 | ]
108 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/utils.py:
--------------------------------------------------------------------------------
```python
1 | """Utility functions specific to Confluence operations."""
2 |
3 | import logging
4 |
5 | from .constants import RESERVED_CQL_WORDS
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | def quote_cql_identifier_if_needed(identifier: str) -> str:
11 | """
12 | Quotes a Confluence identifier for safe use in CQL literals if required.
13 |
14 | Handles:
15 | - Personal space keys starting with '~'.
16 | - Identifiers matching reserved CQL words (case-insensitive).
17 | - Identifiers starting with a number.
18 | - Escapes internal quotes ('"') and backslashes ('\\') within the identifier
19 | *before* quoting.
20 |
21 | Args:
22 | identifier: The identifier string (e.g., space key).
23 |
24 | Returns:
25 | The identifier, correctly quoted and escaped if necessary,
26 | otherwise the original identifier.
27 | """
28 | needs_quoting = False
29 | identifier_lower = identifier.lower()
30 |
31 | # Rule 1: Starts with ~ (Personal Space Key)
32 | if identifier.startswith("~"):
33 | needs_quoting = True
34 | logger.debug(f"Identifier '{identifier}' needs quoting (starts with ~).")
35 |
36 | # Rule 2: Is a reserved word (case-insensitive check)
37 | elif identifier_lower in RESERVED_CQL_WORDS:
38 | needs_quoting = True
39 | logger.debug(f"Identifier '{identifier}' needs quoting (reserved word).")
40 |
41 | # Rule 3: Starts with a number
42 | elif identifier and identifier[0].isdigit():
43 | needs_quoting = True
44 | logger.debug(f"Identifier '{identifier}' needs quoting (starts with digit).")
45 |
46 | # Rule 4: Contains internal quotes or backslashes (always needs quoting+escaping)
47 | elif '"' in identifier or "\\" in identifier:
48 | needs_quoting = True
49 | logger.debug(
50 | f"Identifier '{identifier}' needs quoting (contains quotes/backslashes)."
51 | )
52 |
53 | # Add more rules here if other characters prove problematic (e.g., spaces, hyphens)
54 | # elif ' ' in identifier or '-' in identifier:
55 | # needs_quoting = True
56 |
57 | if needs_quoting:
58 | # Escape internal backslashes first, then double quotes
59 | escaped_identifier = identifier.replace("\\", "\\\\").replace('"', '\\"')
60 | quoted_escaped = f'"{escaped_identifier}"'
61 | logger.debug(f"Quoted and escaped identifier: {quoted_escaped}")
62 | return quoted_escaped
63 | else:
64 | # Return the original identifier if no quoting is needed
65 | logger.debug(f"Identifier '{identifier}' does not need quoting.")
66 | return identifier
67 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/confluence/labels.py:
--------------------------------------------------------------------------------
```python
1 | """Module for Confluence label operations."""
2 |
3 | import logging
4 |
5 | from ..models.confluence import ConfluenceLabel
6 | from .client import ConfluenceClient
7 |
8 | logger = logging.getLogger("mcp-atlassian")
9 |
10 |
11 | class LabelsMixin(ConfluenceClient):
12 | """Mixin for Confluence label operations."""
13 |
14 | def get_page_labels(self, page_id: str) -> list[ConfluenceLabel]:
15 | """
16 | Get all labels for a specific page.
17 |
18 | Args:
19 | page_id: The ID of the page to get labels from
20 |
21 | Returns:
22 | List of ConfluenceLabel models containing label content and metadata
23 |
24 | Raises:
25 | Exception: If there is an error getting the label
26 | """
27 | try:
28 | # Get labels with expanded content
29 | labels_response = self.confluence.get_page_labels(page_id=page_id)
30 |
31 | # Process each label
32 | label_models = []
33 | for label_data in labels_response.get("results"):
34 | # Create the model with the processed content
35 | label_model = ConfluenceLabel.from_api_response(
36 | label_data,
37 | base_url=self.config.url,
38 | )
39 |
40 | label_models.append(label_model)
41 |
42 | return label_models
43 |
44 | except Exception as e:
45 | logger.error(f"Failed fetching labels from page {page_id}: {str(e)}")
46 | raise Exception(
47 | f"Failed fetching labels from page {page_id}: {str(e)}"
48 | ) from e
49 |
50 | def add_page_label(self, page_id: str, name: str) -> list[ConfluenceLabel]:
51 | """
52 | Add a label to a Confluence page.
53 |
54 | Args:
55 | page_id: The ID of the page to update
56 | name: The name of the label
57 |
58 | Returns:
59 | Label model containing the updated list of labels
60 |
61 | Raises:
62 | Exception: If there is an error adding the label
63 | """
64 | try:
65 | logger.debug(f"Adding label with name '{name}' to page {page_id}")
66 |
67 | update_kwargs = {
68 | "page_id": page_id,
69 | "label": name,
70 | }
71 | response = self.confluence.set_page_label(**update_kwargs)
72 |
73 | # After update, refresh the page data
74 | return self.get_page_labels(page_id)
75 | except Exception as e:
76 | logger.error(f"Error adding label '{name}' to page {page_id}: {str(e)}")
77 | raise Exception(
78 | f"Failed to add label '{name}' to page {page_id}: {str(e)}"
79 | ) from e
80 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/confluence/comment.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Confluence comment models.
3 | This module provides Pydantic models for Confluence page comments.
4 | """
5 |
6 | import logging
7 | from typing import Any
8 |
9 | from ..base import ApiModel, TimestampMixin
10 | from ..constants import (
11 | CONFLUENCE_DEFAULT_ID,
12 | EMPTY_STRING,
13 | )
14 |
15 | # Import other necessary models using relative imports
16 | from .common import ConfluenceUser
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class ConfluenceComment(ApiModel, TimestampMixin):
22 | """
23 | Model representing a Confluence comment.
24 | """
25 |
26 | id: str = CONFLUENCE_DEFAULT_ID
27 | title: str | None = None
28 | body: str = EMPTY_STRING
29 | created: str = EMPTY_STRING
30 | updated: str = EMPTY_STRING
31 | author: ConfluenceUser | None = None
32 | type: str = "comment" # "comment", "page", etc.
33 |
34 | @classmethod
35 | def from_api_response(
36 | cls, data: dict[str, Any], **kwargs: Any
37 | ) -> "ConfluenceComment":
38 | """
39 | Create a ConfluenceComment from a Confluence API response.
40 |
41 | Args:
42 | data: The comment data from the Confluence API
43 |
44 | Returns:
45 | A ConfluenceComment instance
46 | """
47 | if not data:
48 | return cls()
49 |
50 | author = None
51 | if author_data := data.get("author"):
52 | author = ConfluenceUser.from_api_response(author_data)
53 | # Try to get author from version.by if direct author is not available
54 | elif version_data := data.get("version"):
55 | if by_data := version_data.get("by"):
56 | author = ConfluenceUser.from_api_response(by_data)
57 |
58 | # For title, try to extract from different locations
59 | title = data.get("title")
60 | container = data.get("container")
61 | if not title and container:
62 | title = container.get("title")
63 |
64 | return cls(
65 | id=str(data.get("id", CONFLUENCE_DEFAULT_ID)),
66 | title=title,
67 | body=data.get("body", {}).get("view", {}).get("value", EMPTY_STRING),
68 | created=data.get("created", EMPTY_STRING),
69 | updated=data.get("updated", EMPTY_STRING),
70 | author=author,
71 | type=data.get("type", "comment"),
72 | )
73 |
74 | def to_simplified_dict(self) -> dict[str, Any]:
75 | """Convert to simplified dictionary for API response."""
76 | result = {
77 | "id": self.id,
78 | "body": self.body,
79 | "created": self.format_timestamp(self.created),
80 | "updated": self.format_timestamp(self.updated),
81 | }
82 |
83 | if self.title:
84 | result["title"] = self.title
85 |
86 | if self.author:
87 | result["author"] = self.author.display_name
88 |
89 | return result
90 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/comment.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Jira comment models.
3 |
4 | This module provides Pydantic models for Jira comments.
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 JiraComment(ApiModel, TimestampMixin):
21 | """
22 | Model representing a Jira issue comment.
23 | """
24 |
25 | id: str = JIRA_DEFAULT_ID
26 | body: str = EMPTY_STRING
27 | created: str = EMPTY_STRING
28 | updated: str = EMPTY_STRING
29 | author: JiraUser | None = None
30 |
31 | @classmethod
32 | def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraComment":
33 | """
34 | Create a JiraComment from a Jira API response.
35 |
36 | Args:
37 | data: The comment data from the Jira API
38 |
39 | Returns:
40 | A JiraComment instance
41 | """
42 | if not data:
43 | return cls()
44 |
45 | # Handle non-dictionary data by returning a default instance
46 | if not isinstance(data, dict):
47 | logger.debug("Received non-dictionary data, returning default instance")
48 | return cls()
49 |
50 | # Extract author data
51 | author = None
52 | author_data = data.get("author")
53 | if author_data:
54 | author = JiraUser.from_api_response(author_data)
55 |
56 | # Ensure ID is a string
57 | comment_id = data.get("id", JIRA_DEFAULT_ID)
58 | if comment_id is not None:
59 | comment_id = str(comment_id)
60 |
61 | # Get the body content
62 | body_content = EMPTY_STRING
63 | body = data.get("body")
64 | if isinstance(body, dict) and "content" in body:
65 | # Handle Atlassian Document Format (ADF)
66 | # This is a simplified conversion - a proper implementation would
67 | # parse the ADF structure
68 | body_content = str(body.get("content", EMPTY_STRING))
69 | elif body:
70 | # Handle plain text or HTML content
71 | body_content = str(body)
72 |
73 | return cls(
74 | id=comment_id,
75 | body=body_content,
76 | created=str(data.get("created", EMPTY_STRING)),
77 | updated=str(data.get("updated", EMPTY_STRING)),
78 | author=author,
79 | )
80 |
81 | def to_simplified_dict(self) -> dict[str, Any]:
82 | """Convert to simplified dictionary for API response."""
83 | result = {
84 | "body": self.body,
85 | }
86 |
87 | if self.author:
88 | result["author"] = self.author.to_simplified_dict()
89 |
90 | if self.created:
91 | result["created"] = self.created
92 |
93 | if self.updated:
94 | result["updated"] = self.updated
95 |
96 | return result
97 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/lifecycle.py:
--------------------------------------------------------------------------------
```python
1 | """Lifecycle management utilities for graceful shutdown and signal handling."""
2 |
3 | import logging
4 | import signal
5 | import sys
6 | import threading
7 | from typing import Any
8 |
9 | logger = logging.getLogger("mcp-atlassian.utils.lifecycle")
10 |
11 | # Global shutdown event for signal-safe handling
12 | _shutdown_event = threading.Event()
13 |
14 |
15 | def setup_signal_handlers() -> None:
16 | """Set up signal handlers for graceful shutdown.
17 |
18 | Registers handlers for SIGTERM, SIGINT, and SIGPIPE (if available) to ensure
19 | the application shuts down cleanly when receiving termination signals.
20 |
21 | This is particularly important for Docker containers running with the -i flag,
22 | which need to properly handle shutdown signals from parent processes.
23 | """
24 |
25 | def signal_handler(signum: int, frame: Any) -> None:
26 | """Handle shutdown signals gracefully.
27 |
28 | Uses event-based shutdown to avoid signal safety issues.
29 | Signal handlers should be minimal and avoid complex operations.
30 | """
31 | # Only safe operations in signal handlers - set the shutdown event
32 | _shutdown_event.set()
33 |
34 | # Register signal handlers
35 | signal.signal(signal.SIGTERM, signal_handler)
36 | signal.signal(signal.SIGINT, signal_handler)
37 |
38 | # Handle SIGPIPE which occurs when parent process closes the pipe
39 | try:
40 | signal.signal(signal.SIGPIPE, signal_handler)
41 | logger.debug("SIGPIPE handler registered")
42 | except AttributeError:
43 | # SIGPIPE may not be available on all platforms (e.g., Windows)
44 | logger.debug("SIGPIPE not available on this platform")
45 |
46 |
47 | def ensure_clean_exit() -> None:
48 | """Ensure all output streams are flushed before exit.
49 |
50 | This is important for containerized environments where output might be
51 | buffered and could be lost if not properly flushed before exit.
52 |
53 | Handles cases where streams may already be closed by the parent process,
54 | particularly on Windows or when run as a child process.
55 | """
56 | logger.info("Server stopped, flushing output streams...")
57 |
58 | # Safely flush stdout
59 | try:
60 | if hasattr(sys.stdout, "closed") and not sys.stdout.closed:
61 | sys.stdout.flush()
62 | except (ValueError, OSError, AttributeError) as e:
63 | # Stream might be closed or redirected
64 | logger.debug(f"Could not flush stdout: {e}")
65 |
66 | # Safely flush stderr
67 | try:
68 | if hasattr(sys.stderr, "closed") and not sys.stderr.closed:
69 | sys.stderr.flush()
70 | except (ValueError, OSError, AttributeError) as e:
71 | # Stream might be closed or redirected
72 | logger.debug(f"Could not flush stderr: {e}")
73 |
74 | logger.debug("Output streams flushed, exiting gracefully")
75 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/models/jira/workflow.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Jira workflow models.
3 |
4 | This module provides Pydantic models for Jira workflow entities,
5 | such as transitions between statuses.
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 | )
16 | from .common import JiraStatus
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class JiraTransition(ApiModel):
22 | """
23 | Model representing a Jira issue transition.
24 |
25 | This model contains information about possible status transitions
26 | for Jira issues, including the target status and related metadata.
27 | """
28 |
29 | id: str = JIRA_DEFAULT_ID
30 | name: str = EMPTY_STRING
31 | to_status: JiraStatus | None = None
32 | has_screen: bool = False
33 | is_global: bool = False
34 | is_initial: bool = False
35 | is_conditional: bool = False
36 |
37 | @classmethod
38 | def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraTransition":
39 | """
40 | Create a JiraTransition from a Jira API response.
41 |
42 | Args:
43 | data: The transition data from the Jira API
44 |
45 | Returns:
46 | A JiraTransition 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 to_status data if available
57 | to_status = None
58 | if to := data.get("to"):
59 | if isinstance(to, dict):
60 | to_status = JiraStatus.from_api_response(to)
61 |
62 | # Ensure ID is a string
63 | transition_id = data.get("id", JIRA_DEFAULT_ID)
64 | if transition_id is not None:
65 | transition_id = str(transition_id)
66 |
67 | # Extract boolean flags with type safety
68 | has_screen = bool(data.get("hasScreen", False))
69 | is_global = bool(data.get("isGlobal", False))
70 | is_initial = bool(data.get("isInitial", False))
71 | is_conditional = bool(data.get("isConditional", False))
72 |
73 | return cls(
74 | id=transition_id,
75 | name=str(data.get("name", EMPTY_STRING)),
76 | to_status=to_status,
77 | has_screen=has_screen,
78 | is_global=is_global,
79 | is_initial=is_initial,
80 | is_conditional=is_conditional,
81 | )
82 |
83 | def to_simplified_dict(self) -> dict[str, Any]:
84 | """Convert to simplified dictionary for API response."""
85 | result = {
86 | "id": self.id,
87 | "name": self.name,
88 | }
89 |
90 | if self.to_status:
91 | result["to_status"] = self.to_status.to_simplified_dict()
92 |
93 | return result
94 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_tools.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for tool utility functions."""
2 |
3 | import os
4 | from unittest.mock import patch
5 |
6 | from mcp_atlassian.utils.tools import get_enabled_tools, should_include_tool
7 |
8 |
9 | def test_get_enabled_tools_not_set():
10 | """Test get_enabled_tools when ENABLED_TOOLS is not set."""
11 | with patch.dict(os.environ, {}, clear=True):
12 | assert get_enabled_tools() is None
13 |
14 |
15 | def test_get_enabled_tools_empty_string():
16 | """Test get_enabled_tools with empty string."""
17 | with patch.dict(os.environ, {"ENABLED_TOOLS": ""}, clear=True):
18 | assert get_enabled_tools() is None
19 |
20 |
21 | def test_get_enabled_tools_only_whitespace():
22 | """Test get_enabled_tools with string containing only whitespace."""
23 | with patch.dict(os.environ, {"ENABLED_TOOLS": " "}, clear=True):
24 | assert get_enabled_tools() is None
25 |
26 |
27 | def test_get_enabled_tools_only_commas():
28 | """Test get_enabled_tools with string containing only commas."""
29 | with patch.dict(os.environ, {"ENABLED_TOOLS": ",,,,"}, clear=True):
30 | assert get_enabled_tools() is None
31 |
32 |
33 | def test_get_enabled_tools_whitespace_and_commas():
34 | """Test get_enabled_tools with string containing whitespace and commas."""
35 | with patch.dict(os.environ, {"ENABLED_TOOLS": " , , , "}, clear=True):
36 | assert get_enabled_tools() is None
37 |
38 |
39 | def test_get_enabled_tools_single_tool():
40 | """Test get_enabled_tools with a single tool."""
41 | with patch.dict(os.environ, {"ENABLED_TOOLS": "tool1"}, clear=True):
42 | assert get_enabled_tools() == ["tool1"]
43 |
44 |
45 | def test_get_enabled_tools_multiple_tools():
46 | """Test get_enabled_tools with multiple tools."""
47 | with patch.dict(os.environ, {"ENABLED_TOOLS": "tool1,tool2,tool3"}, clear=True):
48 | assert get_enabled_tools() == ["tool1", "tool2", "tool3"]
49 |
50 |
51 | def test_get_enabled_tools_with_whitespace():
52 | """Test get_enabled_tools with whitespace around tool names."""
53 | with patch.dict(
54 | os.environ, {"ENABLED_TOOLS": " tool1 , tool2 , tool3 "}, clear=True
55 | ):
56 | assert get_enabled_tools() == ["tool1", "tool2", "tool3"]
57 |
58 |
59 | def test_should_include_tool_none_enabled():
60 | """Test should_include_tool when enabled_tools is None."""
61 | assert should_include_tool("any_tool", None) is True
62 |
63 |
64 | def test_should_include_tool_tool_enabled():
65 | """Test should_include_tool when tool is in enabled list."""
66 | enabled_tools = ["tool1", "tool2", "tool3"]
67 | assert should_include_tool("tool2", enabled_tools) is True
68 |
69 |
70 | def test_should_include_tool_tool_not_enabled():
71 | """Test should_include_tool when tool is not in enabled list."""
72 | enabled_tools = ["tool1", "tool2", "tool3"]
73 | assert should_include_tool("tool4", enabled_tools) is False
74 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/jira/boards.py:
--------------------------------------------------------------------------------
```python
1 | """Module for Jira boards operations."""
2 |
3 | import logging
4 | from typing import Any
5 |
6 | import requests
7 |
8 | from ..models.jira import JiraBoard
9 | from .client import JiraClient
10 |
11 | logger = logging.getLogger("mcp-jira")
12 |
13 |
14 | class BoardsMixin(JiraClient):
15 | """Mixin for Jira boards operations."""
16 |
17 | def get_all_agile_boards(
18 | self,
19 | board_name: str | None = None,
20 | project_key: str | None = None,
21 | board_type: str | None = None,
22 | start: int = 0,
23 | limit: int = 50,
24 | ) -> list[dict[str, Any]]:
25 | """
26 | Get boards from Jira by name, project key, or type.
27 |
28 | Args:
29 | board_name: The name of board, support fuzzy search
30 | project_key: Project key (e.g., PROJECT-123)
31 | board_type: Board type (e.g., scrum, kanban)
32 | start: Start index
33 | limit: Maximum number of boards to return
34 |
35 | Returns:
36 | List of board information
37 |
38 | Raises:
39 | Exception: If there is an error retrieving the boards
40 | """
41 | try:
42 | boards = self.jira.get_all_agile_boards(
43 | board_name=board_name,
44 | project_key=project_key,
45 | board_type=board_type,
46 | start=start,
47 | limit=limit,
48 | )
49 | return boards.get("values", []) if isinstance(boards, dict) else []
50 | except requests.HTTPError as e:
51 | logger.error(f"Error getting all agile boards: {str(e.response.content)}")
52 | return []
53 | except Exception as e:
54 | logger.error(f"Error getting all agile boards: {str(e)}")
55 | return []
56 |
57 | def get_all_agile_boards_model(
58 | self,
59 | board_name: str | None = None,
60 | project_key: str | None = None,
61 | board_type: str | None = None,
62 | start: int = 0,
63 | limit: int = 50,
64 | ) -> list[JiraBoard]:
65 | """
66 | Get boards as JiraBoards model from Jira by name, project key, or type.
67 |
68 | Args:
69 | board_name: The name of board, support fuzzy search
70 | project_key: Project key (e.g., PROJECT-123)
71 | board_type: Board type (e.g., scrum, kanban)
72 | start: Start index
73 | limit: Maximum number of boards to return
74 |
75 | Returns:
76 | List of JiraBoards model with board information
77 |
78 | Raises:
79 | Exception: If there is an error retrieving the boards
80 | """
81 | boards = self.get_all_agile_boards(
82 | board_name=board_name,
83 | project_key=project_key,
84 | board_type=board_type,
85 | start=start,
86 | limit=limit,
87 | )
88 | return [JiraBoard.from_api_response(board) for board in boards]
89 |
```
--------------------------------------------------------------------------------
/tests/unit/confluence/test_constants.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for Confluence constants.
2 |
3 | Focused tests for Confluence constants, validating correct values and business logic.
4 | """
5 |
6 | from mcp_atlassian.confluence.constants import RESERVED_CQL_WORDS
7 |
8 |
9 | class TestReservedCqlWords:
10 | """Test suite for RESERVED_CQL_WORDS constant."""
11 |
12 | def test_type_and_structure(self):
13 | """Test that RESERVED_CQL_WORDS is a set of strings."""
14 | assert isinstance(RESERVED_CQL_WORDS, set)
15 | assert all(isinstance(word, str) for word in RESERVED_CQL_WORDS)
16 | assert len(RESERVED_CQL_WORDS) == 41
17 |
18 | def test_contains_expected_cql_words(self):
19 | """Test that RESERVED_CQL_WORDS contains the correct CQL reserved words."""
20 | expected_words = {
21 | "after",
22 | "and",
23 | "as",
24 | "avg",
25 | "before",
26 | "begin",
27 | "by",
28 | "commit",
29 | "contains",
30 | "count",
31 | "distinct",
32 | "else",
33 | "empty",
34 | "end",
35 | "explain",
36 | "from",
37 | "having",
38 | "if",
39 | "in",
40 | "inner",
41 | "insert",
42 | "into",
43 | "is",
44 | "isnull",
45 | "left",
46 | "like",
47 | "limit",
48 | "max",
49 | "min",
50 | "not",
51 | "null",
52 | "or",
53 | "order",
54 | "outer",
55 | "right",
56 | "select",
57 | "sum",
58 | "then",
59 | "was",
60 | "where",
61 | "update",
62 | }
63 | assert RESERVED_CQL_WORDS == expected_words
64 |
65 | def test_sql_keywords_coverage(self):
66 | """Test that common SQL keywords are included."""
67 | sql_keywords = {
68 | "select",
69 | "from",
70 | "where",
71 | "and",
72 | "or",
73 | "not",
74 | "in",
75 | "like",
76 | "is",
77 | "null",
78 | "order",
79 | "by",
80 | "having",
81 | "count",
82 | }
83 | assert sql_keywords.issubset(RESERVED_CQL_WORDS)
84 |
85 | def test_cql_specific_keywords(self):
86 | """Test that CQL-specific keywords are included."""
87 | cql_specific = {"contains", "after", "before", "was", "empty"}
88 | assert cql_specific.issubset(RESERVED_CQL_WORDS)
89 |
90 | def test_word_format_validity(self):
91 | """Test that reserved words are valid for CQL usage."""
92 | for word in RESERVED_CQL_WORDS:
93 | # Words should be non-empty, lowercase, alphabetic only
94 | assert word and word.islower() and word.isalpha()
95 | assert len(word) >= 2 # Shortest valid words like "as", "by"
96 | assert " " not in word and "\t" not in word
97 |
```
--------------------------------------------------------------------------------
/tests/unit/confluence/test_utils.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the Confluence utility functions."""
2 |
3 | from mcp_atlassian.confluence.constants import RESERVED_CQL_WORDS
4 | from mcp_atlassian.confluence.utils import quote_cql_identifier_if_needed
5 |
6 |
7 | class TestCQLQuoting:
8 | """Tests for CQL quoting utility functions."""
9 |
10 | def test_quote_personal_space_key(self):
11 | """Test quoting of personal space keys."""
12 | # Personal space keys starting with ~ should be quoted
13 | assert quote_cql_identifier_if_needed("~username") == '"~username"'
14 | assert quote_cql_identifier_if_needed("~admin") == '"~admin"'
15 | assert quote_cql_identifier_if_needed("~user.name") == '"~user.name"'
16 |
17 | def test_quote_reserved_words(self):
18 | """Test quoting of reserved CQL words."""
19 | # Reserved words should be quoted (case-insensitive)
20 | for word in list(RESERVED_CQL_WORDS)[:5]: # Test a subset for brevity
21 | assert quote_cql_identifier_if_needed(word) == f'"{word}"'
22 | assert quote_cql_identifier_if_needed(word.upper()) == f'"{word.upper()}"'
23 | assert (
24 | quote_cql_identifier_if_needed(word.capitalize())
25 | == f'"{word.capitalize()}"'
26 | )
27 |
28 | def test_quote_numeric_keys(self):
29 | """Test quoting of keys starting with numbers."""
30 | # Keys starting with numbers should be quoted
31 | assert quote_cql_identifier_if_needed("123space") == '"123space"'
32 | assert quote_cql_identifier_if_needed("42") == '"42"'
33 | assert quote_cql_identifier_if_needed("1test") == '"1test"'
34 |
35 | def test_quote_special_characters(self):
36 | """Test quoting and escaping of identifiers with special characters."""
37 | # Keys with quotes or backslashes should be quoted and escaped
38 | assert quote_cql_identifier_if_needed('my"space') == '"my\\"space"'
39 | assert quote_cql_identifier_if_needed("test\\space") == '"test\\\\space"'
40 |
41 | # Test combined quotes and backslashes
42 | input_str = 'quote"and\\slash'
43 | result = quote_cql_identifier_if_needed(input_str)
44 | assert result == '"quote\\"and\\\\slash"'
45 |
46 | # Verify the result by checking individual characters
47 | assert result[0] == '"' # opening quote
48 | assert result[-1] == '"' # closing quote
49 | assert "\\\\" in result # escaped backslash
50 | assert '\\"' in result # escaped quote
51 |
52 | def test_no_quote_regular_keys(self):
53 | """Test that regular keys are not quoted."""
54 | # Regular space keys should not be quoted
55 | assert quote_cql_identifier_if_needed("DEV") == "DEV"
56 | assert quote_cql_identifier_if_needed("MYSPACE") == "MYSPACE"
57 | assert quote_cql_identifier_if_needed("documentation") == "documentation"
58 |
```
--------------------------------------------------------------------------------
/tests/unit/utils/test_masking.py:
--------------------------------------------------------------------------------
```python
1 | """Test the masking utility functions."""
2 |
3 | import logging
4 | from unittest.mock import patch
5 |
6 | from mcp_atlassian.utils.logging import log_config_param, mask_sensitive
7 |
8 |
9 | class TestMaskSensitive:
10 | """Test the _mask_sensitive function."""
11 |
12 | def test_none_value(self):
13 | """Test masking None value."""
14 | assert mask_sensitive(None) == "Not Provided"
15 |
16 | def test_short_value(self):
17 | """Test masking short value."""
18 | assert mask_sensitive("abc") == "***"
19 | assert mask_sensitive("abcdef") == "******"
20 | assert mask_sensitive("abcdefgh", keep_chars=4) == "********"
21 |
22 | def test_normal_value(self):
23 | """Test masking normal value."""
24 | assert mask_sensitive("abcdefghijkl", keep_chars=2) == "ab********kl"
25 | assert mask_sensitive("abcdefghijkl") == "abcd****ijkl"
26 | assert (
27 | mask_sensitive("abcdefghijklmnopqrstuvwxyz", keep_chars=5)
28 | == "abcde****************vwxyz"
29 | )
30 |
31 |
32 | class TestLogConfigParam:
33 | """Test the _log_config_param function."""
34 |
35 | @patch("mcp_atlassian.utils.logging.logging.Logger")
36 | def test_normal_param(self, mock_logger):
37 | """Test logging normal parameter."""
38 | log_config_param(mock_logger, "Jira", "URL", "https://jira.example.com")
39 | mock_logger.info.assert_called_once_with("Jira URL: https://jira.example.com")
40 |
41 | @patch("mcp_atlassian.utils.logging.logging.Logger")
42 | def test_none_param(self, mock_logger):
43 | """Test logging None parameter."""
44 | log_config_param(mock_logger, "Jira", "Projects Filter", None)
45 | mock_logger.info.assert_called_once_with("Jira Projects Filter: Not Provided")
46 |
47 | @patch("mcp_atlassian.utils.logging.logging.Logger")
48 | def test_sensitive_param(self, mock_logger):
49 | """Test logging sensitive parameter."""
50 | log_config_param(
51 | mock_logger, "Jira", "API Token", "abcdefghijklmnop", sensitive=True
52 | )
53 | mock_logger.info.assert_called_once_with("Jira API Token: abcd********mnop")
54 |
55 | def test_log_config_param_masks_proxy_url(self, caplog):
56 | """Test that log_config_param masks credentials in proxy URLs when sensitive=True."""
57 | logger = logging.getLogger("test-proxy-logger")
58 | proxy_url = "socks5://user:[email protected]:1080"
59 | with caplog.at_level(logging.INFO, logger="test-proxy-logger"):
60 | log_config_param(logger, "Jira", "SOCKS_PROXY", proxy_url, sensitive=True)
61 | # Should mask the middle part of the URL, not show user:pass
62 | assert any(
63 | rec.message.startswith("Jira SOCKS_PROXY: sock")
64 | and rec.message.endswith("1080")
65 | and "user:pass" not in rec.message
66 | for rec in caplog.records
67 | )
68 |
```
--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Docker Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*' # Trigger on version tag pushes (e.g., v0.7.1)
7 | pull_request:
8 | branches:
9 | - main # Trigger on PRs targeting main branch
10 | workflow_dispatch: {} # Allow manual triggering from the Actions tab
11 |
12 | env:
13 | REGISTRY: ghcr.io
14 | IMAGE_NAME: ${{ github.repository }}
15 |
16 | jobs:
17 | build-and-push:
18 | runs-on: ubuntu-latest
19 | permissions:
20 | contents: read # Needed for checkout
21 | packages: write # Needed to push packages to ghcr.io
22 |
23 | steps:
24 | - name: Checkout repository
25 | uses: actions/checkout@v4
26 |
27 | - name: Set up QEMU
28 | uses: docker/setup-qemu-action@v3
29 | with:
30 | platforms: arm64,amd64
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v3
34 |
35 | # Extract metadata (tags, labels) for Docker
36 | - name: Extract Docker metadata
37 | id: meta
38 | uses: docker/metadata-action@v5
39 | with:
40 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
41 | tags: |
42 | # Create 'main' tag on push to main branch
43 | type=ref,event=branch,branch=main
44 | # Create 'X.Y.Z', 'X.Y', 'X' tags on tag push (e.g., v1.2.3 -> 1.2.3, 1.2, 1)
45 | type=semver,pattern={{version}}
46 | type=semver,pattern={{major}}.{{minor}}
47 | type=semver,pattern={{major}}
48 | # Only create 'latest' tag if it's a tag push AND it's a non-prerelease version tag (no '-')
49 | type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
50 | # Create a tag for PRs (e.g., pr-123) - useful for testing PR builds
51 | type=ref,event=pr
52 | # For manual workflow runs, use branch name with 'manual' suffix
53 | type=raw,value={{branch}}-manual,enable=${{ github.event_name == 'workflow_dispatch' }}
54 |
55 | - name: Log in to GitHub Container Registry
56 | uses: docker/login-action@v3
57 | # Only login for pushes and manual runs, not for PRs
58 | if: github.event_name != 'pull_request'
59 | with:
60 | registry: ${{ env.REGISTRY }}
61 | username: ${{ github.actor }}
62 | password: ${{ secrets.GITHUB_TOKEN }}
63 |
64 | - name: Build and push Docker image
65 | id: docker_build
66 | uses: docker/build-push-action@v5
67 | with:
68 | context: .
69 | platforms: linux/amd64,linux/arm64
70 | # Only push for non-PR events
71 | push: ${{ github.event_name != 'pull_request' }}
72 | # Use tags and labels from metadata action
73 | tags: ${{ steps.meta.outputs.tags }}
74 | labels: ${{ steps.meta.outputs.labels }}
75 | # Enable Docker layer caching
76 | cache-from: type=gha
77 | cache-to: type=gha,mode=max
78 |
```
--------------------------------------------------------------------------------
/src/mcp_atlassian/utils/env.py:
--------------------------------------------------------------------------------
```python
1 | """Environment variable utility functions for MCP Atlassian."""
2 |
3 | import os
4 |
5 |
6 | def is_env_truthy(env_var_name: str, default: str = "") -> bool:
7 | """Check if environment variable is set to a standard truthy value.
8 |
9 | Considers 'true', '1', 'yes' as truthy values (case-insensitive).
10 | Used for most MCP environment variables.
11 |
12 | Args:
13 | env_var_name: Name of the environment variable to check
14 | default: Default value if environment variable is not set
15 |
16 | Returns:
17 | True if the environment variable is set to a truthy value, False otherwise
18 | """
19 | return os.getenv(env_var_name, default).lower() in ("true", "1", "yes")
20 |
21 |
22 | def is_env_extended_truthy(env_var_name: str, default: str = "") -> bool:
23 | """Check if environment variable is set to an extended truthy value.
24 |
25 | Considers 'true', '1', 'yes', 'y', 'on' as truthy values (case-insensitive).
26 | Used for READ_ONLY_MODE and similar flags.
27 |
28 | Args:
29 | env_var_name: Name of the environment variable to check
30 | default: Default value if environment variable is not set
31 |
32 | Returns:
33 | True if the environment variable is set to a truthy value, False otherwise
34 | """
35 | return os.getenv(env_var_name, default).lower() in ("true", "1", "yes", "y", "on")
36 |
37 |
38 | def is_env_ssl_verify(env_var_name: str, default: str = "true") -> bool:
39 | """Check SSL verification setting with secure defaults.
40 |
41 | Defaults to true unless explicitly set to false values.
42 | Used for SSL_VERIFY environment variables.
43 |
44 | Args:
45 | env_var_name: Name of the environment variable to check
46 | default: Default value if environment variable is not set
47 |
48 | Returns:
49 | True unless explicitly set to false values
50 | """
51 | return os.getenv(env_var_name, default).lower() not in ("false", "0", "no")
52 |
53 |
54 | def get_custom_headers(env_var_name: str) -> dict[str, str]:
55 | """Parse custom headers from environment variable containing comma-separated key=value pairs.
56 |
57 | Args:
58 | env_var_name: Name of the environment variable to read
59 |
60 | Returns:
61 | Dictionary of parsed headers
62 |
63 | Examples:
64 | >>> # With CUSTOM_HEADERS="X-Custom=value1,X-Other=value2"
65 | >>> parse_custom_headers("CUSTOM_HEADERS")
66 | {'X-Custom': 'value1', 'X-Other': 'value2'}
67 | >>> # With unset environment variable
68 | >>> parse_custom_headers("UNSET_VAR")
69 | {}
70 | """
71 | header_string = os.getenv(env_var_name)
72 | if not header_string or not header_string.strip():
73 | return {}
74 |
75 | headers = {}
76 | pairs = header_string.split(",")
77 |
78 | for pair in pairs:
79 | pair = pair.strip()
80 | if not pair:
81 | continue
82 |
83 | if "=" not in pair:
84 | continue
85 |
86 | key, value = pair.split("=", 1) # Split on first = only
87 | key = key.strip()
88 | value = value.strip()
89 |
90 | if key: # Only add if key is not empty
91 | headers[key] = value
92 |
93 | return headers
94 |
```