From b9d8073d03ec0b454fa9a41c65b39c38643cb389 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Wed, 2 Jul 2025 15:34:02 +0200 Subject: [PATCH 1/9] JIRA and Salesforce examples rewritten in Python --- examples/jira/README.md | 161 +++--- examples/jira/config.yml | 19 +- examples/jira/mxcp-site.yml | 8 +- .../jira/plugins/mxcp_plugin_jira/__init__.py | 9 - .../jira/plugins/mxcp_plugin_jira/plugin.py | 156 ----- examples/jira/python/jira_endpoints.py | 538 ++++++++++++++++++ examples/jira/sql/get_project.sql | 2 - examples/jira/sql/get_user.sql | 2 - examples/jira/sql/jql.sql | 2 - examples/jira/sql/list_projects.sql | 2 - examples/jira/tools/get_issue.yml | 114 ++++ examples/jira/tools/get_project.yml | 66 ++- .../jira/tools/get_project_role_users.yml | 78 +++ examples/jira/tools/get_project_roles.yml | 45 ++ examples/jira/tools/get_user.yml | 49 +- .../jira/tools/{jql.yml => jql_query.yml} | 50 +- examples/jira/tools/list_projects.yml | 37 +- examples/jira/tools/search_user.yml | 56 ++ examples/salesforce/README.md | 187 +++--- examples/salesforce/config.yml | 17 +- examples/salesforce/mxcp-site.yml | 7 +- .../mxcp_plugin_salesforce/__init__.py | 165 ------ .../salesforce/python/salesforce_endpoints.py | 284 +++++++++ .../salesforce/tools/describe_sobject.yml | 15 +- examples/salesforce/tools/get_sobject.yml | 17 +- examples/salesforce/tools/list_sobjects.yml | 34 +- examples/salesforce/tools/search.yml | 17 +- examples/salesforce/tools/soql.yml | 30 +- examples/salesforce/tools/sosl.yml | 32 +- 29 files changed, 1602 insertions(+), 597 deletions(-) delete mode 100644 examples/jira/plugins/mxcp_plugin_jira/__init__.py delete mode 100644 examples/jira/plugins/mxcp_plugin_jira/plugin.py create mode 100644 examples/jira/python/jira_endpoints.py delete mode 100644 examples/jira/sql/get_project.sql delete mode 100644 examples/jira/sql/get_user.sql delete mode 100644 examples/jira/sql/jql.sql delete mode 100644 examples/jira/sql/list_projects.sql create mode 100644 examples/jira/tools/get_issue.yml create mode 100644 examples/jira/tools/get_project_role_users.yml create mode 100644 examples/jira/tools/get_project_roles.yml rename examples/jira/tools/{jql.yml => jql_query.yml} (59%) create mode 100644 examples/jira/tools/search_user.yml delete mode 100644 examples/salesforce/plugins/mxcp_plugin_salesforce/__init__.py create mode 100644 examples/salesforce/python/salesforce_endpoints.py diff --git a/examples/jira/README.md b/examples/jira/README.md index 04c7a12c..b4dd29af 100644 --- a/examples/jira/README.md +++ b/examples/jira/README.md @@ -1,46 +1,42 @@ -# MXCP Jira Plugin Example +# MXCP Jira Python Endpoints Example -This example demonstrates how to use MXCP with Jira data. It shows how to: -- Create and use a custom MXCP plugin for Jira integration -- Query Jira data using SQL -- Combine Jira data with other data sources +This example demonstrates how to use MXCP with Jira data using Python endpoints. This approach uses Python functions directly as MCP tools. ## Overview -The plugin provides several UDFs that allow you to: +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: +Follow the same process as the plugin example: 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**: +2. **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 Integration") - - Select an expiration date (tokens can last from 1 day to 1 year) + - Enter a descriptive name for your token (e.g., "MXCP Jira Python Integration") + - Select an expiration date - 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 +3. **Copy and save your token** securely ### 2. User Configuration -Add the following to your MXCP user config (`~/.mxcp/config.yml`). You can use the example `config.yml` in this directory as a template: +Add the following to your MXCP user config (`~/.mxcp/config.yml`): ```yaml mxcp: 1 @@ -48,98 +44,93 @@ mxcp: 1 projects: jira-demo: profiles: - dev: - plugin: - config: - jira: + 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 ``` -**Configuration Notes:** -- Replace `your-domain` with your actual Atlassian domain -- Replace `your-email@example.com` with the email address of your Atlassian account -- Replace `your-api-token` with the API token you created in step 1 -- The `password` field should contain your API token, not your actual Atlassian password - -### 2. Site Configuration +### 3. Site Configuration Create an `mxcp-site.yml` file: ```yaml mxcp: 1 project: jira-demo -profile: dev -plugin: - - name: jira - module: mxcp_plugin_jira - config: jira +profile: default +secrets: + - jira ``` ## Available Tools ### JQL Query -```sql --- Execute a JQL query to search issues -SELECT jql_query_jira($jql, $limit) as result; +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 -```sql --- Get user information -SELECT get_user_jira($username) as result; +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 -```sql --- List all projects -SELECT list_projects_jira($project_name) as result; +List all projects: +```bash +mxcp run tool list_projects ``` ### Get Project -```sql --- Get project details -SELECT get_project_jira($project_key) as result; +Get project details: +```bash +mxcp run tool get_project --param project_key="TEST" ``` -## Example Queries - -1. Query issues with their assignees: -```sql -WITH issues AS ( - SELECT * FROM jql_query_jira('project = "PROJ" ORDER BY created DESC', 100) -) -SELECT - i.key as issue_key, - i.fields.summary as summary, - i.fields.assignee.displayName as assignee -FROM issues i; +### Get Project Roles +Get all roles available in a project: +```bash +mxcp run tool get_project_roles --param project_key="TEST" ``` -## Plugin Development - -The `mxcp_plugin_jira` directory contains a complete MXCP plugin implementation that you can use as a reference for creating your own plugins. It demonstrates: - -- Plugin class structure -- Type conversion -- UDF implementation -- Configuration handling - -## Running the Example - -1. Set the `MXCP_CONFIG` environment variable to point to your config file: - ```bash - export MXCP_CONFIG=/path/to/examples/jira/config.yml - ``` - -2. Start the MXCP server: - ```bash - mxcp serve - ``` +### 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" +``` -## Notes +## Project Structure -- Make sure to keep your API token secure and never commit it to version control. -- The plugin requires proper authentication and API permissions to work with your Jira instance. -- All functions return JSON strings containing the requested data. \ No newline at end of file +``` +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 +``` \ No newline at end of file diff --git a/examples/jira/config.yml b/examples/jira/config.yml index 25cfb89d..e81a555c 100644 --- a/examples/jira/config.yml +++ b/examples/jira/config.yml @@ -1,12 +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: - dev: - plugin: - config: - jira: - url: "https://your-domain.atlassian.net" - username: "your-email@example.com" - password: "your-api-token" \ No newline at end of file + default: + secrets: + - name: "jira" + type: "python" + parameters: + url: "${JIRA_URL}" + username: "${JIRA_USERNAME}" + password: "${JIRA_API_TOKEN}" + diff --git a/examples/jira/mxcp-site.yml b/examples/jira/mxcp-site.yml index 3121afb7..581f427a 100644 --- a/examples/jira/mxcp-site.yml +++ b/examples/jira/mxcp-site.yml @@ -1,7 +1,5 @@ mxcp: 1 project: jira-demo -profile: dev -plugin: - - name: jira - module: mxcp_plugin_jira - config: jira +profile: default +secrets: + - jira diff --git a/examples/jira/plugins/mxcp_plugin_jira/__init__.py b/examples/jira/plugins/mxcp_plugin_jira/__init__.py deleted file mode 100644 index 47d8f456..00000000 --- a/examples/jira/plugins/mxcp_plugin_jira/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -MXCP Jira Plugin - -This plugin provides UDFs for querying Atlassian Jira using JQL. -""" - -from .plugin import MXCPPlugin - -__all__ = ["MXCPPlugin"] diff --git a/examples/jira/plugins/mxcp_plugin_jira/plugin.py b/examples/jira/plugins/mxcp_plugin_jira/plugin.py deleted file mode 100644 index d5ef7c5b..00000000 --- a/examples/jira/plugins/mxcp_plugin_jira/plugin.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Jira Plugin Implementation - -This module provides UDFs for querying Atlassian Jira using JQL. -""" - -import json -import logging -from typing import Any, Dict, List, Optional - -from atlassian import Jira - -from mxcp.plugins import MXCPBasePlugin, udf - -logger = logging.getLogger(__name__) - - -class MXCPPlugin(MXCPBasePlugin): - """Jira plugin that provides JQL query functionality.""" - - def __init__(self, config: Dict[str, Any]): - """Initialize the Jira plugin. - - Args: - config: Plugin configuration containing Jira API credentials - Required keys: - - url: The base URL of your Jira instance - - username: Your Jira username/email - - password: Your Jira API token/password - """ - super().__init__(config) - self.url = config.get("url", "") - self.username = config.get("username", "") - self.password = config.get("password", "") - - if not all([self.url, self.username, self.password]): - raise ValueError("Jira plugin requires url, username, and password in configuration") - - # Initialize Jira client - self.jira = Jira(url=self.url, username=self.username, password=self.password, cloud=True) - - @udf - def jql_query(self, query: str, start: Optional[int] = 0, limit: Optional[int] = None) -> str: - """Execute a JQL query against Jira. - - Args: - query: The JQL query string - start: Starting index for pagination (default: 0) - limit: Maximum number of results to return (default: None, meaning no limit) - - Returns: - JSON string containing Jira issues matching the query - """ - logger.info("Executing JQL query: %s with start=%s, limit=%s", query, start, limit) - - raw = self.jira.jql( - jql=query, - start=start, - limit=limit, - fields=( - "key,summary,status,resolution,resolutiondate," - "assignee,reporter,issuetype,priority," - "created,updated,labels,fixVersions,parent" - ), - ) - - 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]] = [] - 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"{self.url}/browse/{issue['key']}", # web UI URL - } - ) - - return json.dumps(cleaned) - - @udf - def get_user(self, username: str) -> str: - """Get details for a specific user by username. - - Args: - username: The username to search for - - Returns: - JSON string containing the user details - """ - logger.info("Getting user details for username: %s", username) - return json.dumps(self.jira.user_find_by_user_string(query=username)) - - @udf - def list_projects(self) -> str: - """ - Return a concise list of Jira projects. - """ - logger.info("Listing all projects") - - raw_projects: List[Dict[str, Any]] = self.jira.projects() - - 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]] = [] - 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"{self.url}/projects/{p.get('key')}", # web UI URL - } - ) - - return json.dumps(concise) - - @udf - def get_project(self, project_key: str) -> str: - """Get details for a specific project by its key. - - Args: - project_key: The project key (e.g., 'TEST' for project TEST) - - Returns: - JSON string containing the project details - """ - logger.info("Getting project details for key: %s", project_key) - info = self.jira.project(project_key) - # remove the self key if it exists - if "self" in info: - info.pop("self") - # Add web UI URL - info["url"] = f"{self.url}/projects/{project_key}" - return json.dumps(info) diff --git a/examples/jira/python/jira_endpoints.py b/examples/jira/python/jira_endpoints.py new file mode 100644 index 00000000..88915019 --- /dev/null +++ b/examples/jira/python/jira_endpoints.py @@ -0,0 +1,538 @@ +""" +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(): + """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(): + """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) -> Callable: + """ + 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, **kwargs): + 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] = 0, 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: 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, + limit=limit, + fields=( + "key,summary,status,resolution,resolutiondate," + "assignee,reporter,issuetype,priority," + "created,updated,labels,fixVersions,parent" + ), + ) + + 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, key, default=None): + """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 + + diff --git a/examples/jira/sql/get_project.sql b/examples/jira/sql/get_project.sql deleted file mode 100644 index 605d237a..00000000 --- a/examples/jira/sql/get_project.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Get details for a specific Jira project -SELECT get_project_jira($project_key) as result; \ No newline at end of file diff --git a/examples/jira/sql/get_user.sql b/examples/jira/sql/get_user.sql deleted file mode 100644 index 7066eeb6..00000000 --- a/examples/jira/sql/get_user.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Get details for a specific Jira user -SELECT get_user_jira($username) as result; \ No newline at end of file diff --git a/examples/jira/sql/jql.sql b/examples/jira/sql/jql.sql deleted file mode 100644 index 79967ba8..00000000 --- a/examples/jira/sql/jql.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Example JQL query endpoint -SELECT jql_query_jira($query, $start, $limit) as result; \ No newline at end of file diff --git a/examples/jira/sql/list_projects.sql b/examples/jira/sql/list_projects.sql deleted file mode 100644 index d786f0ee..00000000 --- a/examples/jira/sql/list_projects.sql +++ /dev/null @@ -1,2 +0,0 @@ --- List all projects in Jira -SELECT list_projects_jira() as result; diff --git a/examples/jira/tools/get_issue.yml b/examples/jira/tools/get_issue.yml new file mode 100644 index 00000000..7d115e77 --- /dev/null +++ b/examples/jira/tools/get_issue.yml @@ -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" diff --git a/examples/jira/tools/get_project.yml b/examples/jira/tools/get_project.yml index b0609b96..282d5fef 100644 --- a/examples/jira/tools/get_project.yml +++ b/examples/jira/tools/get_project.yml @@ -3,30 +3,74 @@ mxcp: 1 tool: name: get_project description: | - Get details for a specific project in your Jira instance by its project key. - Returns a JSON string containing the project's details. + Get details for a specific project by its key. + Returns comprehensive project information including description, settings, and lead. type: tool annotations: - title: Get Project Details + 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 to search for. This is the short identifier for the project (e.g., 'TEST' for project TEST). - Project keys are typically uppercase and contain only letters and numbers. + The project key (e.g., 'TEST' for project TEST). + This is the short identifier for the project. examples: [ "TEST", "PROJ", "DEV" ] return: - type: string - description: | - A JSON string containing the project's details. - language: "sql" - source: - file: "../sql/get_project.sql" + 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" diff --git a/examples/jira/tools/get_project_role_users.yml b/examples/jira/tools/get_project_role_users.yml new file mode 100644 index 00000000..f8219179 --- /dev/null +++ b/examples/jira/tools/get_project_role_users.yml @@ -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" diff --git a/examples/jira/tools/get_project_roles.yml b/examples/jira/tools/get_project_roles.yml new file mode 100644 index 00000000..b9f5a6d2 --- /dev/null +++ b/examples/jira/tools/get_project_roles.yml @@ -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" diff --git a/examples/jira/tools/get_user.yml b/examples/jira/tools/get_user.yml index bdeb41a5..80dc3fa9 100644 --- a/examples/jira/tools/get_user.yml +++ b/examples/jira/tools/get_user.yml @@ -3,28 +3,51 @@ mxcp: 1 tool: name: get_user description: | - Get details for a specific user in your Jira instance by their username. - Returns a JSON string containing the user's details. + 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 Details + title: Get User readOnlyHint: true destructiveHint: false idempotentHint: true openWorldHint: true + language: python + source: + file: ../python/jira_endpoints.py parameters: - - name: username + - name: account_id type: string description: | - The username to search for. This is typically the user's email address or username in Jira. + 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: [ - "john.doe@example.com", - "jane.smith" + "557058:ab168c94-8485-405c-88e6-6458375eb30b", + "5b10ac8d82e05b22cc7d4ef5", + "712020:0e99e8b3-7b3a-4b7c-9a1f-9e5d8c7b4a3e" ] return: - type: string - description: | - A JSON string containing the user's details. - language: "sql" - source: - file: "../sql/get_user.sql" + 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" diff --git a/examples/jira/tools/jql.yml b/examples/jira/tools/jql_query.yml similarity index 59% rename from examples/jira/tools/jql.yml rename to examples/jira/tools/jql_query.yml index cb6190b8..f5135ebd 100644 --- a/examples/jira/tools/jql.yml +++ b/examples/jira/tools/jql_query.yml @@ -1,10 +1,10 @@ mxcp: 1 tool: - name: jql + name: jql_query description: | Execute a JQL (Jira Query Language) query to search for issues in your Jira instance. - Returns a JSON string containing the matching issues with their details. + Returns a list of issues with their details. Use the start and limit parameters to paginate through large result sets. type: tool annotations: @@ -13,6 +13,9 @@ tool: destructiveHint: false idempotentHint: true openWorldHint: true + language: python + source: + file: ../python/jira_endpoints.py parameters: - name: query type: string @@ -33,6 +36,7 @@ tool: 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 @@ -41,10 +45,40 @@ tool: If not specified, returns all matching results. Recommended to use with start parameter for pagination. examples: [50, 100, 200] + default: null return: - type: string - description: | - A JSON string containing an array of Jira issues. - language: "sql" - source: - file: "../sql/jql.sql" + 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 diff --git a/examples/jira/tools/list_projects.yml b/examples/jira/tools/list_projects.yml index 62f59b58..d0eab95e 100644 --- a/examples/jira/tools/list_projects.yml +++ b/examples/jira/tools/list_projects.yml @@ -3,8 +3,8 @@ mxcp: 1 tool: name: list_projects description: | - List all projects in your Jira instance. - Returns a JSON string containing an array of projects with their details. + Return a concise list of Jira projects. + Returns basic project information including key, name, type, and lead. type: tool annotations: title: List Projects @@ -12,10 +12,31 @@ tool: destructiveHint: false idempotentHint: true openWorldHint: true - return: - type: string - description: | - A JSON string containing an array of Jira projects. - language: "sql" + language: python source: - file: "../sql/list_projects.sql" + 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: [] diff --git a/examples/jira/tools/search_user.yml b/examples/jira/tools/search_user.yml new file mode 100644 index 00000000..551f1794 --- /dev/null +++ b/examples/jira/tools/search_user.yml @@ -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" diff --git a/examples/salesforce/README.md b/examples/salesforce/README.md index bac95699..c5e2b8f3 100644 --- a/examples/salesforce/README.md +++ b/examples/salesforce/README.md @@ -1,112 +1,115 @@ -# MXCP Salesforce Example - -This example demonstrates how to use MXCP with Salesforce data. It shows how to: -- Create and use a custom MXCP plugin for Salesforce integration -- Query Salesforce data using SQL -- Combine Salesforce data with other data sources - -## Setup - -1. Configure your Salesforce credentials: - Add the following to your MXCP user config (`~/.mxcp/config.yml`). You can use the example `config.yml` in this directory as a template: - - ```yaml - mxcp: 1 - - projects: - salesforce-demo: - profiles: - dev: - plugin: - config: - salesforce: - instance_url: "https://your-instance.salesforce.com" - username: "your-username@example.com" - password: "your-password" - security_token: "your-security-token" - client_id: "your-client-id" - ``` - -2. Create an `mxcp-site.yml` file: - - ```yaml - mxcp: 1 - project: salesforce-demo - profile: dev - plugin: - - name: salesforce - module: mxcp_plugin_salesforce - config: salesforce - ``` - -3. Start the MXCP server: - ```bash - mxcp serve - ``` +# MXCP Salesforce Python Endpoints Example -## Available Tools +This example demonstrates how to use MXCP with Salesforce data using **Python endpoints**. -The example provides several tools for interacting with Salesforce: +## Overview -### List Objects -```sql --- List all available Salesforce objects -SELECT list_sobjects_salesforce() as result; -``` +This example provides Python MCP endpoints that allow you to: +- Execute SOQL queries to retrieve Salesforce data +- Execute SOSL searches across multiple objects +- List all available Salesforce objects +- Get detailed object descriptions +- Retrieve specific records by ID +- Perform simple text searches across common objects -### Describe Object -```sql --- Get field descriptions for an object -SELECT describe_sobject_salesforce($object_name) as result; +## Configuration + +### 1. Getting Salesforce Credentials + +To use this example, you'll need: + +1. **Salesforce Username**: Your Salesforce username (email address) +2. **Salesforce Password**: Your Salesforce password +3. **Security Token**: Your Salesforce security token (get from Setup → My Personal Information → Reset My Security Token) +4. **Instance URL**: Your Salesforce instance URL (e.g., https://your-domain.salesforce.com) +5. **Client ID**: A connected app client ID (you can use any valid client ID) + +### 2. User Configuration + +Add the following to your MXCP user config (`~/.mxcp/config.yml`): + +```yaml +mxcp: 1 + +projects: + salesforce-demo: + profiles: + dev: + secrets: + salesforce: + instance_url: "https://your-instance.salesforce.com" + username: "your-username@example.com" + password: "your-password" + security_token: "your-security-token" + client_id: "your-client-id" ``` -### Get Object -```sql --- Get a specific record -SELECT get_sobject_salesforce($object_name, $record_id) as result; +### 3. Site Configuration + +Create an `mxcp-site.yml` file: + +```yaml +mxcp: 1 +project: salesforce-demo +profile: dev +secrets: + - salesforce + +extensions: + - json ``` +## Available Tools + ### SOQL Query -```sql --- Execute a SOQL query -SELECT soql_salesforce($query) as result; +Execute SOQL queries directly as Python function calls: +```bash +mxcp run tool soql --param query="SELECT Id, Name FROM Account LIMIT 10" ``` ### SOSL Search -```sql --- Execute a SOSL search -SELECT sosl_salesforce($query) as result; +Execute SOSL searches across multiple objects: +```bash +mxcp run tool sosl --param query="FIND {Acme} IN ALL FIELDS RETURNING Account(Name, Phone)" ``` -## Example Queries - -1. Query accounts with their contacts: -```sql -WITH accounts AS ( - SELECT * FROM soql_salesforce('SELECT Id, Name FROM Account') -), -contacts AS ( - SELECT * FROM soql_salesforce('SELECT Id, Name, AccountId FROM Contact') -) -SELECT - a.Name as account_name, - ARRAY_AGG(c.Name) as contact_names -FROM accounts a -LEFT JOIN contacts c ON c.AccountId = a.Id -GROUP BY a.Name; +### Simple Search +Perform simple text searches across common objects: +```bash +mxcp run tool search --param search_term="Acme" ``` -## Plugin Development +### List Objects +List all available Salesforce objects: +```bash +mxcp run tool list_sobjects +``` -The `mxcp_plugin_salesforce` directory contains a complete MXCP plugin implementation that you can use as a reference for creating your own plugins. It demonstrates: +### Describe Object +Get detailed information about a specific object: +```bash +mxcp run tool describe_sobject --param sobject_name="Account" +``` -- Plugin class structure -- Type conversion -- UDF implementation -- Configuration handling +### Get Object +Get a specific record by its ID: +```bash +mxcp run tool get_sobject --param sobject_name="Account" --param record_id="001xx000003DIloAAG" +``` -## Notes +## Project Structure -- Make sure to keep your Salesforce credentials secure and never commit them to version control. -- The plugin requires proper authentication and API permissions to work with your Salesforce instance. -- All functions return JSON strings containing the requested data. \ No newline at end of file +``` +salesforce/ +├── mxcp-site.yml # Site configuration +├── python/ # Python implementations +│ └── salesforce_endpoints.py # All Salesforce endpoint functions +├── tools/ # Tool definitions +│ ├── soql.yml +│ ├── sosl.yml +│ ├── search.yml +│ ├── list_sobjects.yml +│ ├── describe_sobject.yml +│ └── get_sobject.yml +└── README.md +``` \ No newline at end of file diff --git a/examples/salesforce/config.yml b/examples/salesforce/config.yml index add5ff4b..ca02a834 100644 --- a/examples/salesforce/config.yml +++ b/examples/salesforce/config.yml @@ -4,11 +4,12 @@ projects: salesforce-demo: profiles: dev: - plugin: - config: - salesforce: - instance_url: "https://your-instance.salesforce.com" - username: "your-username@example.com" - password: "your-password" - security_token: "your-security-token" - client_id: "your-client-id" \ No newline at end of file + secrets: + - name: salesforce + type: python + parameters: + instance_url: "${SALESFORCE_URL}" + username: "${SALESFORCE_USERNAME}" + password: "${SALESFORCE_PASSWORD}" + security_token: "${SALESFORCE_TOKEN}" + client_id: "${SALESFORCE_CLIENT_ID}" diff --git a/examples/salesforce/mxcp-site.yml b/examples/salesforce/mxcp-site.yml index 20e21691..41d85716 100644 --- a/examples/salesforce/mxcp-site.yml +++ b/examples/salesforce/mxcp-site.yml @@ -1,11 +1,8 @@ mxcp: 1 project: salesforce-demo profile: dev +secrets: + - salesforce extensions: - json - -plugin: - - name: salesforce - module: mxcp_plugin_salesforce - config: salesforce diff --git a/examples/salesforce/plugins/mxcp_plugin_salesforce/__init__.py b/examples/salesforce/plugins/mxcp_plugin_salesforce/__init__.py deleted file mode 100644 index 4743506f..00000000 --- a/examples/salesforce/plugins/mxcp_plugin_salesforce/__init__.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Salesforce Plugin for MXCP - -This plugin provides integration with Salesforce, allowing you to query and manipulate Salesforce data through SQL. -It uses simple_salesforce for authentication and API calls. - -Example usage: - >>> plugin = MXCPPlugin({ - "username": "user@example.com", - "password": "password", - "security_token": "token", - "instance_url": "https://instance.salesforce.com", - "client_id": "client_id" - }) - >>> plugin.soql("SELECT Id, Name FROM Account") # Returns list of accounts -""" - -import json -from typing import Any, Dict, List, Optional, TypedDict - -import simple_salesforce - -from mxcp.plugins import MXCPBasePlugin, udf - - -class SalesforceRecord(TypedDict): - """Type definition for a Salesforce record.""" - - Id: str - Name: str - # Add other common fields as needed - - -class MXCPPlugin(MXCPBasePlugin): - """Plugin that provides Salesforce integration functions. - - This plugin allows you to interact with Salesforce data through SQL queries, - providing functions to execute SOQL queries, list objects, and retrieve - object descriptions. - - Example: - >>> plugin = MXCPPlugin({ - "username": "user@example.com", - "password": "password", - "security_token": "token", - "instance_url": "https://instance.salesforce.com", - "client_id": "client_id" - }) - >>> plugin.soql("SELECT Id, Name FROM Account") # Returns list of accounts - """ - - def __init__(self, config: Dict[str, Any]): - """Initialize the plugin with configuration. - - Args: - config: Configuration dictionary containing: - - username: Salesforce username - - password: Salesforce password - - security_token: Salesforce security token - - instance_url: Salesforce instance URL - - client_id: Salesforce client ID (required for authentication) - """ - super().__init__(config) - self.sf = simple_salesforce.Salesforce( - username=config["username"], - password=config["password"], - security_token=config["security_token"], - instance_url=config["instance_url"], - client_id=config["client_id"], - ) - - @udf - def soql(self, query: str) -> List[Dict[str, str]]: - """Execute an SOQL query against Salesforce. - - Args: - query: The SOQL query to execute - - Returns: - List of records returned by the query, with 'attributes' field removed - - Example: - >>> plugin.soql("SELECT Id, Name FROM Account") - """ - result = self.sf.query(query) - # remove 'attributes' field from each record - return [{k: v for k, v in r.items() if k != "attributes"} for r in result["records"]] - - @udf - def sosl(self, query: str) -> str: - """Execute a SOSL query against Salesforce. - - Args: - query: The SOSL query to execute - - Returns: - JSON string containing the search results from searchRecords - - Example: - >>> plugin.sosl("FIND {Acme} IN ALL FIELDS RETURNING Account(Name), Contact(FirstName,LastName)") - """ - result = self.sf.search(query) - return json.dumps(result["searchRecords"]) - - @udf - def search(self, search_term: str) -> str: - """Search across all Salesforce objects using a simple search term. - - Args: - search_term: The term to search for - - Returns: - JSON string containing the search results - - Example: - >>> plugin.search("Acme") # Searches for "Acme" across all objects - """ - # Build a SOSL query that searches across common objects - sosl_query = f"FIND {{{search_term}}} IN ALL FIELDS RETURNING Account(Name, Phone, BillingCity), Contact(FirstName, LastName, Email), Lead(FirstName, LastName, Company), Opportunity(Name, Amount, StageName)" - return self.sosl(sosl_query) - - @udf - def list_sobjects(self) -> List[str]: - """Get a list of all available Salesforce objects. - - Returns: - List of object names from the org - - Example: - >>> plugin.list_sobjects() # Returns ['Account', 'Contact', ...] - """ - return [obj["name"] for obj in self.sf.describe()["sobjects"]] - - @udf - def describe_sobject(self, type_name: str) -> str: - """Get the description of a Salesforce object type. - - Args: - type_name: The name of the Salesforce object type - - Returns: - JSON string containing the object's field descriptions - - Example: - >>> plugin.describe_sobject("Account") - """ - result = self.sf.__getattr__(type_name).describe() - return json.dumps(result) - - @udf - def get_sobject(self, type_name: str, id: str) -> str: - """Get a specific Salesforce object by its ID. - - Args: - type_name: The name of the Salesforce object type - id: The Salesforce ID of the object - - Returns: - JSON string containing the object's field values - - Example: - >>> plugin.get_sobject("Account", "001xx000003DIloAAG") - """ - result = self.sf.__getattr__(type_name).get(id) - return json.dumps(result) diff --git a/examples/salesforce/python/salesforce_endpoints.py b/examples/salesforce/python/salesforce_endpoints.py new file mode 100644 index 00000000..3e5d926e --- /dev/null +++ b/examples/salesforce/python/salesforce_endpoints.py @@ -0,0 +1,284 @@ +""" +Salesforce Python Endpoints + +This module provides direct Python MCP endpoints for querying Salesforce. +This is a simpler alternative to the plugin-based approach. +""" + +from typing import Dict, Any, List, Optional, Callable +import logging +import time +import functools +import threading +import simple_salesforce +from simple_salesforce.exceptions import SalesforceExpiredSession + +from mxcp.runtime import config, on_init, on_shutdown + +logger = logging.getLogger(__name__) + +# Global Salesforce client for reuse across all function calls +sf_client: Optional[simple_salesforce.Salesforce] = None +# Thread lock to protect client initialization +_client_lock = threading.Lock() + + +@on_init +def setup_salesforce_client(): + """Initialize Salesforce client when server starts. + + Thread-safe: multiple threads can safely call this simultaneously. + """ + global sf_client + + with _client_lock: + logger.info("Initializing Salesforce client...") + + sf_config = config.get_secret("salesforce") + if not sf_config: + raise ValueError("Salesforce configuration not found. Please configure Salesforce secrets in your user config.") + + required_keys = ["username", "password", "security_token", "instance_url", "client_id"] + missing_keys = [key for key in required_keys if not sf_config.get(key)] + if missing_keys: + raise ValueError(f"Missing Salesforce configuration keys: {', '.join(missing_keys)}") + + sf_client = simple_salesforce.Salesforce( + username=sf_config["username"], + password=sf_config["password"], + security_token=sf_config["security_token"], + instance_url=sf_config["instance_url"], + client_id=sf_config["client_id"] + ) + + logger.info("Salesforce client initialized successfully") + + +@on_shutdown +def cleanup_salesforce_client(): + """Clean up Salesforce client when server stops.""" + global sf_client + if sf_client: + # Salesforce client doesn't need explicit cleanup, but we'll clear the reference + sf_client = None + logger.info("Salesforce client cleaned up") + + +def retry_on_session_expiration(func: Callable) -> Callable: + """ + Decorator that automatically retries functions on session expiration. + + This only retries on SalesforceExpiredSession, not SalesforceAuthenticationFailed. + Authentication failures (wrong credentials) should not be retried. + + Retries up to 2 times on session expiration (3 total attempts). + Thread-safe: setup_salesforce_client() handles concurrent access internally. + + Usage: + @retry_on_session_expiration + def my_salesforce_function(): + # Function that might fail due to session expiration + pass + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + max_retries = 2 # Hardcoded: 2 retries = 3 total attempts + + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except SalesforceExpiredSession as 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_salesforce_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 Salesforce 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 + + return wrapper + + +def _get_salesforce_client() -> simple_salesforce.Salesforce: + """Get the global Salesforce client.""" + if sf_client is None: + raise RuntimeError("Salesforce client not initialized. Make sure the server is started properly.") + return sf_client + + +@retry_on_session_expiration +def soql(query: str) -> List[Dict[str, Any]]: + """Execute an SOQL query against Salesforce. + + Args: + query: The SOQL query to execute + + Returns: + List of records returned by the query, with 'attributes' field removed + + Example: + >>> soql("SELECT Id, Name FROM Account") + """ + logger.info("Executing SOQL query: %s", query) + + sf = _get_salesforce_client() + result = sf.query(query) + + # Remove 'attributes' field from each record for cleaner output + return [ + {k: v for k, v in record.items() if k != 'attributes'} + for record in result['records'] + ] + + +@retry_on_session_expiration +def sosl(query: str) -> List[Dict[str, Any]]: + """Execute a SOSL query against Salesforce. + + Args: + query: The SOSL query to execute + + Returns: + List of search results from searchRecords + + Example: + >>> sosl("FIND {Acme} IN ALL FIELDS RETURNING Account(Name), Contact(FirstName,LastName)") + """ + logger.info("Executing SOSL query: %s", query) + + sf = _get_salesforce_client() + result = sf.search(query) + + # Return the searchRecords directly as a list + return result.get('searchRecords', []) + + +@retry_on_session_expiration +def search(search_term: str) -> List[Dict[str, Any]]: + """Search across all Salesforce objects using a simple search term. + + Args: + search_term: The term to search for + + Returns: + List of search results + + Example: + >>> search("Acme") # Searches for "Acme" across all objects + """ + logger.info("Searching for term: %s", search_term) + + # Build a SOSL query that searches across common objects + sosl_query = f"FIND {{{search_term}}} IN ALL FIELDS RETURNING Account(Name, Phone, BillingCity), Contact(FirstName, LastName, Email), Lead(FirstName, LastName, Company), Opportunity(Name, Amount, StageName)" + + return sosl(sosl_query) + + +@retry_on_session_expiration +def list_sobjects(filter: Optional[str] = None) -> List[str]: + """List all available Salesforce objects (sObjects) in the org. + + Args: + filter: Optional fuzzy filter to match object names (case-insensitive substring search). + Examples: "account", "__c" for custom objects, "contact", etc. + + Returns: + list: List of Salesforce object names as strings + """ + sf = _get_salesforce_client() + describe_result = sf.describe() + + object_names = [obj['name'] for obj in describe_result['sobjects']] + + if filter is not None and filter.strip(): + filter_lower = filter.lower() + object_names = [ + name for name in object_names + if filter_lower in name.lower() + ] + + object_names.sort() + return object_names + + +@retry_on_session_expiration +def describe_sobject(sobject_name: str) -> Dict[str, Any]: + """Get the description of a Salesforce object type. + + Args: + sobject_name: The name of the Salesforce object type + + Returns: + Dictionary containing the object's field descriptions + + Example: + >>> describe_sobject("Account") + """ + logger.info("Describing Salesforce object: %s", sobject_name) + + sf = _get_salesforce_client() + + # Try to get the object - catch this specifically for "object doesn't exist" + try: + sobject = getattr(sf, sobject_name) + except AttributeError: + raise Exception(f"Salesforce object '{sobject_name}' does not exist") + + # Let API errors from describe() propagate naturally with their original messages + describe_result = sobject.describe() + + # Process fields into the required format + fields_info = {} + for field in describe_result['fields']: + field_name = field['name'] + field_info = { + 'type': field['type'], + 'label': field['label'] + } + + # Add referenceTo information for reference fields + if field['type'] == 'reference' and field.get('referenceTo'): + field_info['referenceTo'] = field['referenceTo'] + + fields_info[field_name] = field_info + + return fields_info + + +@retry_on_session_expiration +def get_sobject(sobject_name: str, record_id: str) -> Dict[str, Any]: + """Get a specific Salesforce object by its ID. + + Args: + sobject_name: The name of the Salesforce object type + record_id: The Salesforce ID of the object + + Returns: + Dictionary containing the object's field values + + Example: + >>> get_sobject("Account", "001xx000003DIloAAG") + """ + logger.info("Getting Salesforce object: %s with ID: %s", sobject_name, record_id) + + sf = _get_salesforce_client() + + # Try to get the object - catch this specifically for "object doesn't exist" + try: + sobject = getattr(sf, sobject_name) + except AttributeError: + raise Exception(f"Salesforce object '{sobject_name}' does not exist") + + result = sobject.get(record_id) + + # Remove 'attributes' field for consistency with other functions + if isinstance(result, dict) and 'attributes' in result: + result = {k: v for k, v in result.items() if k != 'attributes'} + + return result \ No newline at end of file diff --git a/examples/salesforce/tools/describe_sobject.yml b/examples/salesforce/tools/describe_sobject.yml index 7580b822..bfb9e3ed 100644 --- a/examples/salesforce/tools/describe_sobject.yml +++ b/examples/salesforce/tools/describe_sobject.yml @@ -3,6 +3,9 @@ mxcp: 1 tool: name: "describe_sobject" description: "Use this tool to get detailed information about a specific Salesforce object's structure. This is crucial for understanding what fields are available, their types, and their properties. The tool returns comprehensive metadata including field types, picklist values, required fields, and field-level security settings. Use this before querying an object to ensure you're using valid field names and to understand the data types. For example, use this to discover all available fields on an Account or to find the valid values for a picklist field." + language: python + source: + file: ../python/salesforce_endpoints.py parameters: - name: sobject_name type: string @@ -12,7 +15,13 @@ tool: - "Contact" - "Opportunity" - "CustomObject__c" - source: - code: SELECT describe_sobject_salesforce($sobject_name); + return: + type: object annotations: - readOnlyHint: true \ No newline at end of file + readOnlyHint: true + tests: + - name: "Describe Account object" + description: "Verify Account object description contains expected fields" + arguments: + - key: sobject_name + value: "Account" diff --git a/examples/salesforce/tools/get_sobject.yml b/examples/salesforce/tools/get_sobject.yml index 955b675d..be2ce6b1 100644 --- a/examples/salesforce/tools/get_sobject.yml +++ b/examples/salesforce/tools/get_sobject.yml @@ -3,6 +3,9 @@ mxcp: 1 tool: name: "get_sobject" description: "Use this tool when you have a specific Salesforce record ID and need to retrieve all fields for that record. This is ideal for getting complete details about a known record, like when you have an Account ID from a previous query and need all its information. The tool requires both the object type (e.g., 'Account', 'Contact') and the record's unique ID. This is different from search or SOQL queries which find records based on field values - this tool is for direct record lookup by ID." + language: python + source: + file: ../python/salesforce_endpoints.py parameters: - name: sobject_name type: string @@ -17,7 +20,15 @@ tool: examples: - "001xx000003DIloAAG" - "003xx000004TmiAAE" - source: - code: SELECT get_sobject_salesforce($sobject_name, $record_id); + return: + type: object annotations: - readOnlyHint: true \ No newline at end of file + readOnlyHint: true + tests: + - name: "Get Account record structure" + description: "Verify Account record has expected fields like Id, Name" + arguments: + - key: sobject_name + value: "Account" + - key: record_id + value: "001Qy00000pxRDKIA2" diff --git a/examples/salesforce/tools/list_sobjects.yml b/examples/salesforce/tools/list_sobjects.yml index 1cccb873..9cfeaff2 100644 --- a/examples/salesforce/tools/list_sobjects.yml +++ b/examples/salesforce/tools/list_sobjects.yml @@ -3,8 +3,36 @@ mxcp: 1 tool: name: "list_sobjects" description: "Use this tool to get a list of all Salesforce object names available in your org. This is essential for exploring your Salesforce instance and understanding what data you can access. The tool returns a simple list of object names (e.g., ['Account', 'Contact', 'Opportunity']). Use this before using other tools to ensure you're using valid object names. For example, use this to find custom objects in your org or to verify the exact spelling of standard objects. If you need detailed information about a specific object's structure, use the describe_sobject tool instead." - parameters: [] + language: python source: - code: SELECT list_sobjects_salesforce(); + file: ../python/salesforce_endpoints.py + parameters: + - name: filter + type: string + description: "Optional fuzzy filter to match object names (case-insensitive substring search). Examples: 'account', '__c' for custom objects, 'contact', etc. If not provided, all objects are returned." + examples: + - "account" + - "__c" + - "contact" + default: null + return: + type: array + items: + type: string annotations: - readOnlyHint: true \ No newline at end of file + readOnlyHint: true + tests: + - name: "Contains standard objects" + description: "Verify standard Salesforce objects are present" + arguments: [] + result_contains_all: + - "Account" + - "Contact" + - "Opportunity" + - name: "filter" + description: "Verify the filter is applied" + arguments: + - key: filter + value: "count" + result_contains_all: + - "Account" diff --git a/examples/salesforce/tools/search.yml b/examples/salesforce/tools/search.yml index d110b475..d51ff7fe 100644 --- a/examples/salesforce/tools/search.yml +++ b/examples/salesforce/tools/search.yml @@ -3,6 +3,9 @@ mxcp: 1 tool: name: "search" description: "Use this tool when you want to quickly search for records across multiple Salesforce objects without knowing the exact field names. It's perfect for finding records by company names, people names, or locations. The search is case-insensitive and will match partial words. For example, searching for 'Acme' will find 'Acme Corp', 'Acme Inc', etc. across Account, Contact, Lead, and Opportunity records. If you need more control over which fields to search or which objects to include, use the sosl tool instead." + language: python + source: + file: ../python/salesforce_endpoints.py parameters: - name: search_term type: string @@ -11,7 +14,15 @@ tool: - "Acme" - "John Smith" - "New York" - source: - code: SELECT search_salesforce($search_term); + return: + type: array + items: + type: object annotations: - readOnlyHint: true \ No newline at end of file + readOnlyHint: true + tests: + - name: "Basic search" + description: "Verify search returns array of results" + arguments: + - key: search_term + value: "Test" diff --git a/examples/salesforce/tools/soql.yml b/examples/salesforce/tools/soql.yml index 323f763c..e51474ff 100644 --- a/examples/salesforce/tools/soql.yml +++ b/examples/salesforce/tools/soql.yml @@ -3,6 +3,9 @@ mxcp: 1 tool: name: "soql" description: "Use this tool when you need to query specific fields from a single Salesforce object, similar to SQL. It's ideal for getting structured data like 'all accounts in New York' or 'all contacts for a specific account'. SOQL is more precise than search or SOSL because you specify exactly which fields you want. For example, use this when you need to get a list of accounts with their phone numbers and addresses, or when you need to find all opportunities over a certain amount. If you need to search across multiple objects or don't know the exact field names, use the search or sosl tools instead." + language: python + source: + file: ../python/salesforce_endpoints.py parameters: - name: query type: string @@ -11,7 +14,30 @@ tool: - "SELECT Id, Name FROM Account" - "SELECT Id, Name, Email FROM Contact WHERE AccountId = '001xx000003DIloAAG'" - "SELECT Id, Name, Amount, StageName FROM Opportunity WHERE IsWon = true ORDER BY Amount DESC LIMIT 5" - source: - code: SELECT soql_salesforce($query); + return: + type: array + items: + type: object annotations: readOnlyHint: true + tests: + - name: "Basic Account query" + description: "Verify SOQL query returns array of Account records" + arguments: + - key: query + value: "SELECT Id, Name FROM Account LIMIT 1" + - name: "Query with LIMIT" + description: "Verify SOQL query respects LIMIT clause" + arguments: + - key: query + value: "SELECT Id FROM Account LIMIT 2" + - name: "Contact query structure" + description: "Verify Contact query returns expected fields" + arguments: + - key: query + value: "SELECT Id, FirstName, LastName FROM Contact LIMIT 1" + - name: "Results should not contain attributes" + description: "Verify attributes field is filtered out from results" + arguments: + - key: query + value: "SELECT Id, Name FROM Account LIMIT 1" diff --git a/examples/salesforce/tools/sosl.yml b/examples/salesforce/tools/sosl.yml index ba1b0faa..5b00d094 100644 --- a/examples/salesforce/tools/sosl.yml +++ b/examples/salesforce/tools/sosl.yml @@ -3,6 +3,9 @@ mxcp: 1 tool: name: "sosl" description: "Use this tool when you need advanced search capabilities across multiple Salesforce objects. It's perfect for complex search scenarios like 'find all records containing this text in any field' or 'search only in name fields across accounts and contacts'. SOSL gives you fine-grained control over which objects to search and which fields to return. For example, use this when you need to find all records mentioning a specific product across accounts, contacts, and opportunities. If you just need a simple search, use the search tool instead. If you need to query specific fields from a single object, use the soql tool instead." + language: python + source: + file: ../python/salesforce_endpoints.py parameters: - name: query type: string @@ -11,7 +14,30 @@ tool: - "FIND {Acme} IN ALL FIELDS RETURNING Account(Name, Phone)" - "FIND {John} IN NAME FIELDS RETURNING Contact(FirstName, LastName, Email)" - "FIND {New York} IN ALL FIELDS RETURNING Account(Name, BillingCity), Lead(Company, City)" - source: - code: SELECT sosl_salesforce($query); + return: + type: array + items: + type: object annotations: - readOnlyHint: true \ No newline at end of file + readOnlyHint: true + tests: + - name: "Basic SOSL search" + description: "Verify SOSL search returns array of search results" + arguments: + - key: query + value: "FIND {Test} IN ALL FIELDS RETURNING Account(Name)" + - name: "Multi-object search" + description: "Verify SOSL can search across multiple objects" + arguments: + - key: query + value: "FIND {Test} IN ALL FIELDS RETURNING Account(Name), Contact(FirstName, LastName)" + - name: "Name field search" + description: "Verify SOSL can search in specific fields" + arguments: + - key: query + value: "FIND {Test} IN NAME FIELDS RETURNING Account(Name)" + - name: "Search with specific fields" + description: "Verify SOSL returns specified fields" + arguments: + - key: query + value: "FIND {Test} IN ALL FIELDS RETURNING Account(Name, Phone)" From e1d5c65bb434f1d65e6324a4370dc02b6a2d5e41 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Thu, 14 Aug 2025 09:46:10 +0200 Subject: [PATCH 2/9] ruff --- examples/jira/python/jira_endpoints.py | 290 ++++++++++-------- .../salesforce/python/salesforce_endpoints.py | 152 ++++----- 2 files changed, 235 insertions(+), 207 deletions(-) diff --git a/examples/jira/python/jira_endpoints.py b/examples/jira/python/jira_endpoints.py index 88915019..6a7854b0 100644 --- a/examples/jira/python/jira_endpoints.py +++ b/examples/jira/python/jira_endpoints.py @@ -24,30 +24,32 @@ @on_init def setup_jira_client(): """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.") - + 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 + cloud=True, ) - + logger.info("JIRA client initialized successfully") @@ -64,21 +66,22 @@ def cleanup_jira_client(): def retry_on_session_expiration(func: Callable) -> Callable: """ 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, **kwargs): max_retries = 2 # Hardcoded: 2 retries = 3 total attempts - + for attempt in range(max_retries + 1): try: return func(*args, **kwargs) @@ -86,9 +89,13 @@ def wrapper(*args, **kwargs): # 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})") - + 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 @@ -101,25 +108,31 @@ def wrapper(*args, **kwargs): 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" - ]): + if any( + phrase in error_msg + for phrase in [ + "session expired", + "session invalid", + "authentication failed", + "invalid session", + "session timeout", + ] + ): return True - + return False @@ -131,7 +144,9 @@ def _get_jira_client() -> Jira: @retry_on_session_expiration -def jql_query(query: str, start: Optional[int] = 0, limit: Optional[int] = None) -> List[Dict[str, Any]]: +def jql_query( + query: str, start: Optional[int] = 0, limit: Optional[int] = None +) -> List[Dict[str, Any]]: """Execute a JQL query against Jira. Args: @@ -145,7 +160,7 @@ def jql_query(query: str, start: Optional[int] = 0, limit: Optional[int] = None) 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, @@ -166,27 +181,29 @@ def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]: 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 - }) + 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 @@ -200,20 +217,20 @@ def get_issue(issue_key: str) -> Dict[str, Any]: 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', {}) + fields = issue.get("fields", {}) jira_url = jira.url - + def _safe_get(obj, key, default=None): """Safely get a value from a dict/object that might be None.""" if obj is None: @@ -221,35 +238,43 @@ def _safe_get(obj, key, default=None): 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 [], + "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') + "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')}" + "parent": _safe_get(fields.get("parent"), "key"), + "url": f"{jira_url}/browse/{issue.get('key')}", } - + return cleaned_issue @@ -263,23 +288,23 @@ def get_user(account_id: str) -> Dict[str, Any]: 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") + "timeZone": user.get("timeZone"), } @@ -296,31 +321,33 @@ def search_user(query: str) -> List[Dict[str, Any]]: """ 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") - }) - + 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 """ @@ -334,15 +361,17 @@ def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]: 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 - }) + 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 @@ -356,13 +385,13 @@ def get_project(project_key: str) -> Dict[str, Any]: 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: @@ -375,7 +404,7 @@ def get_project(project_key: str) -> Dict[str, Any]: 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"), @@ -385,20 +414,20 @@ def get_project(project_key: str) -> Dict[str, Any]: "simplified": info.get("simplified"), "style": info.get("style"), "isPrivate": info.get("isPrivate"), - "archived": info.get("archived") + "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") + "active": info["lead"].get("active"), } - + cleaned_info["url"] = f"{jira.url}/projects/{project_key}" - + return cleaned_info @@ -411,29 +440,26 @@ def get_project_roles(project_key: str) -> List[Dict[str, Any]]: 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 - }) - + + 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() @@ -456,36 +482,38 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]: 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}") - + 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": [] + "groups": [], } - + # Process actors (role_details is a list of actors) if isinstance(role_details, list): for actor in role_details: @@ -495,14 +523,14 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]: # Individual user user_info = { "accountId": actor.get("actorUser", {}).get("accountId"), - "displayName": actor.get("displayName") + "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") + "groupId": actor.get("actorGroup", {}).get("groupId"), } result["groups"].append(group_info) else: @@ -514,16 +542,16 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]: "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 @@ -533,6 +561,6 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]: 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 - - + raise ValueError( + f"Error retrieving users for role '{role_name}' in project '{project_key}': {e}" + ) from e diff --git a/examples/salesforce/python/salesforce_endpoints.py b/examples/salesforce/python/salesforce_endpoints.py index 3e5d926e..ddba9458 100644 --- a/examples/salesforce/python/salesforce_endpoints.py +++ b/examples/salesforce/python/salesforce_endpoints.py @@ -26,31 +26,33 @@ @on_init def setup_salesforce_client(): """Initialize Salesforce client when server starts. - + Thread-safe: multiple threads can safely call this simultaneously. """ global sf_client - + with _client_lock: logger.info("Initializing Salesforce client...") - + sf_config = config.get_secret("salesforce") if not sf_config: - raise ValueError("Salesforce configuration not found. Please configure Salesforce secrets in your user config.") - + raise ValueError( + "Salesforce configuration not found. Please configure Salesforce secrets in your user config." + ) + required_keys = ["username", "password", "security_token", "instance_url", "client_id"] missing_keys = [key for key in required_keys if not sf_config.get(key)] if missing_keys: raise ValueError(f"Missing Salesforce configuration keys: {', '.join(missing_keys)}") - + sf_client = simple_salesforce.Salesforce( username=sf_config["username"], password=sf_config["password"], security_token=sf_config["security_token"], instance_url=sf_config["instance_url"], - client_id=sf_config["client_id"] + client_id=sf_config["client_id"], ) - + logger.info("Salesforce client initialized successfully") @@ -67,31 +69,36 @@ def cleanup_salesforce_client(): def retry_on_session_expiration(func: Callable) -> Callable: """ Decorator that automatically retries functions on session expiration. - + This only retries on SalesforceExpiredSession, not SalesforceAuthenticationFailed. Authentication failures (wrong credentials) should not be retried. - + Retries up to 2 times on session expiration (3 total attempts). Thread-safe: setup_salesforce_client() handles concurrent access internally. - + Usage: @retry_on_session_expiration def my_salesforce_function(): # Function that might fail due to session expiration pass """ + @functools.wraps(func) def wrapper(*args, **kwargs): max_retries = 2 # Hardcoded: 2 retries = 3 total attempts - + for attempt in range(max_retries + 1): try: return func(*args, **kwargs) except SalesforceExpiredSession as 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})") - + 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_salesforce_client() # Thread-safe internally time.sleep(0.1) # Small delay to avoid immediate retry @@ -101,108 +108,104 @@ def wrapper(*args, **kwargs): else: # Last attempt failed, re-raise the session expiration error raise e - + return wrapper def _get_salesforce_client() -> simple_salesforce.Salesforce: """Get the global Salesforce client.""" if sf_client is None: - raise RuntimeError("Salesforce client not initialized. Make sure the server is started properly.") + raise RuntimeError( + "Salesforce client not initialized. Make sure the server is started properly." + ) return sf_client @retry_on_session_expiration def soql(query: str) -> List[Dict[str, Any]]: """Execute an SOQL query against Salesforce. - + Args: query: The SOQL query to execute - + Returns: List of records returned by the query, with 'attributes' field removed - + Example: >>> soql("SELECT Id, Name FROM Account") """ logger.info("Executing SOQL query: %s", query) - + sf = _get_salesforce_client() result = sf.query(query) - + # Remove 'attributes' field from each record for cleaner output - return [ - {k: v for k, v in record.items() if k != 'attributes'} - for record in result['records'] - ] + return [{k: v for k, v in record.items() if k != "attributes"} for record in result["records"]] @retry_on_session_expiration def sosl(query: str) -> List[Dict[str, Any]]: """Execute a SOSL query against Salesforce. - + Args: query: The SOSL query to execute - + Returns: List of search results from searchRecords - + Example: >>> sosl("FIND {Acme} IN ALL FIELDS RETURNING Account(Name), Contact(FirstName,LastName)") """ logger.info("Executing SOSL query: %s", query) - + sf = _get_salesforce_client() result = sf.search(query) - + # Return the searchRecords directly as a list - return result.get('searchRecords', []) + return result.get("searchRecords", []) @retry_on_session_expiration def search(search_term: str) -> List[Dict[str, Any]]: """Search across all Salesforce objects using a simple search term. - + Args: search_term: The term to search for - + Returns: List of search results - + Example: >>> search("Acme") # Searches for "Acme" across all objects """ logger.info("Searching for term: %s", search_term) - + # Build a SOSL query that searches across common objects sosl_query = f"FIND {{{search_term}}} IN ALL FIELDS RETURNING Account(Name, Phone, BillingCity), Contact(FirstName, LastName, Email), Lead(FirstName, LastName, Company), Opportunity(Name, Amount, StageName)" - + return sosl(sosl_query) @retry_on_session_expiration def list_sobjects(filter: Optional[str] = None) -> List[str]: """List all available Salesforce objects (sObjects) in the org. - + Args: filter: Optional fuzzy filter to match object names (case-insensitive substring search). Examples: "account", "__c" for custom objects, "contact", etc. - + Returns: list: List of Salesforce object names as strings """ sf = _get_salesforce_client() describe_result = sf.describe() - - object_names = [obj['name'] for obj in describe_result['sobjects']] - + + object_names = [obj["name"] for obj in describe_result["sobjects"]] + if filter is not None and filter.strip(): filter_lower = filter.lower() - object_names = [ - name for name in object_names - if filter_lower in name.lower() - ] - + object_names = [name for name in object_names if filter_lower in name.lower()] + object_names.sort() return object_names @@ -210,75 +213,72 @@ def list_sobjects(filter: Optional[str] = None) -> List[str]: @retry_on_session_expiration def describe_sobject(sobject_name: str) -> Dict[str, Any]: """Get the description of a Salesforce object type. - + Args: sobject_name: The name of the Salesforce object type - + Returns: Dictionary containing the object's field descriptions - + Example: >>> describe_sobject("Account") """ logger.info("Describing Salesforce object: %s", sobject_name) - + sf = _get_salesforce_client() - + # Try to get the object - catch this specifically for "object doesn't exist" try: sobject = getattr(sf, sobject_name) except AttributeError: raise Exception(f"Salesforce object '{sobject_name}' does not exist") - + # Let API errors from describe() propagate naturally with their original messages describe_result = sobject.describe() - + # Process fields into the required format fields_info = {} - for field in describe_result['fields']: - field_name = field['name'] - field_info = { - 'type': field['type'], - 'label': field['label'] - } - + for field in describe_result["fields"]: + field_name = field["name"] + field_info = {"type": field["type"], "label": field["label"]} + # Add referenceTo information for reference fields - if field['type'] == 'reference' and field.get('referenceTo'): - field_info['referenceTo'] = field['referenceTo'] - + if field["type"] == "reference" and field.get("referenceTo"): + field_info["referenceTo"] = field["referenceTo"] + fields_info[field_name] = field_info - + return fields_info @retry_on_session_expiration def get_sobject(sobject_name: str, record_id: str) -> Dict[str, Any]: """Get a specific Salesforce object by its ID. - + Args: sobject_name: The name of the Salesforce object type record_id: The Salesforce ID of the object - + Returns: Dictionary containing the object's field values - + Example: >>> get_sobject("Account", "001xx000003DIloAAG") """ logger.info("Getting Salesforce object: %s with ID: %s", sobject_name, record_id) - + sf = _get_salesforce_client() - + # Try to get the object - catch this specifically for "object doesn't exist" try: sobject = getattr(sf, sobject_name) except AttributeError: raise Exception(f"Salesforce object '{sobject_name}' does not exist") - + result = sobject.get(record_id) - + # Remove 'attributes' field for consistency with other functions - if isinstance(result, dict) and 'attributes' in result: - result = {k: v for k, v in result.items() if k != 'attributes'} - - return result \ No newline at end of file + if isinstance(result, dict) and "attributes" in result: + result = {k: v for k, v in result.items() if k != "attributes"} + + return result From e764b23a5fdd40332889cf8d53f7ad01064914b6 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Thu, 14 Aug 2025 09:47:48 +0200 Subject: [PATCH 3/9] black --- examples/jira/python/jira_endpoints.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/jira/python/jira_endpoints.py b/examples/jira/python/jira_endpoints.py index 6a7854b0..aee80741 100644 --- a/examples/jira/python/jira_endpoints.py +++ b/examples/jira/python/jira_endpoints.py @@ -257,16 +257,16 @@ def _safe_get(obj, key, default=None): "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 [], + "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"), From b82430181b16343daad2234c81a2e3b9a541cf9f Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Thu, 14 Aug 2025 10:05:54 +0200 Subject: [PATCH 4/9] mypy salesforce --- .../salesforce/python/salesforce_endpoints.py | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/examples/salesforce/python/salesforce_endpoints.py b/examples/salesforce/python/salesforce_endpoints.py index ddba9458..0a0b24bd 100644 --- a/examples/salesforce/python/salesforce_endpoints.py +++ b/examples/salesforce/python/salesforce_endpoints.py @@ -10,7 +10,7 @@ import time import functools import threading -import simple_salesforce +from simple_salesforce import Salesforce # type: ignore[attr-defined] from simple_salesforce.exceptions import SalesforceExpiredSession from mxcp.runtime import config, on_init, on_shutdown @@ -18,13 +18,13 @@ logger = logging.getLogger(__name__) # Global Salesforce client for reuse across all function calls -sf_client: Optional[simple_salesforce.Salesforce] = None +sf_client: Optional[Salesforce] = None # Thread lock to protect client initialization _client_lock = threading.Lock() @on_init -def setup_salesforce_client(): +def setup_salesforce_client() -> None: """Initialize Salesforce client when server starts. Thread-safe: multiple threads can safely call this simultaneously. @@ -45,7 +45,7 @@ def setup_salesforce_client(): if missing_keys: raise ValueError(f"Missing Salesforce configuration keys: {', '.join(missing_keys)}") - sf_client = simple_salesforce.Salesforce( + sf_client = Salesforce( username=sf_config["username"], password=sf_config["password"], security_token=sf_config["security_token"], @@ -57,7 +57,7 @@ def setup_salesforce_client(): @on_shutdown -def cleanup_salesforce_client(): +def cleanup_salesforce_client() -> None: """Clean up Salesforce client when server stops.""" global sf_client if sf_client: @@ -66,7 +66,7 @@ def cleanup_salesforce_client(): logger.info("Salesforce client cleaned up") -def retry_on_session_expiration(func: Callable) -> Callable: +def retry_on_session_expiration(func: Callable[..., Any]) -> Callable[..., Any]: """ Decorator that automatically retries functions on session expiration. @@ -84,7 +84,7 @@ def my_salesforce_function(): """ @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: max_retries = 2 # Hardcoded: 2 retries = 3 total attempts for attempt in range(max_retries + 1): @@ -112,7 +112,7 @@ def wrapper(*args, **kwargs): return wrapper -def _get_salesforce_client() -> simple_salesforce.Salesforce: +def _get_salesforce_client() -> Salesforce: """Get the global Salesforce client.""" if sf_client is None: raise RuntimeError( @@ -140,7 +140,11 @@ def soql(query: str) -> List[Dict[str, Any]]: result = sf.query(query) # Remove 'attributes' field from each record for cleaner output - return [{k: v for k, v in record.items() if k != "attributes"} for record in result["records"]] + if "records" not in result: + raise ValueError(f"Unexpected SOQL response format: missing 'records' field in {result}") + + records = result["records"] + return [{k: v for k, v in record.items() if k != "attributes"} for record in records] @retry_on_session_expiration @@ -162,7 +166,11 @@ def sosl(query: str) -> List[Dict[str, Any]]: result = sf.search(query) # Return the searchRecords directly as a list - return result.get("searchRecords", []) + if "searchRecords" not in result: + raise ValueError(f"Unexpected SOSL response format: missing 'searchRecords' field in {result}") + + search_records: List[Dict[str, Any]] = result["searchRecords"] + return search_records @retry_on_session_expiration @@ -183,7 +191,8 @@ def search(search_term: str) -> List[Dict[str, Any]]: # Build a SOSL query that searches across common objects sosl_query = f"FIND {{{search_term}}} IN ALL FIELDS RETURNING Account(Name, Phone, BillingCity), Contact(FirstName, LastName, Email), Lead(FirstName, LastName, Company), Opportunity(Name, Amount, StageName)" - return sosl(sosl_query) + result: List[Dict[str, Any]] = sosl(sosl_query) + return result @retry_on_session_expiration @@ -200,7 +209,21 @@ def list_sobjects(filter: Optional[str] = None) -> List[str]: sf = _get_salesforce_client() describe_result = sf.describe() - object_names = [obj["name"] for obj in describe_result["sobjects"]] + if not describe_result: + raise ValueError("Salesforce describe() returned empty result") + + if "sobjects" not in describe_result: + raise ValueError(f"Unexpected describe response format: missing 'sobjects' field in {describe_result}") + + sobjects = describe_result["sobjects"] + object_names = [] + + for obj in sobjects: + if not isinstance(obj, dict): + raise ValueError(f"Unexpected sobject format: expected dict, got {type(obj)}: {obj}") + if "name" not in obj: + raise ValueError(f"Sobject missing 'name' field: {obj}") + object_names.append(obj["name"]) if filter is not None and filter.strip(): filter_lower = filter.lower() @@ -231,14 +254,28 @@ def describe_sobject(sobject_name: str) -> Dict[str, Any]: try: sobject = getattr(sf, sobject_name) except AttributeError: - raise Exception(f"Salesforce object '{sobject_name}' does not exist") + raise ValueError(f"Salesforce object '{sobject_name}' does not exist") # Let API errors from describe() propagate naturally with their original messages describe_result = sobject.describe() + if not describe_result: + raise ValueError(f"Salesforce object '{sobject_name}' describe() returned empty result") + + if "fields" not in describe_result: + raise ValueError(f"Unexpected describe response format for '{sobject_name}': missing 'fields' field in {describe_result}") + # Process fields into the required format fields_info = {} for field in describe_result["fields"]: + if not isinstance(field, dict): + raise ValueError(f"Unexpected field format in '{sobject_name}': expected dict, got {type(field)}: {field}") + + required_fields = ["name", "type", "label"] + for required_field in required_fields: + if required_field not in field: + raise ValueError(f"Field missing '{required_field}' in '{sobject_name}': {field}") + field_name = field["name"] field_info = {"type": field["type"], "label": field["label"]} @@ -273,12 +310,13 @@ def get_sobject(sobject_name: str, record_id: str) -> Dict[str, Any]: try: sobject = getattr(sf, sobject_name) except AttributeError: - raise Exception(f"Salesforce object '{sobject_name}' does not exist") + raise ValueError(f"Salesforce object '{sobject_name}' does not exist") result = sobject.get(record_id) # Remove 'attributes' field for consistency with other functions if isinstance(result, dict) and "attributes" in result: - result = {k: v for k, v in result.items() if k != "attributes"} + cleaned_result: Dict[str, Any] = {k: v for k, v in result.items() if k != "attributes"} + return cleaned_result - return result + return dict(result) if result else {} From 12227f781e30b35511a0b72aa6c268d6f022b908 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Thu, 14 Aug 2025 11:06:14 +0200 Subject: [PATCH 5/9] mypy jira --- examples/jira/python/jira_endpoints.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/jira/python/jira_endpoints.py b/examples/jira/python/jira_endpoints.py index aee80741..c0aa6ad9 100644 --- a/examples/jira/python/jira_endpoints.py +++ b/examples/jira/python/jira_endpoints.py @@ -22,7 +22,7 @@ @on_init -def setup_jira_client(): +def setup_jira_client() -> None: """Initialize JIRA client when server starts. Thread-safe: multiple threads can safely call this simultaneously. @@ -54,7 +54,7 @@ def setup_jira_client(): @on_shutdown -def cleanup_jira_client(): +def cleanup_jira_client() -> None: """Clean up JIRA client when server stops.""" global jira_client if jira_client: @@ -63,7 +63,7 @@ def cleanup_jira_client(): logger.info("JIRA client cleaned up") -def retry_on_session_expiration(func: Callable) -> Callable: +def retry_on_session_expiration(func: Callable[..., Any]) -> Callable[..., Any]: """ Decorator that automatically retries functions on JIRA session expiration. @@ -79,7 +79,7 @@ def my_jira_function(): """ @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: max_retries = 2 # Hardcoded: 2 retries = 3 total attempts for attempt in range(max_retries + 1): @@ -145,13 +145,13 @@ def _get_jira_client() -> Jira: @retry_on_session_expiration def jql_query( - query: str, start: Optional[int] = 0, limit: Optional[int] = None + 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: 0) + start: Starting index for pagination (default: None, which becomes 0) limit: Maximum number of results to return (default: None, meaning no limit) Returns: @@ -163,7 +163,7 @@ def jql_query( raw = jira.jql( jql=query, - start=start, + start=start if start is not None else 0, limit=limit, fields=( "key,summary,status,resolution,resolutiondate," @@ -172,6 +172,9 @@ def jql_query( ), ) + 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 @@ -231,7 +234,7 @@ def get_issue(issue_key: str) -> Dict[str, Any]: fields = issue.get("fields", {}) jira_url = jira.url - def _safe_get(obj, key, default=None): + 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 From 2d95c04b87521cca8077bbb55de690da28a222b4 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Thu, 14 Aug 2025 11:20:38 +0200 Subject: [PATCH 6/9] ruff --- .../salesforce/python/salesforce_endpoints.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/examples/salesforce/python/salesforce_endpoints.py b/examples/salesforce/python/salesforce_endpoints.py index 0a0b24bd..e911c28d 100644 --- a/examples/salesforce/python/salesforce_endpoints.py +++ b/examples/salesforce/python/salesforce_endpoints.py @@ -142,7 +142,7 @@ def soql(query: str) -> List[Dict[str, Any]]: # Remove 'attributes' field from each record for cleaner output if "records" not in result: raise ValueError(f"Unexpected SOQL response format: missing 'records' field in {result}") - + records = result["records"] return [{k: v for k, v in record.items() if k != "attributes"} for record in records] @@ -167,8 +167,10 @@ def sosl(query: str) -> List[Dict[str, Any]]: # Return the searchRecords directly as a list if "searchRecords" not in result: - raise ValueError(f"Unexpected SOSL response format: missing 'searchRecords' field in {result}") - + raise ValueError( + f"Unexpected SOSL response format: missing 'searchRecords' field in {result}" + ) + search_records: List[Dict[str, Any]] = result["searchRecords"] return search_records @@ -211,13 +213,15 @@ def list_sobjects(filter: Optional[str] = None) -> List[str]: if not describe_result: raise ValueError("Salesforce describe() returned empty result") - + if "sobjects" not in describe_result: - raise ValueError(f"Unexpected describe response format: missing 'sobjects' field in {describe_result}") - + raise ValueError( + f"Unexpected describe response format: missing 'sobjects' field in {describe_result}" + ) + sobjects = describe_result["sobjects"] object_names = [] - + for obj in sobjects: if not isinstance(obj, dict): raise ValueError(f"Unexpected sobject format: expected dict, got {type(obj)}: {obj}") @@ -261,21 +265,25 @@ def describe_sobject(sobject_name: str) -> Dict[str, Any]: if not describe_result: raise ValueError(f"Salesforce object '{sobject_name}' describe() returned empty result") - + if "fields" not in describe_result: - raise ValueError(f"Unexpected describe response format for '{sobject_name}': missing 'fields' field in {describe_result}") + raise ValueError( + f"Unexpected describe response format for '{sobject_name}': missing 'fields' field in {describe_result}" + ) # Process fields into the required format fields_info = {} for field in describe_result["fields"]: if not isinstance(field, dict): - raise ValueError(f"Unexpected field format in '{sobject_name}': expected dict, got {type(field)}: {field}") - + raise ValueError( + f"Unexpected field format in '{sobject_name}': expected dict, got {type(field)}: {field}" + ) + required_fields = ["name", "type", "label"] for required_field in required_fields: if required_field not in field: raise ValueError(f"Field missing '{required_field}' in '{sobject_name}': {field}") - + field_name = field["name"] field_info = {"type": field["type"], "label": field["label"]} From 9ded341059abff79db797524cc9e8b47e4dc15b5 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Tue, 19 Aug 2025 14:37:58 +0200 Subject: [PATCH 7/9] fixup! ruff wip --- examples/salesforce/mxcp-site.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/salesforce/mxcp-site.yml b/examples/salesforce/mxcp-site.yml index 41d85716..da362c79 100644 --- a/examples/salesforce/mxcp-site.yml +++ b/examples/salesforce/mxcp-site.yml @@ -3,6 +3,3 @@ project: salesforce-demo profile: dev secrets: - salesforce - -extensions: - - json From 785df182d13603f1bf2ef9c01a5d96ae855fe0d4 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Tue, 19 Aug 2025 14:38:35 +0200 Subject: [PATCH 8/9] fixup! fixup! ruff wip --- examples/salesforce/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/salesforce/README.md b/examples/salesforce/README.md index c5e2b8f3..dbabbc75 100644 --- a/examples/salesforce/README.md +++ b/examples/salesforce/README.md @@ -54,9 +54,6 @@ project: salesforce-demo profile: dev secrets: - salesforce - -extensions: - - json ``` ## Available Tools From b46b9ca21b52211e4151df54903440909dcec548 Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Tue, 19 Aug 2025 14:53:16 +0200 Subject: [PATCH 9/9] reset warning about tokens --- examples/jira/README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/examples/jira/README.md b/examples/jira/README.md index b4dd29af..029fb870 100644 --- a/examples/jira/README.md +++ b/examples/jira/README.md @@ -22,17 +22,26 @@ This example uses Python functions that are exposed as MCP tools: ### 1. Creating an Atlassian API Token -Follow the same process as the plugin example: +**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. **Create the API token**: +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 + - Select an expiration date (tokens can last from 1 day to 1 year) - Click **"Create"** -3. **Copy and save your token** securely +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