Skip to main content
Glama

Shortcut.com MCP Server

by WynnD
server.py26.3 kB
""" MCP Server for Shortcut.com integration. This server provides an interface for accessing and searching Shortcut tickets using the Model Context Protocol (MCP). Run this server with: python -m src.server """ import logging import os import sys # Import sys for more detailed error info if needed # --- Setup File Logging --- # Determine an absolute path for the log file to avoid ambiguity # This will place server_debug.log in the same directory as server.py (i.e., in src/) log_file_dir = os.path.dirname(os.path.abspath(__file__)) log_file_path = os.path.join(log_file_dir, 'server_debug.log') # Configure root logger to catch all logs root_logger = logging.getLogger('') root_logger.setLevel(logging.DEBUG) # Capture DEBUG level and above # Create file handler try: # Use 'w' to overwrite the log file on each run, making it easier to read file_handler = logging.FileHandler(log_file_path, mode='w') file_handler.setLevel(logging.DEBUG) # Log DEBUG and above to file # More detailed formatter file_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(pathname)s:%(lineno)d - %(message)s') file_handler.setFormatter(file_formatter) root_logger.addHandler(file_handler) except Exception as e: # If logging setup fails, print to stderr (which might be visible somewhere or nowhere) print(f"CRITICAL: Failed to configure file logging to {log_file_path}: {e}", file=sys.stderr) # Get a logger for this specific module logger = logging.getLogger(__name__) logger.info(f"--- MCP Shortcut Server script execution started. Logging to: {log_file_path} ---") # --- End File Logging Setup --- # --- Rest of your imports --- try: logger.debug("Attempting to import json, datetime, typing...") import json from datetime import datetime from typing import List, Dict, Any, Optional, Union, Literal logger.debug("Core Python imports successful.") logger.debug("Attempting to import FastMCP...") from fastmcp import FastMCP logger.debug("FastMCP imported successfully.") logger.debug("Attempting to import local modules (.shortcut_client, .utils, .config)...") from .shortcut_client import ShortcutClient logger.debug(".shortcut_client imported.") from .utils import ( StoryType, StorySummary, StoryDetail, Comment, WorkflowState, Project, ErrorResponse, SuccessResponse, create_bug_report_template, create_feature_request_template ) logger.debug(".utils imported.") from . import config logger.debug(".config imported. SHORTCUT_API_TOKEN first 5: {}".format(getattr(config, 'SHORTCUT_API_TOKEN', 'N/A')[:5])) except ImportError as e: logger.error(f"ImportError during server startup: {e}", exc_info=True) # If imports fail, the server likely won't work, so re-raise or exit # For now, we'll let it try to continue to see if mcp object gets created. # In a real scenario, you might want to sys.exit(1) here. except Exception as e: logger.error(f"Generic exception during imports or initial config: {e}", exc_info=True) # --- End Imports --- logger.info("Initializing ShortcutClient...") try: shortcut_client = ShortcutClient() logger.info("ShortcutClient initialized.") if not shortcut_client.api_token: logger.warning("ShortcutClient initialized BUT API TOKEN IS MISSING or empty.") else: logger.info("ShortcutClient appears to have an API token.") except Exception as e: logger.error(f"Exception during ShortcutClient() instantiation: {e}", exc_info=True) # This is critical, server probably can't function # For now, we log and continue to see if FastMCP part errors out logger.info("Initializing FastMCP server object...") try: mcp = FastMCP("Shortcut.com Ticket Manager") logger.info("FastMCP object initialized.") except Exception as e: logger.error(f"Exception during FastMCP() instantiation: {e}", exc_info=True) # Critical if FastMCP object itself fails # Resource implementations @mcp.resource("shortcut://stories?workflow_state_id={workflow_state_id}&project_id={project_id}&limit={limit}") async def get_stories_resource( workflow_state_id: int = None, project_id: int = None, limit: int = 20 ) -> str: """ Access stories from Shortcut as a resource Args: workflow_state_id: Filter by workflow state ID project_id: Filter by project ID limit: Maximum number of stories to return """ params = {} if workflow_state_id: params["workflow_state_id"] = workflow_state_id if project_id: params["project_id"] = project_id stories = await shortcut_client.get_stories_async(params) # Limit the results stories = stories[:limit] if limit > 0 else stories try: # Convert raw stories to validated model objects formatted_stories = [StorySummary.model_validate(story).model_dump() for story in stories] return json.dumps(formatted_stories, indent=2) except Exception as e: logger.error(f"Error formatting stories: {e}") return json.dumps({"error": f"Error formatting stories: {str(e)}"}) @mcp.resource("shortcut://story/{story_id}") async def get_story_resource(story_id: int) -> str: """ Access a specific story from Shortcut as a resource Args: story_id: ID of the story to retrieve """ story = await shortcut_client.get_story_by_id_async(story_id) if not story: return json.dumps({"error": f"Story with ID {story_id} not found"}) try: # Convert raw story to validated model object formatted_story = StoryDetail.model_validate(story).model_dump() return json.dumps(formatted_story, indent=2) except Exception as e: logger.error(f"Error formatting story: {e}") return json.dumps({"error": f"Error formatting story: {str(e)}"}) # Tool implementations @mcp.tool() async def list_stories( workflow_state_id: int = None, project_id: int = None, owner_id: str = None, limit: int = 25 ) -> str: """ List stories from Shortcut with optional filtering. Args: workflow_state_id: Filter by workflow state ID project_id: Filter by project ID owner_id: Filter by owner user ID limit: Maximum number of stories to return (default: 25) Returns: Formatted list of stories """ params = {} if workflow_state_id: params["workflow_state_id"] = workflow_state_id if project_id: params["project_id"] = project_id if owner_id: params["owner_ids[]"] = owner_id stories = await shortcut_client.get_stories_async(params) # Limit the results stories = stories[:limit] if limit > 0 else stories try: # Convert raw stories to validated model objects formatted_stories = [StorySummary.model_validate(story).model_dump() for story in stories] return json.dumps(formatted_stories, indent=2) except Exception as e: logger.error(f"Error formatting stories: {e}") return json.dumps({"error": f"Error formatting stories: {str(e)}"}) @mcp.tool() async def search_stories(query: str, limit: int = 25) -> str: """ Search for stories in Shortcut. Args: query: Search query string limit: Maximum number of stories to return (default: 25) Returns: Formatted search results """ if not query: return json.dumps({"error": "Search query is required"}) stories = await shortcut_client.search_stories_async(query, limit) try: # Convert raw stories to validated model objects formatted_stories = [StorySummary.model_validate(story).model_dump() for story in stories] return json.dumps(formatted_stories, indent=2) except Exception as e: logger.error(f"Error formatting search results: {e}") return json.dumps({"error": f"Error formatting search results: {str(e)}"}) @mcp.tool() async def get_story_details(story_id: int) -> str: """ Get detailed information about a specific story. Args: story_id: The ID of the story to retrieve Returns: Formatted story details """ story = await shortcut_client.get_story_by_id_async(story_id) if not story: return json.dumps({"error": f"Story with ID {story_id} not found"}) try: # Convert raw story to validated model object formatted_story = StoryDetail.model_validate(story).model_dump() return json.dumps(formatted_story, indent=2) except Exception as e: logger.error(f"Error formatting story: {e}") return json.dumps({"error": f"Error formatting story: {str(e)}"}) @mcp.tool() async def create_story( name: str, description: str, story_type: StoryType = "feature", project_id: int = None, workflow_state_id: int = None, owner_ids: List[str] = None, labels: List[str] = None, epic_id: int = None, story_links: List[Dict[str, Any]] = None, group_id: str = None ) -> str: """ Create a new story in Shortcut. Args: name: The name/title of the story description: The description of the story story_type: Type of story (feature, bug, chore) project_id: ID of the project to assign the story to workflow_state_id: ID of the workflow state owner_ids: List of user IDs to assign as owners labels: List of label names to add to the story epic_id: Optional ID of the epic this story belongs to. story_links: Optional list of story links (e.g., [{"verb": "relates to", "object_id": 123}]). group_id: Optional ID of the team/group to assign the story to Returns: Formatted created story details """ # Prepare the story data story_data = { "name": name, "description": description, "story_type": story_type } if project_id is not None: story_data["project_id"] = project_id if workflow_state_id is not None: story_data["workflow_state_id"] = workflow_state_id if owner_ids: story_data["owner_ids"] = owner_ids if labels: story_data["labels"] = [{"name": label} for label in labels] if epic_id is not None: story_data["epic_id"] = epic_id if story_links: story_data["story_links"] = story_links if group_id is not None: story_data["group_id"] = group_id # Create the story created_story = await shortcut_client.create_story_async(story_data) if not created_story: return json.dumps({"error": "Failed to create story"}) try: # Convert raw created story to validated model object formatted_story = StoryDetail.model_validate(created_story).model_dump() return json.dumps(formatted_story, indent=2) except Exception as e: logger.error(f"Error formatting created story: {e}") return json.dumps({"error": f"Error formatting created story: {str(e)}"}) @mcp.tool() async def update_story( story_id: int, name: str = None, description: str = None, story_type: StoryType = None, workflow_state_id: int = None, owner_ids: List[str] = None, epic_id: int = None, story_links: List[Dict[str, Any]] = None, group_id: str = None ) -> str: """ Update an existing story in Shortcut. Args: story_id: ID of the story to update name: New name/title for the story description: New description for the story story_type: New type for the story workflow_state_id: New workflow state ID owner_ids: New list of owner user IDs epic_id: Optional new ID of the epic this story belongs to. Use 'null' via client to remove. story_links: Optional list of story links to add/update. Behavior depends on API (replace vs. merge). group_id: Optional new ID of the team/group to assign the story to Returns: Formatted updated story details """ # Prepare the update data update_data = {} if name is not None: update_data["name"] = name if description is not None: update_data["description"] = description if story_type: update_data["story_type"] = story_type if workflow_state_id is not None: update_data["workflow_state_id"] = workflow_state_id if owner_ids is not None: update_data["owner_ids"] = owner_ids if epic_id is not None: update_data["epic_id"] = epic_id if story_links is not None: update_data["story_links"] = story_links if group_id is not None: update_data["group_id"] = group_id if not update_data: return json.dumps({"error": "No update parameters provided for the story."}) # Update the story updated_story = await shortcut_client.update_story_async(story_id, update_data) if not updated_story: return json.dumps({"error": f"Failed to update story {story_id}"}) try: # Convert raw updated story to validated model object formatted_story = StoryDetail.model_validate(updated_story).model_dump() return json.dumps(formatted_story, indent=2) except Exception as e: logger.error(f"Error formatting updated story: {e}") return json.dumps({"error": f"Error formatting updated story: {str(e)}"}) @mcp.tool() async def add_comment(story_id: int, text: str) -> str: """ Add a comment to a story in Shortcut. Args: story_id: ID of the story to comment on text: Comment text Returns: Status of the comment creation """ comment = await shortcut_client.add_comment_async(story_id, text) if not comment: return json.dumps({"error": f"Failed to add comment to story {story_id}"}) try: # Create validated response success_response = SuccessResponse( success=True, message="Comment added successfully", data=Comment.model_validate(comment).model_dump() ).model_dump() return json.dumps(success_response, indent=2) except Exception as e: logger.error(f"Error formatting comment response: {e}") return json.dumps({"error": f"Error formatting comment response: {str(e)}"}) @mcp.tool() async def list_workflow_states() -> str: """ List all workflow states in the Shortcut workspace. Returns: Formatted list of workflow states """ workflows = await shortcut_client.get_workflow_states_async() try: # Extract workflow states from all workflows all_states = [] for workflow in workflows: for state in workflow.get("states", []): all_states.append(WorkflowState( id=state.get("id"), name=state.get("name"), type=state.get("type"), workflow_id=workflow.get("id"), workflow_name=workflow.get("name") ).model_dump()) return json.dumps(all_states, indent=2) except Exception as e: logger.error(f"Error formatting workflow states: {e}") return json.dumps({"error": f"Error formatting workflow states: {str(e)}"}) @mcp.tool() async def list_projects() -> str: """ List all projects in the Shortcut workspace. Returns: Formatted list of projects """ projects = await shortcut_client.get_projects_async() try: # Convert raw projects to validated model objects formatted_projects = [Project.model_validate(project).model_dump() for project in projects] return json.dumps(formatted_projects, indent=2) except Exception as e: logger.error(f"Error formatting projects: {e}") return json.dumps({"error": f"Error formatting projects: {str(e)}"}) @mcp.tool() async def list_groups() -> str: """ List all groups (teams) in the Shortcut workspace. Returns: Formatted list of groups """ groups = await shortcut_client.get_groups_async() try: # Return groups as-is for now (could add Pydantic model later) return json.dumps(groups, indent=2) except Exception as e: logger.error(f"Error formatting groups: {e}") return json.dumps({"error": f"Error formatting groups: {str(e)}"}) @mcp.tool() async def list_epics( # No specific parameters for basic list, but can add if Shortcut API supports # e.g., archived: bool = None ) -> str: """ List all epics in the Shortcut workspace. Returns: Formatted list of epics as a JSON string. """ logger.debug("Executing list_epics tool") try: epics = await shortcut_client.list_epics_async() # Assuming params are optional or not needed for a general list if epics is None: # list_epics_async might return None on error or empty logger.warning("shortcut_client.list_epics_async returned None") return json.dumps([]) # Return empty list if None # For now, return epics as is. Later, we can add Pydantic models for validation/formatting. # Example: formatted_epics = [EpicSummary.model_validate(epic).model_dump() for epic in epics] logger.info(f"Successfully retrieved {len(epics)} epics.") return json.dumps(epics, indent=2) except Exception as e: logger.error(f"Error in list_epics tool: {e}", exc_info=True) return json.dumps({"error": f"Error listing epics: {str(e)}"}) @mcp.tool() async def create_epic( name: str, description: str = None, owner_ids: List[str] = None, # Add other relevant fields from Shortcut API as needed, e.g.: # external_id: str = None, # milestone_id: int = None, # labels: List[Dict[str, str]] = None, # e.g., [{"name": "epic-label"}] # follower_ids: List[str] = None, # requested_by_id: str = None, ) -> str: """ Create a new epic in Shortcut. Args: name: The name of the epic (required). description: Optional description for the epic. owner_ids: Optional list of UUIDs for owners of this epic. # ... other optional args Returns: Formatted created epic details as a JSON string. """ logger.debug(f"Executing create_epic tool with name: {name}") epic_data = {"name": name} if description is not None: epic_data["description"] = description if owner_ids: epic_data["owner_ids"] = owner_ids # Add other optional fields to epic_data if provided # if labels: epic_data["labels"] = labels try: created_epic = await shortcut_client.create_epic_async(epic_data) if not created_epic or ("error" in created_epic and created_epic.get("details")): error_detail = created_epic.get("details", "Unknown error from client") if isinstance(created_epic, dict) else "Unknown error from client" logger.error(f"Failed to create epic. Client response: {error_detail}") return json.dumps({"error": "Failed to create epic", "details": error_detail}) logger.info(f"Successfully created epic with ID: {created_epic.get('id')}") # For now, return raw epic. Later, use Pydantic model: EpicDetail.model_validate(created_epic).model_dump() return json.dumps(created_epic, indent=2) except Exception as e: logger.error(f"Error in create_epic tool: {e}", exc_info=True) return json.dumps({"error": f"Error creating epic: {str(e)}"}) @mcp.tool() async def get_epic_details(epic_id: int) -> str: """ Get detailed information about a specific epic. Args: epic_id: The ID of the epic to retrieve. Returns: Formatted epic details as a JSON string. """ logger.debug(f"Executing get_epic_details tool for epic_id: {epic_id}") try: epic = await shortcut_client.get_epic_async(epic_id) if not epic or ("error" in epic and epic.get("details")): error_detail = epic.get("details", f"Epic with ID {epic_id} not found or error from client") if isinstance(epic, dict) else f"Epic with ID {epic_id} not found" logger.warning(f"Could not retrieve epic {epic_id}. Client response: {error_detail}") return json.dumps({"error": f"Epic with ID {epic_id} not found", "details": error_detail }) logger.info(f"Successfully retrieved details for epic ID: {epic_id}") # For now, return raw epic. Later, use Pydantic model: EpicDetail.model_validate(epic).model_dump() return json.dumps(epic, indent=2) except Exception as e: logger.error(f"Error in get_epic_details tool for epic_id {epic_id}: {e}", exc_info=True) return json.dumps({"error": f"Error getting epic details: {str(e)}"}) @mcp.tool() async def update_epic( epic_id: int, name: str = None, description: str = None, owner_ids: List[str] = None, # Add other relevant updateable fields from Shortcut API as needed # e.g., milestone_id: int = None, (pass null to unset) # archived: bool = None ) -> str: """ Update an existing epic in Shortcut. Args: epic_id: The ID of the epic to update. name: Optional new name for the epic. description: Optional new description for the epic. owner_ids: Optional new list of owner UUIDs. # ... other optional args Returns: Formatted updated epic details as a JSON string. """ logger.debug(f"Executing update_epic tool for epic_id: {epic_id}") update_data = {} if name is not None: update_data["name"] = name if description is not None: update_data["description"] = description if owner_ids is not None: # Allow empty list to remove all owners update_data["owner_ids"] = owner_ids # Add other fields to update_data if provided if not update_data: return json.dumps({"error": "No update data provided for epic."}) try: updated_epic = await shortcut_client.update_epic_async(epic_id, update_data) if not updated_epic or ("error" in updated_epic and updated_epic.get("details")): error_detail = updated_epic.get("details", "Unknown error from client") if isinstance(updated_epic, dict) else "Unknown error from client" logger.error(f"Failed to update epic {epic_id}. Client response: {error_detail}") return json.dumps({"error": f"Failed to update epic {epic_id}", "details": error_detail}) logger.info(f"Successfully updated epic ID: {epic_id}") # For now, return raw epic. Later, use Pydantic model: EpicDetail.model_validate(updated_epic).model_dump() return json.dumps(updated_epic, indent=2) except Exception as e: logger.error(f"Error in update_epic tool for epic_id {epic_id}: {e}", exc_info=True) return json.dumps({"error": f"Error updating epic: {str(e)}"}) @mcp.tool() async def delete_epic(epic_id: int) -> str: """ Delete an epic from Shortcut. Args: epic_id: The ID of the epic to delete. Returns: Success or error message as a JSON string. """ logger.debug(f"Executing delete_epic tool for epic_id: {epic_id}") try: success = await shortcut_client.delete_epic_async(epic_id) if success: logger.info(f"Successfully deleted epic ID: {epic_id}") return json.dumps({"success": True, "message": f"Epic {epic_id} deleted successfully."}) else: # delete_epic_async in client returns False on error or if client itself had an issue. # It might also return a dict with error from _make_request_async logger.warning(f"Failed to delete epic {epic_id}. Client indicated failure.") return json.dumps({"error": f"Failed to delete epic {epic_id}. Client indicated failure or epic not found."}) except Exception as e: logger.error(f"Error in delete_epic tool for epic_id {epic_id}: {e}", exc_info=True) return json.dumps({"error": f"Error deleting epic: {str(e)}"}) # Prompt templates @mcp.prompt() def create_bug_report(title: str, steps: str, expected: str, actual: str) -> str: """ Create a bug report template Args: title: Bug title steps: Steps to reproduce expected: Expected behavior actual: Actual behavior """ return create_bug_report_template(title, steps, expected, actual) @mcp.prompt() def create_feature_request(title: str, description: str, user_value: str, acceptance_criteria: str) -> str: """ Create a feature request template Args: title: Feature title description: Description of the feature user_value: Value to users acceptance_criteria: Acceptance criteria """ return create_feature_request_template(title, description, user_value, acceptance_criteria) def main(): logger.info("--- main() function called ---") logger.info(f"Attempting to start FastMCP server with mcp.run() for stdio transport") try: if 'mcp' in globals(): # Check if mcp object exists mcp.run() # MODIFIED: Default to stdio transport logger.info("mcp.run() called. Server should be running via stdio if no exceptions occurred.") else: logger.error("FastMCP object 'mcp' not found in globals. Server cannot start.") except KeyboardInterrupt: logger.info("MCP server shutting down due to KeyboardInterrupt...") except Exception as e: logger.error(f"Exception during mcp.run() or server execution: {e}", exc_info=True) if __name__ == "__main__": logger.info(f"--- Script executed with __name__ == '__main__' ---") main() else: logger.info(f"--- Script imported, __name__ is '{__name__}' ---") # Potentially log if FastMCP expects to be run differently when imported by its runner

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/WynnD/mcp-server-shortcut'

If you have feedback or need assistance with the MCP directory API, please join our Discord server