
Atlassian
- 2 installs
- Updated March 4, 2026
- sanjay3290/atlassian-skill
Manages Jira issues and Confluence pages in Atlassian Cloud via JQL/CQL, with OAuth 2.1 MCP or API-token authentication.
About
Integrates Jira and Confluence so an agent can search, create, and update issues and wiki pages, handle transitions, and browse spaces. A developer uses it to automate Atlassian Cloud workflows from Claude Code.
- Searches and manages Jira issues with JQL and Confluence pages with CQL
- Supports OAuth 2.1 via MCP server or API-token REST fallback
Atlassian by the numbers
- 2 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/atlassian-skill --skill atlassianAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | March 4, 2026 |
| Repository | sanjay3290/atlassian-skill ↗ |
What it does
Manages Jira issues and Confluence pages in Atlassian Cloud via JQL/CQL, with OAuth 2.1 MCP or API-token authentication.
Files
Atlassian (Jira + Confluence)
Full Jira and Confluence integration with two authentication methods:
- OAuth 2.1 via Atlassian MCP server — browser-based consent, auto-refresh tokens, calls MCP tools
- API token — email + token stored in keyring, calls REST API directly
First-Time Setup
Option 1: OAuth 2.1 via MCP Server (Recommended)
No API tokens or instance URLs needed. Uses dynamic client registration and PKCE.
pip install -r requirements.txt
python scripts/auth.py login --oauthA browser opens for Atlassian authorization. Select which products (Jira, Confluence, Compass) to grant access. Tokens are stored in the system keyring and auto-refresh when expired.
Check status:
python scripts/auth.py statusOption 2: API Token (Fallback)
For environments where browser-based OAuth isn't available.
pip install -r requirements.txt
python scripts/auth.py loginFollow the prompts to enter your Atlassian URL, email, and API token. Credentials are stored securely in the system keyring.
Create an API token at: https://id.atlassian.com/manage-profile/security/api-tokens
Check authentication status:
python scripts/auth.py statusLogout (clears both OAuth and API token credentials):
python scripts/auth.py logoutBackend Selection
The scripts automatically detect which backend to use based on your auth type:
- OAuth → MCP backend (calls Atlassian MCP server tools)
- API token → REST backend (calls Atlassian REST API directly)
All commands work identically regardless of backend.
Jira (scripts/jira.py)
Search issues with JQL
python scripts/jira.py search "project = DEV AND status = Open"
python scripts/jira.py search "assignee = currentUser() ORDER BY updated DESC" --limit 10Get issue details
python scripts/jira.py get DEV-123Create an issue
python scripts/jira.py create --project DEV --summary "Fix login bug" --type Bug
python scripts/jira.py create --project DEV --summary "New feature" --type Story \
--description "Details here" --priority High --assignee "user@example.com" --labels "backend,urgent"Update an issue
python scripts/jira.py update DEV-123 --summary "Updated summary" --priority High
python scripts/jira.py update DEV-123 --assignee "user@example.com"Transition issue status
python scripts/jira.py transition DEV-123 "In Progress"
python scripts/jira.py transition DEV-123 "Done"Add and list comments
python scripts/jira.py comment DEV-123 --add "This is a comment"
python scripts/jira.py comment DEV-123 --listList projects and statuses
python scripts/jira.py list-projects
python scripts/jira.py list-statuses DEVTest authentication
python scripts/jira.py auth-infoList available MCP tools (OAuth only)
python scripts/jira.py list-toolsConfluence (scripts/confluence.py)
Search pages
python scripts/confluence.py search "deployment guide"
python scripts/confluence.py search "type=page AND space=DEV AND text~\"deployment\""
python scripts/confluence.py search "onboarding" --limit 10Read a page
python scripts/confluence.py read <page-id>
python scripts/confluence.py read <page-id> --jsonList spaces
python scripts/confluence.py list-spaces
python scripts/confluence.py list-spaces --limit 50Get space details
python scripts/confluence.py get-space <space-id>List pages in a space
python scripts/confluence.py list-pages --space-id <space-id>Create a page
python scripts/confluence.py create --title "New Page" --space-id <space-id>
python scripts/confluence.py create --title "Guide" --space-id <id> --body "<p>Content here</p>"
python scripts/confluence.py create --title "Child" --space-id <id> --parent-id <parent-id>Update a page
python scripts/confluence.py update <page-id> --title "Updated Title"
python scripts/confluence.py update <page-id> --body "<p>New content</p>"Get child pages
python scripts/confluence.py get-children <page-id>Test authentication
python scripts/confluence.py auth-infoList available MCP tools (OAuth only)
python scripts/confluence.py list-toolsOperations Reference
Jira
| Command | Description | Required Args |
|---|---|---|
| search | Search issues with JQL | jql |
| get | Get issue details | issue_key |
| create | Create new issue | --project, --summary, --type |
| update | Update existing issue | issue_key |
| transition | Change issue status | issue_key, status |
| comment | Add or list comments | issue_key |
| list-projects | List accessible projects | - |
| list-statuses | List statuses for project | project_key |
| auth-info | Test API connection | - |
| list-tools | List MCP tools (OAuth only) | - |
Confluence
| Command | Description | Required Args |
|---|---|---|
| search | Search using CQL | query |
| read | Get page content | page_id |
| list-spaces | List all spaces | - |
| get-space | Get space details | space_id |
| list-pages | List pages in a space | --space-id |
| create | Create new page | --title, --space-id |
| update | Update existing page | page_id |
| get-children | Get child pages | page_id |
| auth-info | Test API connection | - |
| list-tools | List MCP tools (OAuth only) | - |
JSON Output
Add --json flag to any script command for machine-readable output.
Token Management
Credentials stored securely using the system keyring:
- macOS: Keychain
- Windows: Windows Credential Locker
- Linux: Secret Service API
Service name: atlassian-skill
OAuth tokens auto-refresh when expired (if refresh token is available).
{
"name": "atlassian-skill",
"owner": { "name": "Sanjay" },
"metadata": {
"description": "Atlassian (Jira + Confluence) skill for AI coding assistants",
"version": "1.0.0",
"license": "Apache-2.0"
},
"plugins": [
{
"name": "atlassian",
"description": "Manage Jira issues and Confluence wiki pages in Atlassian Cloud with OAuth 2.1 or API token authentication.",
"source": "./",
"skills": ["./"]
}
]
}
# Atlassian API Configuration (fallback - keyring preferred)
# ============================================================
# These are only needed if you prefer .env over keyring storage.
# For keyring-based auth, use: python scripts/auth.py login
#
# Get your API token from:
# https://id.atlassian.com/manage-profile/security/api-tokens
# Required: Your Atlassian instance URL (without /wiki suffix)
ATLASSIAN_URL=https://your-domain.atlassian.net
# Required: Your Atlassian account email
ATLASSIAN_EMAIL=your-email@example.com
# Required: Your Atlassian API token
ATLASSIAN_API_TOKEN=your-api-token-here
# Optional: Request timeout in seconds (default: 30)
# ATLASSIAN_TIMEOUT=30
__pycache__/
*.pyc
.env
*.egg-info/
dist/
build/
.venv/
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an acquainted
with the Apache License FAQ at
http://www.apache.org/foundation/license-faq.html
Copyright 2026 Sanjay
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Atlassian Skill
Jira + Confluence integration for AI coding assistants with dual auth support (OAuth 2.1 via MCP server or API token fallback).
Supported AI Clients
<p align="center"> <a href="#claude-code"><img src="https://img.shields.io/badge/Claude_Code-D97757?style=for-the-badge&logo=anthropic&logoColor=white" alt="Claude Code" /></a> <a href="#gemini-cli--cursor--codex--goose"><img src="https://img.shields.io/badge/Gemini_CLI-8E75B2?style=for-the-badge&logo=google&logoColor=white" alt="Gemini CLI" /></a> <a href="#gemini-cli--cursor--codex--goose"><img src="https://img.shields.io/badge/Antigravity-4285F4?style=for-the-badge&logo=google&logoColor=white" alt="Google Antigravity" /></a> <a href="#gemini-cli--cursor--codex--goose"><img src="https://img.shields.io/badge/Cursor-000000?style=for-the-badge&logo=cursor&logoColor=white" alt="Cursor" /></a> <a href="#gemini-cli--cursor--codex--goose"><img src="https://img.shields.io/badge/OpenAI_Codex-412991?style=for-the-badge&logo=openai&logoColor=white" alt="OpenAI Codex" /></a> <a href="#gemini-cli--cursor--codex--goose"><img src="https://img.shields.io/badge/Goose-FF6B35?style=for-the-badge&logo=go&logoColor=white" alt="Goose" /></a> </p>
Features
Jira
- Search issues with JQL queries (full filtering, sorting, pagination)
- Create, update, and transition issues through workflow statuses
- Add and list comments on issues
- List accessible projects and available statuses per project
Confluence
- Search pages with CQL queries or free-text search
- Read, create, and update wiki pages with HTML content
- Browse spaces and page hierarchies (parent/child navigation)
- List all spaces with metadata
Authentication
- OAuth 2.1 via Atlassian MCP server (recommended) -- browser-based consent, PKCE, auto-refresh tokens
- API token fallback -- email + token stored securely in system keyring
- Auto-detects backend: OAuth uses MCP tools, API token uses REST API directly
- Secure credential storage via system keyring (macOS Keychain, Windows Credential Locker, Linux Secret Service)
Quick Install
Claude Code
/plugin marketplace add sanjay3290/atlassian-skillGemini CLI / Cursor / Codex / Goose
npx skills add sanjay3290/atlassian-skillSetup
Install Dependencies
pip install -r requirements.txtOption 1: OAuth 2.1 via MCP Server (Recommended)
No API tokens or instance URLs needed. Uses dynamic client registration and PKCE.
python scripts/auth.py login --oauthA browser opens for Atlassian authorization. Select which products (Jira, Confluence, Compass) to grant access. Tokens are stored in the system keyring and auto-refresh when expired.
Option 2: API Token (Fallback)
For environments where browser-based OAuth is not available.
1. Create an API token at: https://id.atlassian.com/manage-profile/security/api-tokens 2. Run the login command and follow the prompts:
python scripts/auth.py loginVerify Authentication
python scripts/auth.py statusLogout
python scripts/auth.py logoutUsage Examples
Jira
# Search for open bugs in a project
python scripts/jira.py search "project = DEV AND status = Open"
# Search your assigned issues, most recently updated first
python scripts/jira.py search "assignee = currentUser() ORDER BY updated DESC" --limit 10
# Get full details of an issue
python scripts/jira.py get DEV-123
# Create a bug
python scripts/jira.py create --project DEV --summary "Fix login bug" --type Bug
# Create a story with all fields
python scripts/jira.py create --project DEV --summary "New feature" --type Story \
--description "Details here" --priority High --assignee "user@example.com" --labels "backend,urgent"
# Transition an issue to a new status
python scripts/jira.py transition DEV-123 "In Progress"
# Add a comment
python scripts/jira.py comment DEV-123 --add "Deployed to staging"
# List all projects
python scripts/jira.py list-projectsConfluence
# Search pages by keyword
python scripts/confluence.py search "deployment guide"
# Search with CQL
python scripts/confluence.py search "type=page AND space=DEV AND text~\"deployment\""
# Read a page
python scripts/confluence.py read <page-id>
# List all spaces
python scripts/confluence.py list-spaces
# Create a page in a space
python scripts/confluence.py create --title "New Page" --space-id <space-id>
# Create a page with content under a parent page
python scripts/confluence.py create --title "Guide" --space-id <id> --body "<p>Content here</p>" --parent-id <parent-id>
# Update a page
python scripts/confluence.py update <page-id> --title "Updated Title" --body "<p>New content</p>"
# Get child pages
python scripts/confluence.py get-children <page-id>All Commands
Jira
| Command | Description | Required Args |
|---|---|---|
search | Search issues with JQL | jql |
get | Get issue details | issue_key |
create | Create new issue | --project, --summary, --type |
update | Update existing issue | issue_key |
transition | Change issue status | issue_key, status |
comment | Add or list comments | issue_key |
list-projects | List accessible projects | - |
list-statuses | List statuses for project | project_key |
auth-info | Test API connection | - |
list-tools | List MCP tools (OAuth only) | - |
Confluence
| Command | Description | Required Args |
|---|---|---|
search | Search using CQL | query |
read | Get page content | page_id |
list-spaces | List all spaces | - |
get-space | Get space details | space_id |
list-pages | List pages in a space | --space-id |
create | Create new page | --title, --space-id |
update | Update existing page | page_id |
get-children | Get child pages | page_id |
auth-info | Test API connection | - |
list-tools | List MCP tools (OAuth only) | - |
JSON Output
Add --json flag to any command for machine-readable output.
Part of AI Skills Collection
This skill is also available in the ai-skills collection with 20+ other skills for databases, image generation, research, Google Workspace, Azure DevOps, and more.
License
Apache-2.0
httpx>=0.25.0
keyring>=24.0.0
python-dotenv>=1.0.0
#!/usr/bin/env python3
"""
Shared client layer for Atlassian Cloud APIs (Jira + Confluence).
Supports two backends:
- MCP: OAuth-authenticated calls to the Atlassian MCP server (tools/call)
- REST: API token-authenticated calls to the Atlassian REST API (httpx)
Domain scripts (jira.py, confluence.py) use `get_backend()` to determine
which backend is active, then call accordingly.
"""
import json
import sys
from typing import Any, Dict, Optional
from auth import get_auth_header, get_auth_type, require_config
# httpx is deferred — only needed for REST backend (API token auth).
# MCP-only users (OAuth) don't need httpx installed.
httpx = None
def _ensure_httpx():
"""Import httpx on demand so MCP-only users don't need it installed."""
global httpx
if httpx is None:
try:
import httpx as _httpx # type: ignore[no-redef]
httpx = _httpx
except ImportError:
print("Error: httpx is required for REST backend. Install with: pip install httpx", file=sys.stderr)
sys.exit(1)
class AtlassianAPIError(Exception):
"""Raised when an Atlassian API returns an error."""
pass
class AtlassianClient:
"""HTTP client for Atlassian REST APIs (API token auth)."""
def __init__(self, base_url: str, auth_header: str, timeout: int = 30):
_ensure_httpx()
self.base_url = base_url.rstrip("/")
self._auth_header = auth_header
self.client = httpx.Client(
timeout=timeout,
headers={
"Accept": "application/json",
"Authorization": auth_header,
},
)
def request(
self,
method: str,
url: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> dict:
"""Make an authenticated HTTP request."""
try:
headers = {"Content-Type": "application/json"} if data is not None else None
response = self.client.request(
method, url, params=params, json=data, headers=headers,
)
if response.status_code == 204:
return {}
response.raise_for_status()
if response.content:
return response.json()
return {}
except httpx.HTTPStatusError as e:
error_body = e.response.text
try:
error_json = e.response.json()
errors = error_json.get("errorMessages", [])
field_errors = error_json.get("errors", {})
message = error_json.get("message", "")
parts = []
if message:
parts.append(message)
if errors:
parts.extend(errors)
if field_errors and isinstance(field_errors, dict):
parts.extend(f"{k}: {v}" for k, v in field_errors.items())
error_msg = "; ".join(parts) if parts else error_body
except Exception:
error_msg = error_body
if self._auth_header:
token_part = self._auth_header.split(" ", 1)[-1]
error_msg = error_msg.replace(token_part, "***")
raise AtlassianAPIError(f"HTTP {e.response.status_code}: {error_msg}")
except httpx.RequestError as e:
raise AtlassianAPIError(f"Request failed: {e}")
def get(self, url: str, params: Optional[dict] = None) -> dict:
"""Make a GET request."""
return self.request("GET", url, params=params)
def post(self, url: str, data: dict) -> dict:
"""Make a POST request with JSON body."""
return self.request("POST", url, data=data)
def put(self, url: str, data: dict) -> dict:
"""Make a PUT request with JSON body."""
return self.request("PUT", url, data=data)
def close(self):
"""Close the HTTP client."""
self.client.close()
def get_backend() -> str:
"""
Determine which backend to use based on auth type.
Returns:
"mcp" for OAuth authentication (uses MCP tools)
"rest" for API token authentication (uses REST API)
"""
auth_type = get_auth_type()
if auth_type == "oauth":
return "mcp"
return "rest"
def create_rest_client(timeout: int = 30) -> AtlassianClient:
"""
Create an authenticated REST API client.
Exits with helpful error if not authenticated with API token.
"""
config = require_config()
if config.get("auth_type") == "oauth":
print("Error: REST client requires API token authentication.", file=sys.stderr)
print("Current auth type is OAuth. Use MCP backend instead,", file=sys.stderr)
print("or re-authenticate: python scripts/auth.py login", file=sys.stderr)
sys.exit(1)
auth_header = get_auth_header()
if not auth_header:
print("Error: Failed to build auth header.", file=sys.stderr)
sys.exit(1)
return AtlassianClient(
base_url=config["base_url"],
auth_header=auth_header,
timeout=timeout,
)
# Keep backward compat alias
create_client = create_rest_client
def output_result(data: Any, as_json: bool = False):
"""Output result in requested format."""
if as_json:
if hasattr(data, "__dataclass_fields__"):
data = {k: v for k, v in data.__dict__.items() if v is not None}
print(json.dumps(data, indent=2, default=str))
else:
print(data)
#!/usr/bin/env python3
"""
Authentication management for Atlassian Cloud APIs (Jira + Confluence).
Supports two authentication methods:
1. OAuth 2.1 via Atlassian MCP server (recommended)
- Dynamic client registration (RFC 7591)
- Authorization Code + PKCE (S256)
- Tokens stored in system keyring
2. API token with Basic auth (fallback)
- Email + API token stored in system keyring or env vars
Credentials stored in system keyring:
- macOS: Keychain
- Windows: Windows Credential Locker
- Linux: Secret Service API (GNOME Keyring, KDE Wallet)
Usage:
python3 auth.py login --oauth # OAuth 2.1 via MCP server
python3 auth.py login # API token (email + token)
python3 auth.py status
python3 auth.py logout
"""
import argparse
import base64
import hashlib
import html
import http.server
import json
import os
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from pathlib import Path
KEYCHAIN_SERVICE = "atlassian-skill"
KEYCHAIN_ACCOUNT = "default"
# OAuth 2.1 endpoints (from Atlassian MCP server)
OAUTH_METADATA_URL = "https://mcp.atlassian.com/.well-known/oauth-authorization-server"
OAUTH_REGISTER_URL = "https://cf.mcp.atlassian.com/v1/register"
OAUTH_AUTHORIZE_URL = "https://mcp.atlassian.com/v1/authorize"
OAUTH_TOKEN_URL = "https://cf.mcp.atlassian.com/v1/token"
MCP_ENDPOINT = "https://mcp.atlassian.com/v1/mcp"
# Local callback server
CALLBACK_HOST = "127.0.0.1"
CALLBACK_PORT = 39827 # arbitrary high port
# Try importing keyring
try:
import keyring
HAS_KEYRING = True
except ImportError:
HAS_KEYRING = False
# Try importing dotenv
try:
from dotenv import load_dotenv
except ImportError:
load_dotenv = None
def _load_env():
"""Load environment variables from .env file if python-dotenv is available."""
if load_dotenv is not None:
skill_dir = Path(__file__).parent.parent
env_file = skill_dir / ".env"
if env_file.exists():
load_dotenv(env_file)
elif Path(".env").exists():
load_dotenv()
_load_env()
# ─── Storage ────────────────────────────────────────────────────────────────
def _get_keyring_config():
"""Get stored config from keyring."""
if not HAS_KEYRING:
return None
try:
data_str = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
if not data_str:
return None
return json.loads(data_str)
except json.JSONDecodeError:
return None
except Exception as e:
print(f"Warning: keyring read failed: {e}", file=sys.stderr)
return None
def _save_keyring_config(config):
"""Save config to keyring."""
if not HAS_KEYRING:
return False
try:
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, json.dumps(config))
return True
except Exception as e:
print(f"Warning: keyring write failed: {e}", file=sys.stderr)
return False
def _clear_keyring_config():
"""Clear stored credentials from keyring."""
if not HAS_KEYRING:
return False
try:
keyring.delete_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
return True
except Exception as e:
print(f"Warning: keyring clear failed: {e}", file=sys.stderr)
return False
def _get_env_config():
"""Get API token config from environment variables."""
base_url = os.environ.get("ATLASSIAN_URL", "")
email = os.environ.get("ATLASSIAN_EMAIL", "")
api_token = os.environ.get("ATLASSIAN_API_TOKEN", "")
if base_url and email and api_token:
return {
"auth_type": "api_token",
"base_url": base_url.rstrip("/"),
"email": email,
"api_token": api_token,
}
return None
# ─── PKCE helpers ───────────────────────────────────────────────────────────
def _generate_pkce():
"""Generate PKCE code_verifier and code_challenge (S256)."""
code_verifier = secrets.token_urlsafe(64)[:128]
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
# ─── OAuth callback server ──────────────────────────────────────────────────
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the OAuth authorization code callback."""
auth_code = None
auth_error = None
state_received = None
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if "code" in params:
_OAuthCallbackHandler.auth_code = params["code"][0]
_OAuthCallbackHandler.state_received = params.get("state", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h2>Authorization successful!</h2>"
b"<p>You can close this browser tab and return to the terminal.</p>"
b"</body></html>")
elif "error" in params:
_OAuthCallbackHandler.auth_error = params.get("error_description",
params.get("error", ["Unknown error"]))[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
error_msg = html.escape(_OAuthCallbackHandler.auth_error)
self.wfile.write(f"<html><body><h2>Authorization failed</h2>"
f"<p>{error_msg}</p></body></html>".encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args): # type: ignore[override]
pass # suppress HTTP logs
def _wait_for_callback(server, timeout=120):
"""Run the callback server until we get a response or timeout."""
server.timeout = 1
deadline = time.time() + timeout
while time.time() < deadline:
server.handle_request()
if _OAuthCallbackHandler.auth_code or _OAuthCallbackHandler.auth_error:
break
# ─── OAuth flow ─────────────────────────────────────────────────────────────
def _http_json_request(url, data=None, method=None, headers=None):
"""Make an HTTP request and return parsed JSON response."""
if data is not None:
if isinstance(data, dict):
body = json.dumps(data).encode("utf-8")
content_type = "application/json"
else:
body = data.encode("utf-8") if isinstance(data, str) else data
content_type = "application/x-www-form-urlencoded"
if method is None:
method = "POST"
else:
body = None
content_type = None
if method is None:
method = "GET"
req = urllib.request.Request(url, data=body, method=method)
req.add_header("User-Agent", "atlassian-skill/3.0")
req.add_header("Accept", "application/json")
if content_type:
req.add_header("Content-Type", content_type)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
try:
error_json = json.loads(error_body)
msg = error_json.get("error_description", error_json.get("error", error_body))
except Exception:
msg = error_body
raise RuntimeError(f"HTTP {e.code}: {msg}")
def _register_oauth_client(redirect_uri):
"""Dynamically register a public OAuth client (RFC 7591)."""
registration_data = {
"client_name": "atlassian-skill-cli",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
return _http_json_request(OAUTH_REGISTER_URL, data=registration_data)
def _exchange_code_for_tokens(client_id, auth_code, redirect_uri, code_verifier):
"""Exchange authorization code for tokens at the token endpoint."""
token_data = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": client_id,
"code": auth_code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
})
return _http_json_request(OAUTH_TOKEN_URL, data=token_data)
def _refresh_access_token(client_id, refresh_token):
"""Use refresh token to get a new access token."""
token_data = urllib.parse.urlencode({
"grant_type": "refresh_token",
"client_id": client_id,
"refresh_token": refresh_token,
})
return _http_json_request(OAUTH_TOKEN_URL, data=token_data)
def oauth_login():
"""Run the full OAuth 2.1 + PKCE login flow."""
redirect_uri = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}/callback"
# Step 1: Dynamic client registration
print("Registering OAuth client...", file=sys.stderr)
try:
reg = _register_oauth_client(redirect_uri)
except RuntimeError as e:
print(f"Error: Client registration failed: {e}", file=sys.stderr)
sys.exit(1)
client_id = reg.get("client_id")
if not client_id:
print("Error: No client_id returned from registration.", file=sys.stderr)
sys.exit(1)
# Step 2: Generate PKCE values
code_verifier, code_challenge = _generate_pkce()
state = secrets.token_urlsafe(32)
# Step 3: Build authorization URL
auth_params = urllib.parse.urlencode({
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": state,
})
auth_url = f"{OAUTH_AUTHORIZE_URL}?{auth_params}"
# Step 4: Start local callback server
_OAuthCallbackHandler.auth_code = None
_OAuthCallbackHandler.auth_error = None
_OAuthCallbackHandler.state_received = None
server = http.server.HTTPServer((CALLBACK_HOST, CALLBACK_PORT), _OAuthCallbackHandler)
# Step 5: Open browser for authorization
print(f"\nOpening browser for Atlassian authorization...", file=sys.stderr)
print(f"If the browser doesn't open, visit this URL:\n{auth_url}\n", file=sys.stderr)
webbrowser.open(auth_url)
# Step 6: Wait for callback
print("Waiting for authorization (2 minute timeout)...", file=sys.stderr)
try:
_wait_for_callback(server, timeout=120)
finally:
server.server_close()
if _OAuthCallbackHandler.auth_error:
print(f"Error: Authorization failed: {_OAuthCallbackHandler.auth_error}", file=sys.stderr)
sys.exit(1)
if not _OAuthCallbackHandler.auth_code:
print("Error: No authorization code received (timed out).", file=sys.stderr)
sys.exit(1)
# Validate state
if _OAuthCallbackHandler.state_received != state:
print("Error: State mismatch — possible CSRF attack.", file=sys.stderr)
sys.exit(1)
# Step 7: Exchange code for tokens
print("Exchanging code for tokens...", file=sys.stderr)
try:
tokens = _exchange_code_for_tokens(
client_id,
_OAuthCallbackHandler.auth_code,
redirect_uri,
code_verifier,
)
except RuntimeError as e:
print(f"Error: Token exchange failed: {e}", file=sys.stderr)
sys.exit(1)
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
expires_in = tokens.get("expires_in", 3600)
if not access_token:
print("Error: No access_token in response.", file=sys.stderr)
sys.exit(1)
# Step 8: Store in keyring
config = {
"auth_type": "oauth",
"client_id": client_id,
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": int(time.time()) + expires_in,
"mcp_endpoint": MCP_ENDPOINT,
}
if _save_keyring_config(config):
print("OAuth login successful!")
print(f"Token expires in {expires_in // 60} minutes.")
if refresh_token:
print("Refresh token stored — will auto-refresh when expired.")
print("Credentials stored in system keyring.")
else:
print("Error: Failed to store credentials in keyring.", file=sys.stderr)
sys.exit(1)
def ensure_valid_oauth_token():
"""
Check if the OAuth token is expired and refresh if needed.
Returns the config with a valid access_token, or None if refresh fails.
"""
config = _get_keyring_config()
if not config or config.get("auth_type") != "oauth":
return config
expires_at = config.get("expires_at", 0)
# Refresh if token expires within 60 seconds
if time.time() < expires_at - 60:
return config
refresh_token = config.get("refresh_token")
if not refresh_token:
return None
try:
tokens = _refresh_access_token(config["client_id"], refresh_token)
except RuntimeError:
return None
config["access_token"] = tokens.get("access_token", config["access_token"])
if tokens.get("refresh_token"):
config["refresh_token"] = tokens["refresh_token"]
config["expires_at"] = int(time.time()) + tokens.get("expires_in", 3600)
_save_keyring_config(config)
return config
# ─── Validation (API token) ─────────────────────────────────────────────────
def _validate_credentials(base_url, email, api_token):
"""Validate API token credentials by calling Jira /rest/api/3/myself."""
url = f"{base_url.rstrip('/')}/rest/api/3/myself"
creds = base64.b64encode(f"{email}:{api_token}".encode()).decode()
headers = {
"Authorization": f"Basic {creds}",
"Accept": "application/json",
}
try:
req = urllib.request.Request(url, headers=headers)
req.add_header("User-Agent", "atlassian-skill/3.0")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:200]
print(f" Validation failed (HTTP {e.code}): {body}", file=sys.stderr)
return None
except (urllib.error.URLError, Exception) as e:
print(f" Validation failed: {e}", file=sys.stderr)
return None
# ─── Public API (used by api_client.py and domain scripts) ──────────────────
def get_auth_type():
"""
Get the current authentication type.
Returns:
"oauth" | "api_token" | None
"""
config = get_config()
if not config:
return None
return config.get("auth_type", "api_token")
def get_config():
"""
Get Atlassian config. Priority: keyring > environment variables.
For OAuth: returns dict with auth_type, client_id, access_token, refresh_token, etc.
For API token: returns dict with auth_type, base_url, email, api_token.
Returns None if not configured.
"""
config = _get_keyring_config()
if config:
# Ensure auth_type is set (backward compat with old configs)
if "auth_type" not in config:
config["auth_type"] = "api_token"
# Auto-refresh OAuth tokens
if config.get("auth_type") == "oauth":
config = ensure_valid_oauth_token()
return config
config = _get_env_config()
if config:
return config
return None
def get_auth_header():
"""
Get the auth header value.
For OAuth: returns "Bearer <access_token>"
For API token: returns "Basic <base64(email:token)>"
"""
config = get_config()
if not config:
return None
if config.get("auth_type") == "oauth":
access_token = config.get("access_token")
if access_token:
return f"Bearer {access_token}"
return None
# API token path
email = config.get("email", "")
api_token = config.get("api_token", "")
if email and api_token:
creds = base64.b64encode(f"{email}:{api_token}".encode()).decode()
return f"Basic {creds}"
return None
def get_base_url():
"""Get the Atlassian instance base URL (API token auth only)."""
config = get_config()
if not config:
return None
return config.get("base_url")
def get_mcp_endpoint():
"""Get the MCP endpoint URL (OAuth auth only)."""
config = get_config()
if not config or config.get("auth_type") != "oauth":
return None
return config.get("mcp_endpoint", MCP_ENDPOINT)
def require_config():
"""
Get config or exit with helpful error message.
Returns:
dict with auth config
"""
config = get_config()
if config:
return config
print("Error: Not authenticated.", file=sys.stderr)
print("\nTo authenticate, choose one of:", file=sys.stderr)
if HAS_KEYRING:
print("\n Option 1 (recommended): OAuth via Atlassian MCP server", file=sys.stderr)
print(" python scripts/auth.py login --oauth", file=sys.stderr)
print("\n Option 2: API token via keyring", file=sys.stderr)
print(" python scripts/auth.py login", file=sys.stderr)
print("\n Option 3: Set environment variables", file=sys.stderr)
print(" export ATLASSIAN_URL=https://your-domain.atlassian.net", file=sys.stderr)
print(" export ATLASSIAN_EMAIL=your-email@example.com", file=sys.stderr)
print(" export ATLASSIAN_API_TOKEN=your-api-token", file=sys.stderr)
print("\n Get your API token at:", file=sys.stderr)
print(" https://id.atlassian.com/manage-profile/security/api-tokens", file=sys.stderr)
sys.exit(1)
# ─── CLI ────────────────────────────────────────────────────────────────────
def cmd_login(args):
"""Handle login command."""
if args.oauth:
if not HAS_KEYRING:
print("Error: keyring package required for OAuth. Install with: pip install keyring",
file=sys.stderr)
sys.exit(1)
oauth_login()
return
# API token login
if not HAS_KEYRING:
print("Error: keyring package not installed.", file=sys.stderr)
print("Install with: pip install keyring", file=sys.stderr)
print("Or use environment variables instead (see .env.example).", file=sys.stderr)
sys.exit(1)
base_url = args.url
if not base_url:
base_url = input("Atlassian URL (e.g. https://your-domain.atlassian.net): ").strip()
if not base_url:
print("URL is required.", file=sys.stderr)
sys.exit(1)
base_url = base_url.rstrip("/")
email = args.email
if not email:
email = input("Email: ").strip()
if not email:
print("Email is required.", file=sys.stderr)
sys.exit(1)
api_token = args.token
if not api_token:
import getpass
api_token = getpass.getpass("API token: ").strip()
if not api_token:
print("API token is required.", file=sys.stderr)
sys.exit(1)
print("Validating credentials...", file=sys.stderr)
user = _validate_credentials(base_url, email, api_token)
if not user:
print("Error: Invalid credentials. Check your URL, email, and API token.", file=sys.stderr)
sys.exit(1)
config = {
"auth_type": "api_token",
"base_url": base_url,
"email": email,
"api_token": api_token,
}
if _save_keyring_config(config):
print(f"Login successful!")
print(f"User: {user.get('displayName', 'Unknown')}")
print(f"Instance: {base_url}")
print(f"Credentials stored in system keyring.")
else:
print("Failed to store credentials in keyring.", file=sys.stderr)
sys.exit(1)
def cmd_logout(_args):
"""Handle logout command."""
if _clear_keyring_config():
print("Logged out successfully. Credentials removed from keyring.")
else:
print("No credentials to clear (or keyring not available).")
def cmd_status(_args):
"""Handle status command."""
config = get_config()
if not config:
print("Status: Not authenticated")
print("\nRun: python scripts/auth.py login --oauth (OAuth)")
print(" or: python scripts/auth.py login (API token)")
sys.exit(1)
auth_type = config.get("auth_type", "api_token")
source = "keyring" if _get_keyring_config() else "environment variables"
print(f"Status: Authenticated")
print(f"Auth type: {auth_type}")
print(f"Source: {source}")
if auth_type == "oauth":
expires_at = int(config.get("expires_at", 0))
remaining = expires_at - int(time.time())
if remaining > 0:
print(f"Token expires in: {remaining // 60}m {remaining % 60}s")
else:
print(f"Token: expired (will auto-refresh)")
has_refresh = "yes" if config.get("refresh_token") else "no"
print(f"Refresh token: {has_refresh}")
print(f"MCP endpoint: {config.get('mcp_endpoint', MCP_ENDPOINT)}")
else:
base_url = config.get("base_url", "unknown")
email = config.get("email", "unknown")
print(f"Instance: {base_url}")
print(f"Email: {email}")
# Validate connection
print("\nTesting connection...", file=sys.stderr)
user = _validate_credentials(base_url, email, config.get("api_token", ""))
if user:
print(f"Connection: OK")
print(f"User: {user.get('displayName', 'Unknown')}")
else:
print(f"Connection: FAILED (credentials may be expired)")
def main():
parser = argparse.ArgumentParser(description="Atlassian authentication management")
subparsers = parser.add_subparsers(dest="command")
login_parser = subparsers.add_parser("login", help="Authenticate with Atlassian Cloud")
login_parser.add_argument("--oauth", action="store_true",
help="Use OAuth 2.1 via Atlassian MCP server (recommended)")
login_parser.add_argument("--url", help="Atlassian instance URL (API token mode)")
login_parser.add_argument("--email", help="Account email (API token mode)")
login_parser.add_argument("--token", help="API token (API token mode)")
subparsers.add_parser("logout", help="Clear stored credentials")
subparsers.add_parser("status", help="Check authentication status")
args = parser.parse_args()
if args.command == "login":
cmd_login(args)
elif args.command == "logout":
cmd_logout(args)
elif args.command == "status":
cmd_status(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Confluence Wiki CLI - Search, read, and manage Confluence wiki pages.
Supports two backends:
- MCP: OAuth-authenticated calls to Atlassian MCP server (tools/call)
- REST: API token-authenticated calls to Confluence REST API v2
Usage:
python3 confluence.py search "query"
python3 confluence.py read <page-id>
python3 confluence.py list-spaces
python3 confluence.py get-space <space-id>
python3 confluence.py list-pages --space-id <space-id>
python3 confluence.py create --title "Title" --space-id <id> [--body "<p>content</p>"]
python3 confluence.py update <page-id> --title "New Title"
python3 confluence.py get-children <page-id>
python3 confluence.py auth-info
python3 confluence.py list-tools # MCP only: show available tools
"""
import argparse
import html
import json
import re
import sys
from dataclasses import dataclass
from typing import Any, Dict, Optional
from api_client import AtlassianAPIError, AtlassianClient, create_rest_client, get_backend, output_result
# ─── Data Models ─────────────────────────────────────────────────────────────
@dataclass
class ConfluencePage:
"""Represents a Confluence page."""
id: str
title: str
space_id: Optional[str] = None
status: Optional[str] = None
body: Optional[str] = None
version_number: Optional[int] = None
parent_id: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
url: Optional[str] = None
@classmethod
def from_v2_dict(cls, data: dict, base_url: str = "") -> "ConfluencePage":
"""Create from v2 API response."""
body_content = None
if "body" in data:
body_obj = data["body"]
if "storage" in body_obj:
body_content = body_obj["storage"].get("value", "")
elif "view" in body_obj:
body_content = body_obj["view"].get("value", "")
elif "atlas_doc_format" in body_obj:
body_content = body_obj["atlas_doc_format"].get("value", "")
version_num = None
if "version" in data:
version_num = data["version"].get("number")
page_url = None
if base_url and data.get("_links", {}).get("webui"):
page_url = base_url.rstrip("/") + data["_links"]["webui"]
return cls(
id=str(data.get("id", "")),
title=data.get("title", ""),
space_id=data.get("spaceId"),
status=data.get("status"),
body=body_content,
version_number=version_num,
parent_id=data.get("parentId"),
created_at=data.get("createdAt"),
updated_at=data.get("version", {}).get("createdAt"),
url=page_url,
)
@classmethod
def from_v1_search(cls, data: dict, base_url: str = "") -> "ConfluencePage":
"""Create from v1 search API response."""
body_content = None
if "body" in data:
body_obj = data["body"]
if "storage" in body_obj:
body_content = body_obj["storage"].get("value", "")
elif "view" in body_obj:
body_content = body_obj["view"].get("value", "")
version_num = None
if "version" in data:
version_num = data["version"].get("number")
page_url = None
if base_url and data.get("_links", {}).get("webui"):
page_url = base_url.rstrip("/") + data["_links"]["webui"]
return cls(
id=str(data.get("id", "")),
title=data.get("title", ""),
space_id=data.get("space", {}).get("id") if isinstance(data.get("space"), dict) else None,
status=data.get("status"),
body=body_content,
version_number=version_num,
parent_id=None,
created_at=None,
updated_at=data.get("version", {}).get("when"),
url=page_url,
)
@dataclass
class ConfluenceSpace:
"""Represents a Confluence space."""
id: str
key: str
name: str
description: Optional[str] = None
type: Optional[str] = None
status: Optional[str] = None
homepage_id: Optional[str] = None
@classmethod
def from_v2_dict(cls, data: dict) -> "ConfluenceSpace":
return cls(
id=str(data.get("id", "")),
key=data.get("key", ""),
name=data.get("name", ""),
description=data.get("description", {}).get("plain", {}).get("value") if isinstance(data.get("description"), dict) else data.get("description"),
type=data.get("type"),
status=data.get("status"),
homepage_id=str(data.get("homepageId", "")) if data.get("homepageId") else None,
)
# ─── Helpers ─────────────────────────────────────────────────────────────────
def strip_html_tags(text: str) -> str:
"""Strip HTML tags and decode entities for readable output."""
if not text:
return ""
clean = re.sub(r"<[^>]+>", " ", text)
clean = html.unescape(clean)
clean = re.sub(r"\s+", " ", clean).strip()
return clean
# ─── REST Client ─────────────────────────────────────────────────────────────
class ConfluenceClient:
"""Confluence API client wrapping shared AtlassianClient."""
def __init__(self, client: AtlassianClient):
self.client = client
self.base_url = client.base_url
wiki_base = self.base_url
if not wiki_base.endswith("/wiki"):
wiki_base = f"{wiki_base}/wiki"
self.wiki_base = wiki_base
self.api_url = f"{wiki_base}/api/v2"
self.v1_api_url = f"{wiki_base}/rest/api"
def get_current_user(self) -> dict:
url = f"{self.wiki_base}/rest/api/user/current"
return self.client.get(url)
def search(self, cql: str, limit: int = 25, start: int = 0) -> dict:
"""Search content using CQL (v1 API)."""
url = f"{self.v1_api_url}/content/search"
params = {
"cql": cql,
"limit": min(limit, 100),
"start": start,
"expand": "version,space",
}
result = self.client.get(url, params=params)
pages = [ConfluencePage.from_v1_search(r, self.wiki_base) for r in result.get("results", [])]
return {
"pages": pages,
"total_size": result.get("totalSize", 0),
"start": result.get("start", 0),
"limit": result.get("limit", limit),
}
def get_page(self, page_id: str) -> ConfluencePage:
url = f"{self.api_url}/pages/{page_id}"
params = {"body-format": "storage"}
result = self.client.get(url, params=params)
return ConfluencePage.from_v2_dict(result, self.wiki_base)
def list_spaces(self, limit: int = 25, cursor: Optional[str] = None) -> dict:
url = f"{self.api_url}/spaces"
params: dict = {"limit": min(limit, 250)}
if cursor:
params["cursor"] = cursor
result = self.client.get(url, params=params)
spaces = [ConfluenceSpace.from_v2_dict(s) for s in result.get("results", [])]
return {
"spaces": spaces,
"next_cursor": result.get("_links", {}).get("next"),
}
def get_space(self, space_id: str) -> ConfluenceSpace:
url = f"{self.api_url}/spaces/{space_id}"
params = {"description-format": "plain"}
result = self.client.get(url, params=params)
return ConfluenceSpace.from_v2_dict(result)
def list_pages(self, space_id: str, limit: int = 25, cursor: Optional[str] = None, status: str = "current") -> dict:
url = f"{self.api_url}/spaces/{space_id}/pages"
params: dict = {"limit": min(limit, 250), "status": status}
if cursor:
params["cursor"] = cursor
result = self.client.get(url, params=params)
pages = [ConfluencePage.from_v2_dict(p, self.wiki_base) for p in result.get("results", [])]
return {
"pages": pages,
"next_cursor": result.get("_links", {}).get("next"),
}
def create_page(self, space_id: str, title: str, body: Optional[str] = None,
parent_id: Optional[str] = None, status: str = "current") -> ConfluencePage:
url = f"{self.api_url}/pages"
payload: dict = {
"spaceId": space_id,
"status": status,
"title": title,
"body": {
"representation": "storage",
"value": body or "",
},
}
if parent_id:
payload["parentId"] = parent_id
result = self.client.post(url, payload)
return ConfluencePage.from_v2_dict(result, self.wiki_base)
def update_page(self, page_id: str, title: Optional[str] = None,
body: Optional[str] = None, status: str = "current") -> ConfluencePage:
"""Update a page. Automatically handles version increment."""
current = self.get_page(page_id)
new_version = (current.version_number or 0) + 1
url = f"{self.api_url}/pages/{page_id}"
payload = {
"id": page_id,
"status": status,
"title": title if title is not None else current.title,
"version": {
"number": new_version,
"message": "Updated via Confluence CLI",
},
"body": {
"representation": "storage",
"value": body if body is not None else (current.body or ""),
},
}
result = self.client.put(url, payload)
return ConfluencePage.from_v2_dict(result, self.wiki_base)
def get_children(self, page_id: str, limit: int = 25, cursor: Optional[str] = None) -> dict:
url = f"{self.api_url}/pages/{page_id}/children"
params: dict = {"limit": min(limit, 250)}
if cursor:
params["cursor"] = cursor
result = self.client.get(url, params=params)
pages = [ConfluencePage.from_v2_dict(p, self.wiki_base) for p in result.get("results", [])]
return {
"pages": pages,
"next_cursor": result.get("_links", {}).get("next"),
}
# ─── MCP Backend ─────────────────────────────────────────────────────────────
from mcp_client import AtlassianMCPClient, MCPError, mcp_output as _mcp_output # noqa: E402
class ConfluenceMCPClient(AtlassianMCPClient):
"""Confluence operations via Atlassian MCP server tools."""
def __init__(self):
super().__init__(product_name="Confluence")
def run_mcp_command(args):
"""Execute a Confluence command via MCP backend."""
client = ConfluenceMCPClient()
cmd = args.command
if cmd == "list-tools":
tools = client.list_tools()
if getattr(args, "json", False):
_mcp_output(tools, as_json=True)
else:
conf_tools = [t for t in tools if "confluence" in t.get("name", "").lower()
or t.get("name", "") in ("search", "fetch",
"atlassianUserInfo",
"getAccessibleAtlassianResources")]
print(f"Available Confluence-related MCP tools ({len(conf_tools)}):\n")
for t in conf_tools:
desc = t.get("description", "")[:80]
print(f" {t.get('name', '?')}")
if desc:
print(f" {desc}")
print()
return
if cmd == "auth-info":
result = client.call("atlassianUserInfo")
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "search":
cql = args.query
cql_operators = ["=", "~", "AND", "OR", "NOT", "IN", "order by"]
if not any(op in cql for op in cql_operators):
cql = f'type=page AND text~"{cql}"'
result = client.call("searchConfluenceUsingCql", {
"cql": cql,
"limit": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "read":
result = client.call("getConfluencePage", {
"pageId": args.page_id,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "list-spaces":
result = client.call("getConfluenceSpaces", {
"limit": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "get-space":
# MCP doesn't have a direct get-space tool, use getConfluenceSpaces with filter
try:
space_id_int = int(args.space_id)
except ValueError:
print(f"Error: Space ID must be numeric, got '{args.space_id}'.", file=sys.stderr)
sys.exit(1)
result = client.call("getConfluenceSpaces", {
"ids": [space_id_int],
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "list-pages":
result = client.call("getPagesInConfluenceSpace", {
"spaceId": args.space_id,
"limit": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "create":
tool_args: Dict[str, Any] = {
"spaceId": args.space_id,
"title": args.title,
"body": args.body if args.body else "", # MCP requires body
}
if args.parent_id:
tool_args["parentId"] = args.parent_id
result = client.call("createConfluencePage", tool_args)
if getattr(args, "json", False):
_mcp_output(result, as_json=True)
else:
print("Page created successfully!")
if isinstance(result, dict):
print(f"Title: {result.get('title', '')}")
print(f"ID: {result.get('id', '')}")
else:
print(result)
return
if cmd == "update":
tool_args_update: Dict[str, Any] = {
"pageId": args.page_id,
}
if args.title:
tool_args_update["title"] = args.title
if args.body:
tool_args_update["body"] = args.body
else:
# MCP requires body — fetch current page body if not provided
page = client.call("getConfluencePage", {"pageId": args.page_id})
if isinstance(page, dict) and "body" in page:
body = page["body"]
adf = {
"type": body.get("type", "doc"),
"version": body.get("version", 1),
"content": body.get("content", []),
}
tool_args_update["body"] = json.dumps(adf)
tool_args_update["contentFormat"] = "adf"
else:
print("Error: Could not fetch current page body. Provide --body.", file=sys.stderr)
sys.exit(1)
result = client.call("updateConfluencePage", tool_args_update)
if getattr(args, "json", False):
_mcp_output(result, as_json=True)
else:
print("Page updated successfully!")
if isinstance(result, dict):
print(f"Title: {result.get('title', '')}")
else:
print(result)
return
if cmd == "get-children":
result = client.call("getConfluencePageDescendants", {
"pageId": args.page_id,
"limit": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
print(f"Error: Unknown command '{cmd}'.", file=sys.stderr)
sys.exit(1)
# ─── Formatting ──────────────────────────────────────────────────────────────
def format_page(page: ConfluencePage, verbose: bool = False) -> str:
lines = [
f"Title: {page.title}",
f"ID: {page.id}",
]
if page.url:
lines.append(f"URL: {page.url}")
if page.space_id:
lines.append(f"Space: {page.space_id}")
if page.status:
lines.append(f"Status: {page.status}")
if page.version_number:
lines.append(f"Version: {page.version_number}")
if page.updated_at:
lines.append(f"Updated: {page.updated_at}")
if verbose and page.body:
readable = strip_html_tags(page.body)
lines.append(f"\n--- Content ---\n{readable}")
return "\n".join(lines)
def format_space(space: ConfluenceSpace) -> str:
lines = [
f"Name: {space.name}",
f"Key: {space.key}",
f"ID: {space.id}",
]
if space.type:
lines.append(f"Type: {space.type}")
if space.description:
lines.append(f"Description: {space.description}")
if space.homepage_id:
lines.append(f"Homepage: {space.homepage_id}")
return "\n".join(lines)
# ─── REST Command Handlers ──────────────────────────────────────────────────
def cmd_search(args, client: ConfluenceClient):
cql = args.query
cql_operators = ["=", "~", "AND", "OR", "NOT", "IN", "order by"]
if not any(op in cql for op in cql_operators):
cql = f'type=page AND text~"{cql}"'
result = client.search(cql=cql, limit=args.limit, start=args.offset)
if args.json:
output_result({
"pages": [p.__dict__ for p in result["pages"]],
"total_size": result["total_size"],
"start": result["start"],
"limit": result["limit"],
}, as_json=True)
else:
pages = result["pages"]
if not pages:
print(f"No pages found for query: {args.query}")
return
print(f"Found {len(pages)} page(s) (total: {result['total_size']}):\n")
for i, page in enumerate(pages, 1):
print(f"{i}. {page.title}")
print(f" ID: {page.id}")
if page.url:
print(f" URL: {page.url}")
if page.updated_at:
print(f" Updated: {page.updated_at}")
print()
def cmd_read(args, client: ConfluenceClient):
page = client.get_page(args.page_id)
if args.json:
output_result(page.__dict__, as_json=True)
else:
print(format_page(page, verbose=True))
def cmd_list_spaces(args, client: ConfluenceClient):
result = client.list_spaces(limit=args.limit)
if args.json:
output_result({"spaces": [s.__dict__ for s in result["spaces"]]}, as_json=True)
else:
spaces = result["spaces"]
if not spaces:
print("No spaces found.")
return
print(f"Found {len(spaces)} space(s):\n")
for space in spaces:
print(format_space(space))
print()
def cmd_get_space(args, client: ConfluenceClient):
space = client.get_space(args.space_id)
if args.json:
output_result(space.__dict__, as_json=True)
else:
print(format_space(space))
def cmd_list_pages(args, client: ConfluenceClient):
result = client.list_pages(space_id=args.space_id, limit=args.limit)
if args.json:
output_result({"pages": [p.__dict__ for p in result["pages"]]}, as_json=True)
else:
pages = result["pages"]
if not pages:
print("No pages found.")
return
print(f"Found {len(pages)} page(s):\n")
for page in pages:
print(f"- {page.title}")
print(f" ID: {page.id}")
if page.url:
print(f" URL: {page.url}")
print()
def cmd_create(args, client: ConfluenceClient):
status = "draft" if args.draft else "current"
page = client.create_page(
space_id=args.space_id,
title=args.title,
body=args.body,
parent_id=args.parent_id,
status=status,
)
if args.json:
output_result(page.__dict__, as_json=True)
else:
print("Page created successfully!\n")
print(format_page(page))
def cmd_update(args, client: ConfluenceClient):
page = client.update_page(
page_id=args.page_id,
title=args.title,
body=args.body,
)
if args.json:
output_result(page.__dict__, as_json=True)
else:
print("Page updated successfully!\n")
print(format_page(page))
def cmd_get_children(args, client: ConfluenceClient):
result = client.get_children(page_id=args.page_id, limit=args.limit)
if args.json:
output_result({"pages": [p.__dict__ for p in result["pages"]]}, as_json=True)
else:
pages = result["pages"]
if not pages:
print("No child pages found.")
return
print(f"Found {len(pages)} child page(s):\n")
for page in pages:
print(f"- {page.title}")
print(f" ID: {page.id}")
if page.url:
print(f" URL: {page.url}")
print()
def cmd_auth_info(args, client: ConfluenceClient):
try:
user = client.get_current_user()
except AtlassianAPIError as e:
print(f"Error: Unable to connect to Confluence API. {e}", file=sys.stderr)
sys.exit(1)
if args.json:
output_result(user, as_json=True)
else:
print("Authentication successful!\n")
print(f"User: {user.get('displayName', 'Unknown')}")
print(f"Email: {user.get('email', 'N/A')}")
print(f"Type: {user.get('type', 'Unknown')}")
print(f"URL: {client.wiki_base}")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Confluence Wiki CLI - Search, read, and manage Confluence wiki pages.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--json", action="store_true", help="Output results as JSON")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
def add_json_flag(subparser):
subparser.add_argument("--json", action="store_true", help="Output as JSON")
# search
search_parser = subparsers.add_parser("search", help="Search pages using CQL")
search_parser.add_argument("query", help="Search query or CQL expression")
search_parser.add_argument("--limit", type=int, default=25, help="Max results (default: 25)")
search_parser.add_argument("--offset", type=int, default=0, help="Pagination offset")
add_json_flag(search_parser)
# read
read_parser = subparsers.add_parser("read", help="Read a page")
read_parser.add_argument("page_id", help="Page ID")
add_json_flag(read_parser)
# list-spaces
list_spaces_parser = subparsers.add_parser("list-spaces", help="List all spaces")
list_spaces_parser.add_argument("--limit", type=int, default=25, help="Max results (default: 25)")
add_json_flag(list_spaces_parser)
# get-space
get_space_parser = subparsers.add_parser("get-space", help="Get space details")
get_space_parser.add_argument("space_id", help="Space ID")
add_json_flag(get_space_parser)
# list-pages
list_pages_parser = subparsers.add_parser("list-pages", help="List pages in a space")
list_pages_parser.add_argument("--space-id", required=True, help="Space ID")
list_pages_parser.add_argument("--limit", type=int, default=25, help="Max results (default: 25)")
add_json_flag(list_pages_parser)
# create
create_parser = subparsers.add_parser("create", help="Create a new page")
create_parser.add_argument("--title", required=True, help="Page title")
create_parser.add_argument("--space-id", required=True, help="Space ID")
create_parser.add_argument("--body", help="Page content (Confluence storage format / HTML)")
create_parser.add_argument("--parent-id", help="Parent page ID")
create_parser.add_argument("--draft", action="store_true", help="Create as draft")
add_json_flag(create_parser)
# update
update_parser = subparsers.add_parser("update", help="Update a page")
update_parser.add_argument("page_id", help="Page ID")
update_parser.add_argument("--title", help="New title")
update_parser.add_argument("--body", help="New content (Confluence storage format / HTML)")
add_json_flag(update_parser)
# get-children
children_parser = subparsers.add_parser("get-children", help="Get child pages")
children_parser.add_argument("page_id", help="Parent page ID")
children_parser.add_argument("--limit", type=int, default=25, help="Max results (default: 25)")
add_json_flag(children_parser)
# auth-info
auth_parser = subparsers.add_parser("auth-info", help="Test authentication and show user info")
add_json_flag(auth_parser)
# list-tools (MCP only)
tools_parser = subparsers.add_parser("list-tools", help="List available MCP tools (OAuth only)")
add_json_flag(tools_parser)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
try:
backend = get_backend()
if backend == "mcp":
run_mcp_command(args)
else:
atlassian_client = create_rest_client()
client = ConfluenceClient(atlassian_client)
try:
commands = {
"search": cmd_search,
"read": cmd_read,
"list-spaces": cmd_list_spaces,
"get-space": cmd_get_space,
"list-pages": cmd_list_pages,
"create": cmd_create,
"update": cmd_update,
"get-children": cmd_get_children,
"auth-info": cmd_auth_info,
}
handler = commands.get(args.command)
if handler:
handler(args, client)
elif args.command == "list-tools":
print("Error: list-tools is only available with OAuth authentication.", file=sys.stderr)
print("Run: python scripts/auth.py login --oauth", file=sys.stderr)
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
finally:
atlassian_client.close()
except AtlassianAPIError as e:
print(f"API Error: {e}", file=sys.stderr)
sys.exit(1)
except MCPError as e:
print(f"MCP Error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nCancelled.", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Jira CLI - Search, create, update, and manage Jira issues.
Supports two backends:
- MCP: OAuth-authenticated calls to Atlassian MCP server (tools/call)
- REST: API token-authenticated calls to Jira REST API v3
Usage:
python3 jira.py search "project = DEV AND status = Open"
python3 jira.py get DEV-123
python3 jira.py create --project DEV --summary "Bug title" --type Bug
python3 jira.py update DEV-123 --summary "New title"
python3 jira.py transition DEV-123 "In Progress"
python3 jira.py comment DEV-123 --add "A comment"
python3 jira.py comment DEV-123 --list
python3 jira.py list-projects
python3 jira.py list-statuses DEV
python3 jira.py auth-info
python3 jira.py list-tools # MCP only: show available tools
"""
import argparse
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from api_client import AtlassianAPIError, AtlassianClient, create_rest_client, get_backend, output_result
# ─── Data Models ─────────────────────────────────────────────────────────────
@dataclass
class JiraIssue:
"""Represents a Jira issue."""
key: str
summary: str
status: str
issue_type: str
project_key: str
assignee: Optional[str] = None
reporter: Optional[str] = None
priority: Optional[str] = None
description: Optional[str] = None
labels: List[str] = field(default_factory=list)
created: Optional[str] = None
updated: Optional[str] = None
url: Optional[str] = None
parent_key: Optional[str] = None
@classmethod
def from_dict(cls, data: dict, base_url: str = "") -> "JiraIssue":
fields = data.get("fields", {})
assignee = fields.get("assignee")
reporter = fields.get("reporter")
priority = fields.get("priority")
status = fields.get("status", {})
issue_type = fields.get("issuetype", {})
project = fields.get("project", {})
parent = fields.get("parent")
description = None
desc_field = fields.get("description")
if desc_field:
if isinstance(desc_field, str):
description = desc_field
elif isinstance(desc_field, dict):
description = _extract_adf_text(desc_field)
key = data.get("key", "")
return cls(
key=key,
summary=fields.get("summary", ""),
status=status.get("name", "") if status else "",
issue_type=issue_type.get("name", "") if issue_type else "",
project_key=project.get("key", "") if project else "",
assignee=assignee.get("displayName", assignee.get("emailAddress", "")) if assignee else None,
reporter=reporter.get("displayName", reporter.get("emailAddress", "")) if reporter else None,
priority=priority.get("name", "") if priority else None,
description=description,
labels=fields.get("labels", []),
created=fields.get("created"),
updated=fields.get("updated"),
url=f"{base_url}/browse/{key}" if base_url else None,
parent_key=parent.get("key") if parent else None,
)
@dataclass
class JiraComment:
"""Represents a Jira comment."""
id: str
author: str
body: str
created: str
updated: Optional[str] = None
@classmethod
def from_dict(cls, data: dict) -> "JiraComment":
author = data.get("author", {})
body_field = data.get("body", "")
if isinstance(body_field, dict):
body = _extract_adf_text(body_field)
else:
body = body_field
return cls(
id=data.get("id", ""),
author=author.get("displayName", author.get("emailAddress", "")),
body=body,
created=data.get("created", ""),
updated=data.get("updated"),
)
# ─── ADF Helpers ─────────────────────────────────────────────────────────────
def _extract_adf_text(adf: dict) -> str:
"""Extract plain text from Atlassian Document Format."""
if not isinstance(adf, dict):
return str(adf)
texts = []
if adf.get("type") == "text":
return adf.get("text", "")
for node in adf.get("content", []):
if isinstance(node, dict):
if node.get("type") == "text":
texts.append(node.get("text", ""))
elif node.get("type") == "hardBreak":
texts.append("\n")
elif "content" in node:
texts.append(_extract_adf_text(node))
if node.get("type") in ("paragraph", "heading", "bulletList", "orderedList"):
texts.append("\n")
return "".join(texts).strip()
def _text_to_adf(text: str) -> dict:
"""Convert plain text to Atlassian Document Format."""
paragraphs = text.split("\n\n") if "\n\n" in text else [text]
content = []
for para in paragraphs:
if para.strip():
content.append({
"type": "paragraph",
"content": [{"type": "text", "text": para.strip()}],
})
return {"type": "doc", "version": 1, "content": content}
# ─── REST Client ─────────────────────────────────────────────────────────────
class JiraClient:
"""Jira REST API v3 client wrapping shared AtlassianClient."""
def __init__(self, client: AtlassianClient):
self.client = client
self.base_url = client.base_url
self.api_url = f"{self.base_url}/rest/api/3"
def _request(self, method: str, endpoint: str, data: Optional[dict] = None, params: Optional[dict] = None) -> dict:
url = f"{self.api_url}{endpoint}"
return self.client.request(method, url, data=data, params=params)
def get_myself(self) -> dict:
return self._request("GET", "/myself")
def search_issues(self, jql: str, limit: int = 50, fields: Optional[List[str]] = None,
next_page_token: Optional[str] = None) -> dict:
default_fields = ["summary", "status", "issuetype", "project", "assignee",
"reporter", "priority", "labels", "created", "updated", "parent"]
params = {
"jql": jql,
"maxResults": min(limit, 100),
"fields": ",".join(fields or default_fields),
}
if next_page_token:
params["nextPageToken"] = next_page_token
return self._request("GET", "/search/jql", params=params)
def get_issue(self, issue_key: str) -> dict:
return self._request("GET", f"/issue/{issue_key}")
def create_issue(self, project_key: str, summary: str, issue_type: str,
description: Optional[str] = None, priority: Optional[str] = None,
assignee_id: Optional[str] = None, labels: Optional[List[str]] = None,
parent_key: Optional[str] = None) -> dict:
fields: Dict[str, Any] = {
"project": {"key": project_key},
"summary": summary,
"issuetype": {"name": issue_type},
}
if description:
fields["description"] = _text_to_adf(description)
if priority:
fields["priority"] = {"name": priority}
if assignee_id:
fields["assignee"] = {"accountId": assignee_id}
if labels:
fields["labels"] = labels
if parent_key:
fields["parent"] = {"key": parent_key}
return self._request("POST", "/issue", data={"fields": fields})
def update_issue(self, issue_key: str, summary: Optional[str] = None,
description: Optional[str] = None, priority: Optional[str] = None,
assignee_id: Optional[str] = None, labels: Optional[List[str]] = None) -> dict:
fields: Dict[str, Any] = {}
if summary is not None:
fields["summary"] = summary
if description is not None:
fields["description"] = _text_to_adf(description)
if priority is not None:
fields["priority"] = {"name": priority}
if assignee_id is not None:
fields["assignee"] = {"accountId": assignee_id}
if labels is not None:
fields["labels"] = labels
return self._request("PUT", f"/issue/{issue_key}", data={"fields": fields})
def get_transitions(self, issue_key: str) -> List[dict]:
result = self._request("GET", f"/issue/{issue_key}/transitions")
return result.get("transitions", [])
def transition_issue(self, issue_key: str, transition_id: str) -> dict:
return self._request("POST", f"/issue/{issue_key}/transitions",
data={"transition": {"id": transition_id}})
def get_comments(self, issue_key: str, limit: int = 50, offset: int = 0) -> dict:
params = {"maxResults": min(limit, 100), "startAt": offset, "orderBy": "-created"}
return self._request("GET", f"/issue/{issue_key}/comment", params=params)
def add_comment(self, issue_key: str, body: str) -> dict:
return self._request("POST", f"/issue/{issue_key}/comment",
data={"body": _text_to_adf(body)})
def list_projects(self, limit: int = 50, offset: int = 0) -> List[dict]:
params = {"maxResults": min(limit, 100), "startAt": offset}
result = self._request("GET", "/project/search", params=params)
return result.get("values", [])
def get_statuses_for_project(self, project_key: str) -> list:
result = self._request("GET", f"/project/{project_key}/statuses")
return result if isinstance(result, list) else []
def find_user(self, query: str) -> list:
params = {"query": query, "maxResults": 10}
result = self._request("GET", "/user/search", params=params)
return result if isinstance(result, list) else []
# ─── MCP Backend ─────────────────────────────────────────────────────────────
from mcp_client import AtlassianMCPClient, MCPError, mcp_output as _mcp_output # noqa: E402
class JiraMCPClient(AtlassianMCPClient):
"""Jira operations via Atlassian MCP server tools."""
def __init__(self):
super().__init__(product_name="Jira")
def run_mcp_command(args):
"""Execute a Jira command via MCP backend."""
client = JiraMCPClient()
cmd = args.command
if cmd == "list-tools":
tools = client.list_tools()
if getattr(args, "json", False):
_mcp_output(tools, as_json=True)
else:
jira_tools = [t for t in tools if "jira" in t.get("name", "").lower()
or t.get("name", "") in ("search", "fetch",
"atlassianUserInfo",
"getAccessibleAtlassianResources")]
print(f"Available Jira-related MCP tools ({len(jira_tools)}):\n")
for t in jira_tools:
desc = t.get("description", "")[:80]
print(f" {t.get('name', '?')}")
if desc:
print(f" {desc}")
print()
return
if cmd == "auth-info":
result = client.call("atlassianUserInfo")
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "search":
result = client.call("searchJiraIssuesUsingJql", {
"jql": args.jql,
"maxResults": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "get":
result = client.call("getJiraIssue", {
"issueIdOrKey": args.issue_key,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "create":
tool_args: Dict[str, Any] = {
"projectKey": args.project,
"summary": args.summary,
"issueTypeName": args.type,
}
if args.description:
tool_args["description"] = args.description
if args.assignee:
# Look up account ID first
user_result = client.call("lookupJiraAccountId", {
"searchString": args.assignee,
})
if isinstance(user_result, list) and user_result:
tool_args["assignee_account_id"] = user_result[0].get("accountId", "")
elif isinstance(user_result, dict) and user_result.get("accountId"):
tool_args["assignee_account_id"] = user_result["accountId"]
result = client.call("createJiraIssue", tool_args)
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "update":
fields: Dict[str, Any] = {}
if args.summary:
fields["summary"] = args.summary
if args.description:
fields["description"] = _text_to_adf(args.description)
if args.priority:
fields["priority"] = {"name": args.priority}
if args.labels:
fields["labels"] = [l.strip() for l in args.labels.split(",")]
if args.assignee:
user_result = client.call("lookupJiraAccountId", {
"searchString": args.assignee,
})
if isinstance(user_result, list) and user_result:
fields["assignee"] = {"accountId": user_result[0].get("accountId", "")}
if not fields:
print("Error: Provide at least one field to update (--summary, --description, --priority, --labels, --assignee).", file=sys.stderr)
sys.exit(1)
result = client.call("editJiraIssue", {
"issueIdOrKey": args.issue_key,
"fields": fields,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "transition":
# Get available transitions first
transitions = client.call("getTransitionsForJiraIssue", {
"issueIdOrKey": args.issue_key,
})
trans_list = transitions if isinstance(transitions, list) else transitions.get("transitions", [])
target = args.status.lower()
match = None
for t in trans_list:
if t.get("name", "").lower() == target:
match = t
break
if not match:
available = [t.get("name", "") for t in trans_list]
print(f"Error: Transition '{args.status}' not available.", file=sys.stderr)
print(f"Available: {', '.join(available)}", file=sys.stderr)
sys.exit(1)
result = client.call("transitionJiraIssue", {
"issueIdOrKey": args.issue_key,
"transition": {"id": match["id"]},
})
if getattr(args, "json", False):
_mcp_output(result, as_json=True)
else:
print(f"Issue {args.issue_key} transitioned to '{match.get('name')}'.")
return
if cmd == "comment":
if getattr(args, "add", None):
result = client.call("addCommentToJiraIssue", {
"issueIdOrKey": args.issue_key,
"commentBody": args.add,
})
if getattr(args, "json", False):
_mcp_output(result, as_json=True)
else:
print(f"Comment added to {args.issue_key}.")
elif getattr(args, "list", False):
# Get issue to see comments
result = client.call("getJiraIssue", {
"issueIdOrKey": args.issue_key,
"fields": ["comment"],
})
_mcp_output(result, getattr(args, "json", False))
else:
print("Error: Use --add to add a comment or --list to list comments.", file=sys.stderr)
sys.exit(1)
return
if cmd == "list-projects":
result = client.call("getVisibleJiraProjects", {
"maxResults": args.limit,
})
_mcp_output(result, getattr(args, "json", False))
return
if cmd == "list-statuses":
result = client.call("getJiraProjectIssueTypesMetadata", {
"projectIdOrKey": args.project_key,
})
_mcp_output(result, getattr(args, "json", False))
return
print(f"Error: Unknown command '{cmd}'.", file=sys.stderr)
sys.exit(1)
# ─── Formatting ──────────────────────────────────────────────────────────────
def format_issue(issue: JiraIssue, verbose: bool = False) -> str:
lines = [
f"Key: {issue.key}",
f"Summary: {issue.summary}",
f"Status: {issue.status}",
f"Type: {issue.issue_type}",
f"Project: {issue.project_key}",
]
if issue.priority:
lines.append(f"Priority: {issue.priority}")
if issue.assignee:
lines.append(f"Assignee: {issue.assignee}")
if issue.reporter:
lines.append(f"Reporter: {issue.reporter}")
if issue.labels:
lines.append(f"Labels: {', '.join(issue.labels)}")
if issue.parent_key:
lines.append(f"Parent: {issue.parent_key}")
if issue.updated:
lines.append(f"Updated: {issue.updated}")
if issue.url:
lines.append(f"URL: {issue.url}")
if verbose and issue.description:
lines.append(f"\n--- Description ---\n{issue.description}")
return "\n".join(lines)
# ─── REST Command Handlers ──────────────────────────────────────────────────
def cmd_search(args, client: JiraClient):
result = client.search_issues(jql=args.jql, limit=args.limit)
issues = [JiraIssue.from_dict(i, client.base_url) for i in result.get("issues", [])]
total = result.get("total", 0)
if args.json:
output_result({
"issues": [i.__dict__ for i in issues],
"total": total,
"startAt": result.get("startAt", 0),
"maxResults": result.get("maxResults", 0),
}, as_json=True)
else:
if not issues:
print(f"No issues found for JQL: {args.jql}")
return
count = total if total else len(issues)
print(f"Found {count} issue(s):\n")
for i, issue in enumerate(issues, 1):
print(f"{i}. [{issue.key}] {issue.summary}")
print(f" Status: {issue.status} | Type: {issue.issue_type} | Priority: {issue.priority or 'None'}")
if issue.assignee:
print(f" Assignee: {issue.assignee}")
print()
def cmd_get(args, client: JiraClient):
data = client.get_issue(args.issue_key)
issue = JiraIssue.from_dict(data, client.base_url)
if args.json:
output_result(issue.__dict__, as_json=True)
else:
print(format_issue(issue, verbose=True))
def cmd_create(args, client: JiraClient):
labels = [l.strip() for l in args.labels.split(",")] if args.labels else None
assignee_id = None
if args.assignee:
users = client.find_user(args.assignee)
if users:
assignee_id = users[0].get("accountId")
else:
print(f"Warning: Could not find user '{args.assignee}', creating without assignee.", file=sys.stderr)
result = client.create_issue(
project_key=args.project,
summary=args.summary,
issue_type=args.type,
description=args.description,
priority=args.priority,
assignee_id=assignee_id,
labels=labels,
parent_key=args.parent,
)
issue_key = result.get("key", "")
if args.json:
output_result({
"key": issue_key,
"id": result.get("id", ""),
"self": result.get("self", ""),
"url": f"{client.base_url}/browse/{issue_key}" if issue_key else None,
}, as_json=True)
else:
print(f"Issue created successfully: {issue_key}")
if issue_key:
print(f"URL: {client.base_url}/browse/{issue_key}")
def cmd_update(args, client: JiraClient):
if not any([args.summary, args.description, args.priority, args.labels, args.assignee]):
print("Error: Provide at least one field to update (--summary, --description, --priority, --labels, --assignee).", file=sys.stderr)
sys.exit(1)
labels = [l.strip() for l in args.labels.split(",")] if args.labels else None
assignee_id = None
if args.assignee:
users = client.find_user(args.assignee)
if users:
assignee_id = users[0].get("accountId")
else:
print(f"Warning: Could not find user '{args.assignee}', skipping assignee update.", file=sys.stderr)
client.update_issue(
issue_key=args.issue_key,
summary=args.summary,
description=args.description,
priority=args.priority,
assignee_id=assignee_id,
labels=labels,
)
if args.json:
data = client.get_issue(args.issue_key)
issue = JiraIssue.from_dict(data, client.base_url)
output_result(issue.__dict__, as_json=True)
else:
print(f"Issue {args.issue_key} updated successfully.")
def cmd_transition(args, client: JiraClient):
transitions = client.get_transitions(args.issue_key)
target = args.status.lower()
match = None
for t in transitions:
if t.get("name", "").lower() == target:
match = t
break
if not match:
available = [t.get("name", "") for t in transitions]
if args.json:
output_result({
"error": f"Transition '{args.status}' not available",
"available_transitions": available,
}, as_json=True)
else:
print(f"Error: Transition '{args.status}' not available for {args.issue_key}.", file=sys.stderr)
print(f"Available transitions: {', '.join(available)}", file=sys.stderr)
sys.exit(1)
client.transition_issue(args.issue_key, match["id"])
if args.json:
output_result({
"issue_key": args.issue_key,
"transition": match.get("name"),
"transition_id": match.get("id"),
}, as_json=True)
else:
print(f"Issue {args.issue_key} transitioned to '{match.get('name')}'.")
def cmd_comment(args, client: JiraClient):
if args.add:
result = client.add_comment(args.issue_key, args.add)
comment = JiraComment.from_dict(result)
if args.json:
output_result(comment.__dict__, as_json=True)
else:
print(f"Comment added to {args.issue_key}.")
print(f"Author: {comment.author}")
print(f"Created: {comment.created}")
elif args.list:
result = client.get_comments(args.issue_key, limit=args.limit)
comments = [JiraComment.from_dict(c) for c in result.get("comments", [])]
total = result.get("total", 0)
if args.json:
output_result({
"comments": [c.__dict__ for c in comments],
"total": total,
}, as_json=True)
else:
if not comments:
print(f"No comments on {args.issue_key}.")
return
print(f"Comments on {args.issue_key} ({total} total):\n")
for c in comments:
print(f"--- {c.author} ({c.created}) ---")
print(c.body)
print()
else:
print("Error: Use --add to add a comment or --list to list comments.", file=sys.stderr)
sys.exit(1)
def cmd_list_projects(args, client: JiraClient):
projects = client.list_projects(limit=args.limit)
if args.json:
output = [{
"key": p.get("key", ""),
"name": p.get("name", ""),
"id": p.get("id", ""),
"style": p.get("style", ""),
"lead": p.get("lead", {}).get("displayName", "") if p.get("lead") else None,
} for p in projects]
output_result(output, as_json=True)
else:
if not projects:
print("No projects found.")
return
print(f"Found {len(projects)} project(s):\n")
for p in projects:
lead = p.get("lead", {})
lead_name = lead.get("displayName", "") if lead else ""
print(f" {p.get('key', '')}: {p.get('name', '')}")
if lead_name:
print(f" Lead: {lead_name}")
print()
def cmd_list_statuses(args, client: JiraClient):
statuses_data = client.get_statuses_for_project(args.project_key)
if args.json:
output = []
for issue_type in statuses_data:
entry = {
"issue_type": issue_type.get("name", ""),
"statuses": [{"name": s.get("name", ""), "id": s.get("id", "")}
for s in issue_type.get("statuses", [])],
}
output.append(entry)
output_result(output, as_json=True)
else:
if not statuses_data:
print(f"No statuses found for project {args.project_key}.")
return
print(f"Statuses for project {args.project_key}:\n")
for issue_type in statuses_data:
print(f" {issue_type.get('name', 'Unknown')}:")
for s in issue_type.get("statuses", []):
print(f" - {s.get('name', '')} (ID: {s.get('id', '')})")
print()
def cmd_auth_info(args, client: JiraClient):
try:
user = client.get_myself()
except AtlassianAPIError:
print("Error: Unable to connect to Jira API. Check your credentials and URL.", file=sys.stderr)
sys.exit(1)
if args.json:
output_result({
"display_name": user.get("displayName", ""),
"email": user.get("emailAddress", ""),
"account_id": user.get("accountId", ""),
"active": user.get("active", False),
"url": client.base_url,
}, as_json=True)
else:
print("Authentication successful!\n")
print(f"User: {user.get('displayName', 'Unknown')}")
print(f"Email: {user.get('emailAddress', 'Unknown')}")
print(f"Account ID: {user.get('accountId', 'Unknown')}")
print(f"Active: {user.get('active', False)}")
print(f"Instance: {client.base_url}")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Jira CLI - Search, create, update, and manage Jira issues.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--json", action="store_true", help="Output results as JSON")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
def add_json_flag(subparser):
subparser.add_argument("--json", action="store_true", help="Output as JSON")
# search
search_parser = subparsers.add_parser("search", help="Search issues with JQL")
search_parser.add_argument("jql", help="JQL query string")
search_parser.add_argument("--limit", type=int, default=50, help="Max results (default: 50)")
search_parser.add_argument("--offset", type=int, default=0, help="Pagination offset")
add_json_flag(search_parser)
# get
get_parser = subparsers.add_parser("get", help="Get issue details")
get_parser.add_argument("issue_key", help="Issue key (e.g. DEV-123)")
add_json_flag(get_parser)
# create
create_parser = subparsers.add_parser("create", help="Create a new issue")
create_parser.add_argument("--project", required=True, help="Project key (e.g. DEV)")
create_parser.add_argument("--summary", required=True, help="Issue summary")
create_parser.add_argument("--type", required=True, help="Issue type (e.g. Bug, Story, Task)")
create_parser.add_argument("--description", help="Issue description")
create_parser.add_argument("--priority", help="Priority (e.g. High, Medium, Low)")
create_parser.add_argument("--assignee", help="Assignee email or display name")
create_parser.add_argument("--labels", help="Comma-separated labels")
create_parser.add_argument("--parent", help="Parent issue key for sub-tasks")
add_json_flag(create_parser)
# update
update_parser = subparsers.add_parser("update", help="Update an issue")
update_parser.add_argument("issue_key", help="Issue key (e.g. DEV-123)")
update_parser.add_argument("--summary", help="New summary")
update_parser.add_argument("--description", help="New description")
update_parser.add_argument("--priority", help="New priority")
update_parser.add_argument("--assignee", help="New assignee email or display name")
update_parser.add_argument("--labels", help="Comma-separated labels (replaces existing)")
add_json_flag(update_parser)
# transition
transition_parser = subparsers.add_parser("transition", help="Transition issue status")
transition_parser.add_argument("issue_key", help="Issue key (e.g. DEV-123)")
transition_parser.add_argument("status", help="Target status name (e.g. 'In Progress', 'Done')")
add_json_flag(transition_parser)
# comment
comment_parser = subparsers.add_parser("comment", help="Add or list comments")
comment_parser.add_argument("issue_key", help="Issue key (e.g. DEV-123)")
comment_parser.add_argument("--add", help="Comment text to add")
comment_parser.add_argument("--list", action="store_true", help="List comments")
comment_parser.add_argument("--limit", type=int, default=50, help="Max comments to list (default: 50)")
add_json_flag(comment_parser)
# list-projects
projects_parser = subparsers.add_parser("list-projects", help="List accessible projects")
projects_parser.add_argument("--limit", type=int, default=50, help="Max results (default: 50)")
add_json_flag(projects_parser)
# list-statuses
statuses_parser = subparsers.add_parser("list-statuses", help="List statuses for a project")
statuses_parser.add_argument("project_key", help="Project key (e.g. DEV)")
add_json_flag(statuses_parser)
# auth-info
auth_parser = subparsers.add_parser("auth-info", help="Test authentication and show user info")
add_json_flag(auth_parser)
# list-tools (MCP only)
tools_parser = subparsers.add_parser("list-tools", help="List available MCP tools (OAuth only)")
add_json_flag(tools_parser)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
try:
backend = get_backend()
if backend == "mcp":
run_mcp_command(args)
else:
atlassian_client = create_rest_client()
client = JiraClient(atlassian_client)
try:
commands = {
"search": cmd_search,
"get": cmd_get,
"create": cmd_create,
"update": cmd_update,
"transition": cmd_transition,
"comment": cmd_comment,
"list-projects": cmd_list_projects,
"list-statuses": cmd_list_statuses,
"auth-info": cmd_auth_info,
}
handler = commands.get(args.command)
if handler:
handler(args, client)
elif args.command == "list-tools":
print("Error: list-tools is only available with OAuth authentication.", file=sys.stderr)
print("Run: python scripts/auth.py login --oauth", file=sys.stderr)
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
finally:
atlassian_client.close()
except AtlassianAPIError as e:
print(f"API Error: {e}", file=sys.stderr)
sys.exit(1)
except MCPError as e:
print(f"MCP Error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nCancelled.", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
MCP (Model Context Protocol) client for Atlassian Cloud.
Communicates with the Atlassian MCP server at https://mcp.atlassian.com/v1/mcp
using JSON-RPC 2.0 over Streamable HTTP (POST with SSE or JSON responses).
Requires OAuth 2.1 authentication — run `python auth.py login --oauth` first.
Protocol sequence:
1. POST initialize → server returns capabilities + Mcp-Session-Id
2. POST notifications/initialized → server returns 202
3. POST tools/list → server returns available tools
4. POST tools/call → server returns tool results
"""
import json
import sys
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from auth import get_auth_header, get_auth_type, get_mcp_endpoint
MCP_PROTOCOL_VERSION = "2025-03-26"
class MCPError(Exception):
"""Raised when the MCP server returns an error."""
pass
class MCPClient:
"""Client for the Atlassian MCP server (JSON-RPC 2.0 over HTTP)."""
def __init__(self, endpoint: str, auth_header: str):
self.endpoint = endpoint
self._auth_header = auth_header
self._session_id: Optional[str] = None
self._request_id = 0
self._initialized = False
def _next_id(self) -> int:
self._request_id += 1
return self._request_id
def _make_request(self, method: str, params: Optional[dict] = None,
is_notification: bool = False) -> Any:
"""
Send a JSON-RPC 2.0 request to the MCP server.
Args:
method: JSON-RPC method name (e.g. "initialize", "tools/list")
params: Method parameters
is_notification: If True, no id field (server returns 202)
Returns:
Parsed result from the JSON-RPC response, or None for notifications
"""
payload: Dict[str, Any] = {
"jsonrpc": "2.0",
"method": method,
}
if params:
payload["params"] = params
if not is_notification:
payload["id"] = self._next_id()
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(self.endpoint, data=body, method="POST")
req.add_header("User-Agent", "atlassian-skill/3.0")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json, text/event-stream")
req.add_header("Authorization", self._auth_header)
if self._session_id:
req.add_header("Mcp-Session-Id", self._session_id)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
# Capture session ID from response headers
session_id = resp.headers.get("Mcp-Session-Id")
if session_id:
self._session_id = session_id
# Notifications return 202 with no body
if resp.status == 202:
return None
content_type = resp.headers.get("Content-Type", "")
raw = resp.read().decode("utf-8")
if "text/event-stream" in content_type:
return self._parse_sse(raw)
else:
return self._parse_json_rpc(raw)
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
# Sanitize auth header from error messages
if self._auth_header:
token_part = self._auth_header.split(" ", 1)[-1]
error_body = error_body.replace(token_part, "***")
raise MCPError(f"HTTP {e.code}: {error_body}")
except urllib.error.URLError as e:
raise MCPError(f"Connection failed: {e.reason}")
def _parse_json_rpc(self, raw: str) -> Any:
"""Parse a JSON-RPC 2.0 response."""
data = json.loads(raw)
if "error" in data:
err = data["error"]
raise MCPError(f"MCP error {err.get('code', '?')}: {err.get('message', raw)}")
return data.get("result")
def _parse_sse(self, raw: str) -> Any:
"""Parse Server-Sent Events response, extracting the last JSON-RPC message."""
last_result = None
for line in raw.split("\n"):
line = line.strip()
if line.startswith("data:"):
data_str = line[5:].strip()
if not data_str:
continue
try:
data = json.loads(data_str)
if "error" in data:
err = data["error"]
raise MCPError(
f"MCP error {err.get('code', '?')}: {err.get('message', data_str)}")
if "result" in data:
last_result = data["result"]
except json.JSONDecodeError:
continue
if last_result is None:
raise MCPError("No valid JSON-RPC result in SSE response")
return last_result
def initialize(self) -> dict:
"""
Initialize the MCP session.
Returns:
Server capabilities dict
"""
result = self._make_request("initialize", {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": "atlassian-skill",
"version": "3.0",
},
})
# Send initialized notification
self._make_request("notifications/initialized", is_notification=True)
self._initialized = True
return result
def _ensure_initialized(self):
"""Initialize the session if not already done."""
if not self._initialized:
self.initialize()
def list_tools(self) -> List[dict]:
"""List available MCP tools."""
self._ensure_initialized()
result = self._make_request("tools/list")
return result.get("tools", []) if result else []
def call_tool(self, tool_name: str, arguments: Optional[dict] = None) -> Any:
"""
Call an MCP tool.
Args:
tool_name: Name of the tool (e.g. "jira_search_issues")
arguments: Tool-specific arguments
Returns:
Tool result (parsed from content array)
"""
self._ensure_initialized()
params: Dict[str, Any] = {"name": tool_name}
params["arguments"] = arguments if arguments is not None else {}
result = self._make_request("tools/call", params)
if not result:
return None
# MCP tools/call returns {content: [{type, text}], isError}
if result.get("isError"):
content = result.get("content", [])
error_text = "\n".join(c.get("text", "") for c in content if c.get("type") == "text")
raise MCPError(f"Tool error: {error_text or 'Unknown error'}")
# Extract text content
content = result.get("content", [])
texts = [c.get("text", "") for c in content if c.get("type") == "text"]
combined = "\n".join(texts)
# Try to parse as JSON
try:
return json.loads(combined)
except (json.JSONDecodeError, ValueError):
return combined
def create_mcp_client() -> MCPClient:
"""
Create an authenticated MCP client.
Exits with helpful error if OAuth is not configured.
"""
auth_type = get_auth_type()
if auth_type != "oauth":
print("Error: MCP client requires OAuth authentication.", file=sys.stderr)
print("Run: python scripts/auth.py login --oauth", file=sys.stderr)
sys.exit(1)
endpoint = get_mcp_endpoint()
if not endpoint:
print("Error: No MCP endpoint configured.", file=sys.stderr)
sys.exit(1)
auth_header = get_auth_header()
if not auth_header:
print("Error: Failed to get OAuth token. Re-authenticate with:", file=sys.stderr)
print(" python scripts/auth.py login --oauth", file=sys.stderr)
sys.exit(1)
return MCPClient(endpoint=endpoint, auth_header=auth_header)
# ─── Shared MCP wrapper with cloudId management ────────────────────────────
# Tools that don't require cloudId
_NO_CLOUD_ID = {"atlassianUserInfo", "getAccessibleAtlassianResources", "search", "fetch"}
class AtlassianMCPClient:
"""Base MCP client with automatic cloudId injection.
Subclass or use directly for Jira/Confluence MCP operations.
"""
def __init__(self, product_name: str = "Atlassian"):
self.mcp = create_mcp_client()
self._cloud_id: Optional[str] = None
self._product_name = product_name
def _get_cloud_id(self) -> str:
"""Get the Atlassian Cloud ID (required for most MCP tools)."""
if self._cloud_id:
return self._cloud_id
result = self.mcp.call_tool("getAccessibleAtlassianResources")
if isinstance(result, list) and result:
self._cloud_id = result[0].get("id", "")
elif isinstance(result, dict):
resources = result.get("resources", [result])
if resources:
self._cloud_id = resources[0].get("id", "")
if not self._cloud_id:
raise RuntimeError(
f"No accessible Atlassian resources found. "
f"Ensure your OAuth consent includes {self._product_name} access.")
return self._cloud_id
def call(self, tool_name: str, arguments: Optional[dict] = None) -> Any:
"""Call an MCP tool with automatic cloudId injection."""
args = dict(arguments) if arguments else {}
if tool_name not in _NO_CLOUD_ID and "cloudId" not in args:
args["cloudId"] = self._get_cloud_id()
return self.mcp.call_tool(tool_name, args)
def list_tools(self) -> List[dict]:
"""List all available MCP tools."""
return self.mcp.list_tools()
def mcp_output(result: Any, as_json: bool = False):
"""Output MCP tool result to stdout."""
if as_json:
if isinstance(result, (dict, list)):
print(json.dumps(result, indent=2, default=str))
else:
print(json.dumps({"result": str(result)}, indent=2))
else:
if isinstance(result, str):
print(result)
elif isinstance(result, (dict, list)):
print(json.dumps(result, indent=2, default=str))
else:
print(result)