
Atlassian
- 11 installs
- 364 repo stars
- Updated July 9, 2026
- sanjay3290/postgres-skill
Helps with ai & agent building tasks.
About
atlassian is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- atlassian
- AI & Agent Building
- AI-coding skill
Atlassian by the numbers
- 11 all-time installs (skills.sh)
- Ranked #11,740 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/postgres-skill --skill atlassianAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 364 |
| Last updated | July 9, 2026 |
| Repository | sanjay3290/postgres-skill ↗ |
What it does
Helps with ai & agent building tasks.
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).
# 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
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)