Initial commit
This commit is contained in:
145
skills/mxcp-expert/assets/project-templates/jira/README.md
Normal file
145
skills/mxcp-expert/assets/project-templates/jira/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# MXCP Jira Python Endpoints Example
|
||||
|
||||
This example demonstrates how to use MXCP with Jira data using Python endpoints. This approach uses Python functions directly as MCP tools.
|
||||
|
||||
## Overview
|
||||
|
||||
This example provides Python MCP endpoints that allow you to:
|
||||
- Execute JQL queries to search issues
|
||||
- Get detailed information for specific issues
|
||||
- Get user information
|
||||
- List projects and their details
|
||||
- Get project metadata
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
This example uses Python functions that are exposed as MCP tools:
|
||||
- Python functions handle the Jira API interactions
|
||||
- Tool definitions map to these Python functions
|
||||
- Results are returned as JSON data
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Creating an Atlassian API Token
|
||||
|
||||
**Important:** This plugin currently only supports API tokens **without scopes**. While Atlassian has introduced scoped API tokens, there are known compatibility issues when using scoped tokens with basic authentication that this plugin relies on.
|
||||
|
||||
To create an API token without scopes:
|
||||
|
||||
1. **Log in to your Atlassian account** at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
|
||||
2. **Verify your identity** (if prompted):
|
||||
- Atlassian may ask you to verify your identity before creating API tokens
|
||||
- Check your email for a one-time passcode and enter it when prompted
|
||||
|
||||
3. **Create the API token**:
|
||||
- Click **"Create API token"** (not "Create API token with scopes")
|
||||
- Enter a descriptive name for your token (e.g., "MXCP Jira Python Integration")
|
||||
- Select an expiration date (tokens can last from 1 day to 1 year)
|
||||
- Click **"Create"**
|
||||
|
||||
4. **Copy and save your token**:
|
||||
- Click **"Copy to clipboard"** to copy the token
|
||||
- **Important:** Save this token securely (like in a password manager) as you won't be able to view it again
|
||||
- This token will be used as your "password" in the configuration below
|
||||
|
||||
### 2. User Configuration
|
||||
|
||||
Add the following to your MXCP user config (`~/.mxcp/config.yml`):
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
jira-demo:
|
||||
profiles:
|
||||
default:
|
||||
secrets:
|
||||
- name: "jira"
|
||||
type: "python"
|
||||
parameters:
|
||||
url: "https://your-domain.atlassian.net"
|
||||
username: "your-email@example.com"
|
||||
password: "your-api-token" # Use the API token you created above
|
||||
```
|
||||
|
||||
### 3. Site Configuration
|
||||
|
||||
Create an `mxcp-site.yml` file:
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
project: jira-demo
|
||||
profile: default
|
||||
secrets:
|
||||
- jira
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### JQL Query
|
||||
Execute JQL queries:
|
||||
```bash
|
||||
mxcp run tool jql_query --param query="project = TEST" --param limit=10
|
||||
```
|
||||
|
||||
### Get Issue
|
||||
Get detailed information for a specific issue by its key:
|
||||
```bash
|
||||
mxcp run tool get_issue --param issue_key="RD-123"
|
||||
```
|
||||
|
||||
### Get User
|
||||
Get a specific user by their account ID:
|
||||
```bash
|
||||
mxcp run tool get_user --param account_id="557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
```
|
||||
|
||||
### Search Users
|
||||
Search for users by name, email, or other criteria:
|
||||
```bash
|
||||
mxcp run tool search_user --param query="john.doe@example.com"
|
||||
```
|
||||
|
||||
### List Projects
|
||||
List all projects:
|
||||
```bash
|
||||
mxcp run tool list_projects
|
||||
```
|
||||
|
||||
### Get Project
|
||||
Get project details:
|
||||
```bash
|
||||
mxcp run tool get_project --param project_key="TEST"
|
||||
```
|
||||
|
||||
### Get Project Roles
|
||||
Get all roles available in a project:
|
||||
```bash
|
||||
mxcp run tool get_project_roles --param project_key="TEST"
|
||||
```
|
||||
|
||||
### Get Project Role Users
|
||||
Get users and groups for a specific role in a project:
|
||||
```bash
|
||||
mxcp run tool get_project_role_users --param project_key="TEST" --param role_name="Developers"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
jira-python/
|
||||
├── mxcp-site.yml # Site configuration
|
||||
├── python/ # Python implementations
|
||||
│ └── jira_endpoints.py # All JIRA endpoint functions
|
||||
├── tools/ # Tool definitions
|
||||
│ ├── jql_query.yml
|
||||
│ ├── get_issue.yml
|
||||
│ ├── get_user.yml
|
||||
│ ├── search_user.yml
|
||||
│ ├── list_projects.yml
|
||||
│ ├── get_project.yml
|
||||
│ ├── get_project_roles.yml
|
||||
│ └── get_project_role_users.yml
|
||||
└── README.md
|
||||
```
|
||||
17
skills/mxcp-expert/assets/project-templates/jira/config.yml
Normal file
17
skills/mxcp-expert/assets/project-templates/jira/config.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
mxcp: 1
|
||||
|
||||
# Sample configuration file for JIRA Python endpoints example
|
||||
# Copy this to ~/.mxcp/config.yml and update with your JIRA details
|
||||
|
||||
projects:
|
||||
jira-demo:
|
||||
profiles:
|
||||
default:
|
||||
secrets:
|
||||
- name: "jira"
|
||||
type: "python"
|
||||
parameters:
|
||||
url: "${JIRA_URL}"
|
||||
username: "${JIRA_USERNAME}"
|
||||
password: "${JIRA_API_TOKEN}"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mxcp: 1
|
||||
project: jira-demo
|
||||
profile: default
|
||||
secrets:
|
||||
- jira
|
||||
@@ -0,0 +1,569 @@
|
||||
"""
|
||||
JIRA Python Endpoints
|
||||
|
||||
This module provides direct Python MCP endpoints for querying Atlassian JIRA.
|
||||
This is a simpler alternative to the plugin-based approach.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import logging
|
||||
from atlassian import Jira
|
||||
from mxcp.runtime import config, on_init, on_shutdown
|
||||
import threading
|
||||
import functools
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global JIRA client for reuse across all function calls
|
||||
jira_client: Optional[Jira] = None
|
||||
# Thread lock to protect client initialization
|
||||
_client_lock = threading.Lock()
|
||||
|
||||
|
||||
@on_init
|
||||
def setup_jira_client() -> None:
|
||||
"""Initialize JIRA client when server starts.
|
||||
|
||||
Thread-safe: multiple threads can safely call this simultaneously.
|
||||
"""
|
||||
global jira_client
|
||||
|
||||
with _client_lock:
|
||||
logger.info("Initializing JIRA client...")
|
||||
|
||||
jira_config = config.get_secret("jira")
|
||||
if not jira_config:
|
||||
raise ValueError(
|
||||
"JIRA configuration not found. Please configure JIRA secrets in your user config."
|
||||
)
|
||||
|
||||
required_keys = ["url", "username", "password"]
|
||||
missing_keys = [key for key in required_keys if not jira_config.get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"Missing JIRA configuration keys: {', '.join(missing_keys)}")
|
||||
|
||||
jira_client = Jira(
|
||||
url=jira_config["url"],
|
||||
username=jira_config["username"],
|
||||
password=jira_config["password"],
|
||||
cloud=True,
|
||||
)
|
||||
|
||||
logger.info("JIRA client initialized successfully")
|
||||
|
||||
|
||||
@on_shutdown
|
||||
def cleanup_jira_client() -> None:
|
||||
"""Clean up JIRA client when server stops."""
|
||||
global jira_client
|
||||
if jira_client:
|
||||
# JIRA client doesn't need explicit cleanup, but we'll clear the reference
|
||||
jira_client = None
|
||||
logger.info("JIRA client cleaned up")
|
||||
|
||||
|
||||
def retry_on_session_expiration(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator that automatically retries functions on JIRA session expiration.
|
||||
|
||||
This only retries on HTTP 401 Unauthorized errors, not other authentication failures.
|
||||
Retries up to 2 times on session expiration (3 total attempts).
|
||||
Thread-safe: setup_jira_client() handles concurrent access internally.
|
||||
|
||||
Usage:
|
||||
@retry_on_session_expiration
|
||||
def my_jira_function():
|
||||
# Function that might fail due to session expiration
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
max_retries = 2 # Hardcoded: 2 retries = 3 total attempts
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# Check if this is a 401 Unauthorized error (session expired)
|
||||
if _is_session_expired(e):
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
f"Session expired on attempt {attempt + 1} in {func.__name__}: {e}"
|
||||
)
|
||||
logger.info(
|
||||
f"Retrying after re-initializing client (attempt {attempt + 2}/{max_retries + 1})"
|
||||
)
|
||||
|
||||
try:
|
||||
setup_jira_client() # Thread-safe internally
|
||||
time.sleep(0.1) # Small delay to avoid immediate retry
|
||||
except Exception as setup_error:
|
||||
logger.error(f"Failed to re-initialize JIRA client: {setup_error}")
|
||||
raise setup_error # Raise the setup error, not the original session error
|
||||
else:
|
||||
# Last attempt failed, re-raise the session expiration error
|
||||
raise e
|
||||
else:
|
||||
# Not a session expiration error, re-raise immediately
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _is_session_expired(exception: Exception) -> bool:
|
||||
"""Check if the exception indicates a JIRA session has expired."""
|
||||
error_msg = str(exception).lower()
|
||||
|
||||
# Check for HTTP 401 Unauthorized
|
||||
if "401" in error_msg or "unauthorized" in error_msg:
|
||||
return True
|
||||
|
||||
# Check for common session expiration messages
|
||||
if any(
|
||||
phrase in error_msg
|
||||
for phrase in [
|
||||
"session expired",
|
||||
"session invalid",
|
||||
"authentication failed",
|
||||
"invalid session",
|
||||
"session timeout",
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_jira_client() -> Jira:
|
||||
"""Get the global JIRA client."""
|
||||
if jira_client is None:
|
||||
raise RuntimeError("JIRA client not initialized. Make sure the server is started properly.")
|
||||
return jira_client
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def jql_query(
|
||||
query: str, start: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Execute a JQL query against Jira.
|
||||
|
||||
Args:
|
||||
query: The JQL query string
|
||||
start: Starting index for pagination (default: None, which becomes 0)
|
||||
limit: Maximum number of results to return (default: None, meaning no limit)
|
||||
|
||||
Returns:
|
||||
List of Jira issues matching the query
|
||||
"""
|
||||
logger.info("Executing JQL query: %s with start=%s, limit=%s", query, start, limit)
|
||||
|
||||
jira = _get_jira_client()
|
||||
|
||||
raw = jira.jql(
|
||||
jql=query,
|
||||
start=start if start is not None else 0,
|
||||
limit=limit,
|
||||
fields=(
|
||||
"key,summary,status,resolution,resolutiondate,"
|
||||
"assignee,reporter,issuetype,priority,"
|
||||
"created,updated,labels,fixVersions,parent"
|
||||
),
|
||||
)
|
||||
|
||||
if not raw:
|
||||
raise ValueError("JIRA JQL query returned empty result")
|
||||
|
||||
def _name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Return obj['name'] if present, else None."""
|
||||
return obj.get("name") if obj else None
|
||||
|
||||
def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("key") if obj else None
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
jira_url = jira.url
|
||||
|
||||
for issue in raw.get("issues", []):
|
||||
f = issue["fields"]
|
||||
|
||||
cleaned.append(
|
||||
{
|
||||
"key": issue["key"],
|
||||
"summary": f.get("summary"),
|
||||
"status": _name(f.get("status")),
|
||||
"resolution": _name(f.get("resolution")),
|
||||
"resolution_date": f.get("resolutiondate"),
|
||||
"assignee": _name(f.get("assignee")),
|
||||
"reporter": _name(f.get("reporter")),
|
||||
"type": _name(f.get("issuetype")),
|
||||
"priority": _name(f.get("priority")),
|
||||
"created": f.get("created"),
|
||||
"updated": f.get("updated"),
|
||||
"labels": f.get("labels") or [],
|
||||
"fix_versions": [_name(v) for v in f.get("fixVersions", [])],
|
||||
"parent": _key(f.get("parent")),
|
||||
"url": f"{jira_url}/browse/{issue['key']}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_issue(issue_key: str) -> Dict[str, Any]:
|
||||
"""Get detailed information for a specific JIRA issue by its key.
|
||||
|
||||
Args:
|
||||
issue_key: The issue key (e.g., 'RD-123', 'TEST-456')
|
||||
|
||||
Returns:
|
||||
Dictionary containing comprehensive issue information
|
||||
|
||||
Raises:
|
||||
ValueError: If issue is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting issue details for key: %s", issue_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# Get issue by key - this method handles the REST API call
|
||||
issue = jira.issue(issue_key)
|
||||
|
||||
# Extract and clean up the most important fields for easier consumption
|
||||
fields = issue.get("fields", {})
|
||||
jira_url = jira.url
|
||||
|
||||
def _safe_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
"""Safely get a value from a dict/object that might be None."""
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
cleaned_issue = {
|
||||
"key": issue.get("key"),
|
||||
"id": issue.get("id"),
|
||||
"summary": fields.get("summary"),
|
||||
"description": fields.get("description"),
|
||||
"status": _safe_get(fields.get("status"), "name"),
|
||||
"assignee": _safe_get(fields.get("assignee"), "displayName"),
|
||||
"assignee_account_id": _safe_get(fields.get("assignee"), "accountId"),
|
||||
"reporter": _safe_get(fields.get("reporter"), "displayName"),
|
||||
"reporter_account_id": _safe_get(fields.get("reporter"), "accountId"),
|
||||
"issue_type": _safe_get(fields.get("issuetype"), "name"),
|
||||
"priority": _safe_get(fields.get("priority"), "name"),
|
||||
"resolution": _safe_get(fields.get("resolution"), "name"),
|
||||
"resolution_date": fields.get("resolutiondate"),
|
||||
"created": fields.get("created"),
|
||||
"updated": fields.get("updated"),
|
||||
"due_date": fields.get("duedate"),
|
||||
"labels": fields.get("labels", []) or [],
|
||||
"components": (
|
||||
[comp.get("name") for comp in fields.get("components", []) if comp and comp.get("name")]
|
||||
if fields.get("components")
|
||||
else []
|
||||
),
|
||||
"fix_versions": (
|
||||
[ver.get("name") for ver in fields.get("fixVersions", []) if ver and ver.get("name")]
|
||||
if fields.get("fixVersions")
|
||||
else []
|
||||
),
|
||||
"project": {
|
||||
"key": _safe_get(fields.get("project"), "key"),
|
||||
"name": _safe_get(fields.get("project"), "name"),
|
||||
},
|
||||
"parent": _safe_get(fields.get("parent"), "key"),
|
||||
"url": f"{jira_url}/browse/{issue.get('key')}",
|
||||
}
|
||||
|
||||
return cleaned_issue
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_user(account_id: str) -> Dict[str, Any]:
|
||||
"""Get a specific user by their unique account ID.
|
||||
|
||||
Args:
|
||||
account_id: The unique Atlassian account ID for the user.
|
||||
Example: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
|
||||
Returns:
|
||||
Dictionary containing filtered user details
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not found or account ID is invalid
|
||||
"""
|
||||
logger.info("Getting user details for account ID: %s", account_id)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# Get user by account ID - pass as account_id parameter for Jira Cloud
|
||||
user = jira.user(account_id=account_id)
|
||||
|
||||
# Return only the requested fields
|
||||
return {
|
||||
"accountId": user.get("accountId"),
|
||||
"displayName": user.get("displayName"),
|
||||
"emailAddress": user.get("emailAddress"),
|
||||
"active": user.get("active"),
|
||||
"timeZone": user.get("timeZone"),
|
||||
}
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def search_user(query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for users by query string (username, email, or display name).
|
||||
|
||||
Args:
|
||||
query: Search term - can be username, email, display name, or partial matches.
|
||||
Examples: "ben@raw-labs.com", "Benjamin Gaidioz", "ben", "benjamin", "gaidioz"
|
||||
|
||||
Returns:
|
||||
List of matching users with filtered fields. Empty list if no matches found.
|
||||
"""
|
||||
logger.info("Searching for users with query: %s", query)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# user_find_by_user_string returns a list of users matching the query
|
||||
users = jira.user_find_by_user_string(query=query)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
||||
# Filter users to only include relevant fields
|
||||
filtered_users = []
|
||||
for user in users:
|
||||
filtered_users.append(
|
||||
{
|
||||
"accountId": user.get("accountId"),
|
||||
"displayName": user.get("displayName"),
|
||||
"emailAddress": user.get("emailAddress"),
|
||||
"active": user.get("active"),
|
||||
"timeZone": user.get("timeZone"),
|
||||
}
|
||||
)
|
||||
|
||||
return filtered_users
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def list_projects() -> List[Dict[str, Any]]:
|
||||
"""Return a concise list of Jira projects.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing project information
|
||||
"""
|
||||
logger.info("Listing all projects")
|
||||
|
||||
jira = _get_jira_client()
|
||||
raw_projects: List[Dict[str, Any]] = jira.projects(expand="lead")
|
||||
|
||||
def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("displayName") or obj.get("name") if obj else None
|
||||
|
||||
concise: List[Dict[str, Any]] = []
|
||||
jira_url = jira.url
|
||||
|
||||
for p in raw_projects:
|
||||
concise.append(
|
||||
{
|
||||
"key": p.get("key"),
|
||||
"name": p.get("name"),
|
||||
"type": p.get("projectTypeKey"), # e.g. software, business
|
||||
"lead": safe_name(p.get("lead")),
|
||||
"url": f"{jira_url}/projects/{p.get('key')}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return concise
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project(project_key: str) -> Dict[str, Any]:
|
||||
"""Get details for a specific project by its key.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
|
||||
Returns:
|
||||
Dictionary containing the project details
|
||||
|
||||
Raises:
|
||||
ValueError: If project is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting project details for key: %s", project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
info = jira.project(project_key)
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
if "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(f"Error retrieving project '{project_key}': {e}") from e
|
||||
|
||||
# Filter to essential fields only to avoid response size issues
|
||||
cleaned_info = {
|
||||
"key": info.get("key"),
|
||||
"name": info.get("name"),
|
||||
"description": info.get("description"),
|
||||
"projectTypeKey": info.get("projectTypeKey"),
|
||||
"simplified": info.get("simplified"),
|
||||
"style": info.get("style"),
|
||||
"isPrivate": info.get("isPrivate"),
|
||||
"archived": info.get("archived"),
|
||||
}
|
||||
|
||||
# Add lead info if present
|
||||
if "lead" in info and info["lead"]:
|
||||
cleaned_info["lead"] = {
|
||||
"displayName": info["lead"].get("displayName"),
|
||||
"emailAddress": info["lead"].get("emailAddress"),
|
||||
"accountId": info["lead"].get("accountId"),
|
||||
"active": info["lead"].get("active"),
|
||||
}
|
||||
|
||||
cleaned_info["url"] = f"{jira.url}/projects/{project_key}"
|
||||
|
||||
return cleaned_info
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
|
||||
"""Get all roles available in a project.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
|
||||
Returns:
|
||||
List of roles available in the project
|
||||
|
||||
Raises:
|
||||
ValueError: If project is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting project roles for key: %s", project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
# Get all project roles using the correct method
|
||||
project_roles = jira.get_project_roles(project_key)
|
||||
|
||||
result = []
|
||||
for role_name, role_url in project_roles.items():
|
||||
# Extract role ID from URL (e.g., "https://domain.atlassian.net/rest/api/3/project/10000/role/10002")
|
||||
role_id = role_url.split("/")[-1]
|
||||
|
||||
result.append({"name": role_name, "id": role_id})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
if "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(f"Error retrieving project roles for '{project_key}': {e}") from e
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
|
||||
"""Get users and groups for a specific role in a project.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
role_name: The name of the role to get users for
|
||||
|
||||
Returns:
|
||||
Dictionary containing users and groups for the specified role
|
||||
|
||||
Raises:
|
||||
ValueError: If project or role is not found, or access is denied
|
||||
"""
|
||||
logger.info("Getting users for role '%s' in project '%s'", role_name, project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
# First get all project roles to find the role ID
|
||||
project_roles = jira.get_project_roles(project_key)
|
||||
|
||||
if role_name not in project_roles:
|
||||
available_roles = list(project_roles.keys())
|
||||
raise ValueError(
|
||||
f"Role '{role_name}' not found in project '{project_key}'. Available roles: {available_roles}"
|
||||
)
|
||||
|
||||
# Extract role ID from URL
|
||||
role_url = project_roles[role_name]
|
||||
role_id = role_url.split("/")[-1]
|
||||
|
||||
# Get role details including actors (users and groups)
|
||||
role_details = jira.get_project_actors_for_role_project(project_key, role_id)
|
||||
|
||||
result = {
|
||||
"project_key": project_key,
|
||||
"role_name": role_name,
|
||||
"role_id": role_id,
|
||||
"users": [],
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
# Process actors (role_details is a list of actors)
|
||||
if isinstance(role_details, list):
|
||||
for actor in role_details:
|
||||
if isinstance(actor, dict):
|
||||
actor_type = actor.get("type", "")
|
||||
if actor_type == "atlassian-user-role-actor":
|
||||
# Individual user
|
||||
user_info = {
|
||||
"accountId": actor.get("actorUser", {}).get("accountId"),
|
||||
"displayName": actor.get("displayName"),
|
||||
}
|
||||
result["users"].append(user_info)
|
||||
elif actor_type == "atlassian-group-role-actor":
|
||||
# Group
|
||||
group_info = {
|
||||
"name": actor.get("displayName"),
|
||||
"groupId": actor.get("actorGroup", {}).get("groupId"),
|
||||
}
|
||||
result["groups"].append(group_info)
|
||||
else:
|
||||
# Handle other actor types or simple user entries
|
||||
display_name = actor.get("displayName") or actor.get("name")
|
||||
if display_name:
|
||||
user_info = {
|
||||
"accountId": actor.get("accountId"),
|
||||
"displayName": display_name,
|
||||
}
|
||||
result["users"].append(user_info)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError:
|
||||
# Re-raise ValueError as-is (these are our custom error messages)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
|
||||
# Don't handle 401 errors here - let the retry decorator handle them
|
||||
if "401" in error_msg or "unauthorized" in error_msg:
|
||||
raise e # Let the retry decorator catch this
|
||||
elif "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(
|
||||
f"Error retrieving users for role '{role_name}' in project '{project_key}': {e}"
|
||||
) from e
|
||||
@@ -0,0 +1,114 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_issue
|
||||
description: |
|
||||
Get detailed information for a specific JIRA issue by its key.
|
||||
Returns comprehensive issue information including all fields, assignee, reporter, etc.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Issue
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: issue_key
|
||||
type: string
|
||||
description: |
|
||||
The issue key (e.g., 'RD-123', 'TEST-456').
|
||||
This is the unique identifier for the issue visible in the Jira UI.
|
||||
examples: [
|
||||
"RD-123",
|
||||
"TEST-456",
|
||||
"PROJ-789"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The issue key
|
||||
id:
|
||||
type: string
|
||||
description: The issue ID
|
||||
summary:
|
||||
type: string
|
||||
description: The issue summary
|
||||
description:
|
||||
type: string
|
||||
description: The issue description
|
||||
status:
|
||||
type: string
|
||||
description: The current status
|
||||
assignee:
|
||||
type: string
|
||||
description: The assignee display name
|
||||
assignee_account_id:
|
||||
type: string
|
||||
description: The assignee account ID
|
||||
reporter:
|
||||
type: string
|
||||
description: The reporter display name
|
||||
reporter_account_id:
|
||||
type: string
|
||||
description: The reporter account ID
|
||||
issue_type:
|
||||
type: string
|
||||
description: The issue type
|
||||
priority:
|
||||
type: string
|
||||
description: The priority level
|
||||
resolution:
|
||||
type: string
|
||||
description: The resolution
|
||||
resolution_date:
|
||||
type: string
|
||||
description: The resolution date
|
||||
created:
|
||||
type: string
|
||||
description: The creation date
|
||||
updated:
|
||||
type: string
|
||||
description: The last update date
|
||||
due_date:
|
||||
type: string
|
||||
description: The due date
|
||||
labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The issue labels
|
||||
components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The issue components
|
||||
fix_versions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The fix versions
|
||||
project:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description: The project information
|
||||
parent:
|
||||
type: string
|
||||
description: The parent issue key
|
||||
url:
|
||||
type: string
|
||||
description: The issue URL
|
||||
tests:
|
||||
- name: "Get issue by key"
|
||||
description: "Verify issue retrieval returns expected structure"
|
||||
arguments:
|
||||
- key: issue_key
|
||||
value: "RD-15333"
|
||||
@@ -0,0 +1,76 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project
|
||||
description: |
|
||||
Get details for a specific project by its key.
|
||||
Returns comprehensive project information including description, settings, and lead.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The project key
|
||||
name:
|
||||
type: string
|
||||
description: The project name
|
||||
description:
|
||||
type: string
|
||||
description: The project description
|
||||
projectTypeKey:
|
||||
type: string
|
||||
description: The project type key
|
||||
simplified:
|
||||
type: boolean
|
||||
description: Whether the project is simplified
|
||||
style:
|
||||
type: string
|
||||
description: The project style
|
||||
isPrivate:
|
||||
type: boolean
|
||||
description: Whether the project is private
|
||||
archived:
|
||||
type: boolean
|
||||
description: Whether the project is archived
|
||||
lead:
|
||||
type: object
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
emailAddress:
|
||||
type: string
|
||||
accountId:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
description: The project lead information
|
||||
url:
|
||||
type: string
|
||||
description: The project URL
|
||||
tests:
|
||||
- name: "Get project by key"
|
||||
description: "Verify project retrieval returns expected structure"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
@@ -0,0 +1,78 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project_role_users
|
||||
description: |
|
||||
Get users and groups for a specific role in a project.
|
||||
Returns detailed information about users and groups assigned to the role.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project Role Users
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
- name: role_name
|
||||
type: string
|
||||
description: |
|
||||
The name of the role to get users for.
|
||||
Common roles include 'Administrators', 'Developers', 'Users'.
|
||||
examples: [
|
||||
"Administrators",
|
||||
"Developers",
|
||||
"Users"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
project_key:
|
||||
type: string
|
||||
description: The project key
|
||||
role_name:
|
||||
type: string
|
||||
description: The role name
|
||||
role_id:
|
||||
type: string
|
||||
description: The role ID
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
description: List of users in the role
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
groupId:
|
||||
type: string
|
||||
description: List of groups in the role
|
||||
tests:
|
||||
- name: "Get role users"
|
||||
description: "Verify role users returns expected structure"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
- key: role_name
|
||||
value: "Administrators"
|
||||
@@ -0,0 +1,45 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project_roles
|
||||
description: |
|
||||
Get all roles available in a project.
|
||||
Returns a list of roles with their IDs and URLs.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project Roles
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The role name
|
||||
id:
|
||||
type: string
|
||||
description: The role ID
|
||||
tests:
|
||||
- name: "Get project roles"
|
||||
description: "Verify project roles returns array of roles"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
@@ -0,0 +1,53 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_user
|
||||
description: |
|
||||
Get a specific user by their unique account ID.
|
||||
Returns detailed user information including display name, email, and account status.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get User
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: account_id
|
||||
type: string
|
||||
description: |
|
||||
The unique Atlassian account ID for the user.
|
||||
This is typically in the format: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
You can get account IDs from other API calls like get_issue or search_user.
|
||||
examples: [
|
||||
"557058:ab168c94-8485-405c-88e6-6458375eb30b",
|
||||
"5b10ac8d82e05b22cc7d4ef5",
|
||||
"712020:0e99e8b3-7b3a-4b7c-9a1f-9e5d8c7b4a3e"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account ID
|
||||
displayName:
|
||||
type: string
|
||||
description: The display name
|
||||
emailAddress:
|
||||
type: string
|
||||
description: The email address
|
||||
active:
|
||||
type: boolean
|
||||
description: Whether the user is active
|
||||
timeZone:
|
||||
type: string
|
||||
description: The user's time zone
|
||||
tests:
|
||||
- name: "Get user by account ID"
|
||||
description: "Just run the tool"
|
||||
arguments:
|
||||
- key: account_id
|
||||
value: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
@@ -0,0 +1,84 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: jql_query
|
||||
description: |
|
||||
Execute a JQL (Jira Query Language) query to search for issues in your Jira instance.
|
||||
Returns a list of issues with their details.
|
||||
Use the start and limit parameters to paginate through large result sets.
|
||||
type: tool
|
||||
annotations:
|
||||
title: JQL Query
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
The JQL query string to execute. Examples:
|
||||
- "project = TEST" to find all issues in the TEST project
|
||||
- "assignee = currentUser()" to find issues assigned to you
|
||||
- "status = 'In Progress'" to find issues in progress
|
||||
examples: [
|
||||
"project = TEST",
|
||||
"status = 'In Progress'",
|
||||
"project = TEST AND status = 'Done'",
|
||||
"created >= -30d ORDER BY created DESC"
|
||||
]
|
||||
- name: start
|
||||
type: integer
|
||||
description: |
|
||||
The index of the first result to return (0-based).
|
||||
Use this for pagination: start=0 for first page, start=50 for second page, etc.
|
||||
Defaults to 0 if not specified.
|
||||
default: 0
|
||||
examples: [0, 50, 100]
|
||||
- name: limit
|
||||
type: integer
|
||||
description: |
|
||||
Maximum number of results to return.
|
||||
If not specified, returns all matching results.
|
||||
Recommended to use with start parameter for pagination.
|
||||
examples: [50, 100, 200]
|
||||
default: null
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
assignee:
|
||||
type: string
|
||||
reporter:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
updated:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
tests:
|
||||
- name: "Basic project query"
|
||||
description: "Verify JQL query returns array of issues"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "project = RD"
|
||||
- key: limit
|
||||
value: 1
|
||||
- name: "Status filter query"
|
||||
description: "Verify JQL query with status filter"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "status = 'In Progress'"
|
||||
- key: limit
|
||||
value: 1
|
||||
@@ -0,0 +1,42 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: list_projects
|
||||
description: |
|
||||
Return a concise list of Jira projects.
|
||||
Returns basic project information including key, name, type, and lead.
|
||||
type: tool
|
||||
annotations:
|
||||
title: List Projects
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters: []
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The project key
|
||||
name:
|
||||
type: string
|
||||
description: The project name
|
||||
type:
|
||||
type: string
|
||||
description: The project type
|
||||
lead:
|
||||
type: string
|
||||
description: The project lead
|
||||
url:
|
||||
type: string
|
||||
description: The project URL
|
||||
tests:
|
||||
- name: "List all projects"
|
||||
description: "Verify projects list returns array of projects"
|
||||
arguments: []
|
||||
@@ -0,0 +1,56 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: search_user
|
||||
description: |
|
||||
Search for users by query string (username, email, or display name).
|
||||
Returns a list of matching users with their details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Search User
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
Search term - can be username, email, display name, or partial matches.
|
||||
The search is case-insensitive and supports partial matching.
|
||||
examples: [
|
||||
"ben@raw-labs.com",
|
||||
"Benjamin Gaidioz",
|
||||
"ben",
|
||||
"benjamin",
|
||||
"gaidioz"
|
||||
]
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account ID
|
||||
displayName:
|
||||
type: string
|
||||
description: The display name
|
||||
emailAddress:
|
||||
type: string
|
||||
description: The email address
|
||||
active:
|
||||
type: boolean
|
||||
description: Whether the user is active
|
||||
timeZone:
|
||||
type: string
|
||||
description: The user's time zone
|
||||
tests:
|
||||
- name: "Search by name"
|
||||
description: "Verify user search by name returns results"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "Ben"
|
||||
Reference in New Issue
Block a user