Taiga MCP Bridge

by talhaorak
Verified
# server_fastmcp.py import logging import logging.config import uuid from typing import Optional, List, Dict, Any from mcp.server.fastmcp import FastMCP from pytaigaclient.exceptions import TaigaException # Assuming taiga_client.py is in the same directory or accessible via PYTHONPATH from src.taiga_client import TaigaClientWrapper # --- Logging Setup --- logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler() # Log to stderr by default ] ) logger = logging.getLogger(__name__) # Quiet down pytaigaclient library logging if needed logging.getLogger("pytaigaclient").setLevel(logging.WARNING) # --- Manual Session Management --- # Store active sessions: session_id -> TaigaClientWrapper instance active_sessions: Dict[str, TaigaClientWrapper] = {} # --- MCP Server Definition --- # No lifespan needed for this approach mcp = FastMCP( "Taiga Bridge (Session ID)", dependencies=["pytaigaclient"] ) # --- Helper Function for Session Validation --- def _get_authenticated_client(session_id: str) -> TaigaClientWrapper: """ Retrieves the authenticated TaigaClientWrapper for a given session ID. Raises PermissionError if the session is invalid or not found. """ client = active_sessions.get(session_id) # Also check if the client object itself exists and is authenticated if not client or not client.is_authenticated: logger.warning(f"Invalid or expired session ID provided: {session_id}") # Raise PermissionError - FastMCP will map this to an appropriate error response raise PermissionError( f"Invalid or expired session ID: '{session_id}'. Please login again.") logger.debug(f"Retrieved valid client for session ID: {session_id}") return client # --- MCP Tools --- @mcp.tool("login", description="Logs into a Taiga instance using username/password and returns a session_id for subsequent authenticated calls.") def login(host: str, username: str, password: str) -> Dict[str, str]: """ Handles Taiga login and creates a session. Args: host: The URL of the Taiga instance (e.g., 'https://tree.taiga.io'). username: The Taiga username. password: The Taiga password. Returns: A dictionary containing the session_id upon successful login. Example: {"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"} """ logger.info(f"Executing login tool for user '{username}' on host '{host}'") try: wrapper = TaigaClientWrapper(host=host) login_successful = wrapper.login(username=username, password=password) if login_successful: # Generate a unique session ID new_session_id = str(uuid.uuid4()) # Store the authenticated wrapper in our manual session store active_sessions[new_session_id] = wrapper logger.info( f"Login successful for '{username}'. Created session ID: {new_session_id}") # Return the session ID to the client return {"session_id": new_session_id} else: # Should not happen if login raises exception on failure, but handle defensively logger.error( f"Login attempt for '{username}' returned False unexpectedly.") raise RuntimeError("Login failed for an unknown reason.") except (ValueError, TaigaException) as e: logger.error(f"Login failed for '{username}': {e}", exc_info=False) # Re-raise the exception - FastMCP will turn it into an error response raise e except Exception as e: logger.error( f"Unexpected error during login for '{username}': {e}", exc_info=True) raise RuntimeError( f"An unexpected server error occurred during login: {e}") # server_fastmcp.py # ... (keep existing imports, logging setup, active_sessions, FastMCP instance) ... # ... (keep _get_authenticated_client helper function) ... # ... (keep login tool function) ... # --- Project Tools --- @mcp.tool("list_projects", description="Lists projects accessible to the user associated with the provided session_id.") def list_projects(session_id: str) -> List[Dict[str, Any]]: """Lists projects accessible by the authenticated user.""" logger.info(f"Executing list_projects for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.method() projects = taiga_client_wrapper.api.projects.list() # Remove .to_dict() as pytaigaclient should return dicts # result = [p.to_dict() for p in projects] logger.info( f"list_projects successful for session {session_id[:8]}, found {len(projects)} projects.") return projects # Return directly except TaigaException as e: logger.error(f"Taiga API error listing projects: {e}", exc_info=False) raise e except Exception as e: logger.error(f"Unexpected error listing projects: {e}", exc_info=True) raise RuntimeError(f"Server error listing projects: {e}") @mcp.tool("list_all_projects", description="Lists all projects visible to the user (requires admin privileges for full list). Uses the provided session_id.") def list_all_projects(session_id: str) -> List[Dict[str, Any]]: """Lists all projects visible to the authenticated user (scope depends on permissions).""" logger.info(f"Executing list_all_projects for session {session_id[:8]}...") # pytaigaclient's list() likely behaves similarly to python-taiga's return list_projects(session_id) # Keep delegation @mcp.tool("get_project", description="Gets detailed information about a specific project by its ID.") def get_project(session_id: str, project_id: int) -> Dict[str, Any]: """Retrieves project details by ID.""" logger.info( f"Executing get_project ID {project_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.get(project_id) project = taiga_client_wrapper.api.projects.get(project_id) # return project.to_dict() # Remove .to_dict() return project # Return directly except TaigaException as e: logger.error( f"Taiga API error getting project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting project: {e}") @mcp.tool("get_project_by_slug", description="Gets detailed information about a specific project by its slug.") def get_project_by_slug(session_id: str, slug: str) -> Dict[str, Any]: """Retrieves project details by slug.""" logger.info( f"Executing get_project_by_slug '{slug}' for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.get(slug=...) project = taiga_client_wrapper.api.projects.get(slug=slug) # return project.to_dict() # Remove .to_dict() return project # Return directly except TaigaException as e: logger.error( f"Taiga API error getting project by slug '{slug}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting project by slug '{slug}': {e}", exc_info=True) raise RuntimeError(f"Server error getting project by slug: {e}") @mcp.tool("create_project", description="Creates a new project.") def create_project(session_id: str, name: str, description: str, **kwargs) -> Dict[str, Any]: """Creates a new project. Requires name and description. Optional args (e.g., is_private) via kwargs.""" logger.info( f"Executing create_project '{name}' for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) if not name or not description: raise ValueError("Project name and description are required.") try: # Use pytaigaclient syntax: client.projects.create(name=..., description=..., **kwargs) new_project = taiga_client_wrapper.api.projects.create( name=name, description=description, **kwargs ) logger.info(f"Project '{name}' created successfully (ID: {new_project.get('id', 'N/A')}).") return new_project # Return the created project dict except TaigaException as e: logger.error( f"Taiga API error creating project '{name}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating project '{name}': {e}", exc_info=True) raise RuntimeError(f"Server error creating project: {e}") @mcp.tool("update_project", description="Updates details of an existing project.") def update_project(session_id: str, project_id: int, **kwargs) -> Dict[str, Any]: """Updates a project. Pass fields to update as keyword arguments (e.g., name='New Name', description='New Desc').""" logger.info( f"Executing update_project ID {project_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient update pattern: client.resource.update(id=..., data=...) if not kwargs: logger.info(f"No fields provided for update on project {project_id}") # Return current state if no updates provided return taiga_client_wrapper.api.projects.get(project_id=project_id) # First fetch the project to get its current version current_project = taiga_client_wrapper.api.projects.get(project_id=project_id) version = current_project.get('version') if not version: raise ValueError(f"Could not determine version for project {project_id}") # The project update method requires project_id, version, and project_data updated_project = taiga_client_wrapper.api.projects.update( project_id=project_id, version=version, project_data=kwargs ) logger.info(f"Project {project_id} update request sent.") # Return the result from the update call return updated_project except TaigaException as e: logger.error( f"Taiga API error updating project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating project: {e}") @mcp.tool("delete_project", description="Deletes a project by its ID. This is irreversible.") def delete_project(session_id: str, project_id: int) -> Dict[str, Any]: """Deletes a project by ID.""" logger.warning( f"Executing delete_project ID {project_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.projects.delete(id=project_id) logger.info(f"Project {project_id} deleted successfully.") return {"status": "deleted", "project_id": project_id} except TaigaException as e: logger.error( f"Taiga API error deleting project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting project: {e}") # @mcp.tool("get_project_roles", description="Lists the available roles within a specific project.") # def get_project_roles(session_id: str, project_id: int) -> List[Dict[str, Any]]: # """Retrieves the list of roles for a project. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_project_roles called, but not supported by pytaigaclient. Project: {project_id}") # raise NotImplementedError("Listing project-specific roles is not currently supported by the pytaigaclient wrapper.") # --- User Story Tools --- @mcp.tool("list_user_stories", description="Lists user stories within a specific project, optionally filtered.") def list_user_stories(session_id: str, project_id: int, **filters) -> List[Dict[str, Any]]: """Lists user stories for a project. Optional filters like 'milestone', 'status', 'assigned_to' can be passed as keyword arguments.""" logger.info( f"Executing list_user_stories for project {project_id}, session {session_id[:8]}, filters: {filters}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=..., **filters) stories = taiga_client_wrapper.api.user_stories.list(project_id=project_id, **filters) # return [s.to_dict() for s in stories] # Remove .to_dict() return stories # Return directly except TaigaException as e: logger.error( f"Taiga API error listing user stories for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing user stories for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing user stories: {e}") @mcp.tool("create_user_story", description="Creates a new user story within a project.") def create_user_story(session_id: str, project_id: int, subject: str, **kwargs) -> Dict[str, Any]: """Creates a user story. Requires project_id and subject. Optional fields (description, milestone_id, status_id, assigned_to_id, etc.) via kwargs.""" logger.info( f"Executing create_user_story '{subject}' in project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not subject: raise ValueError("User story subject cannot be empty.") try: # Use pytaigaclient syntax: client.resource.create(project=..., subject=..., **kwargs) story = taiga_client_wrapper.api.user_stories.create( project=project_id, subject=subject, **kwargs) logger.info( f"User story '{subject}' created successfully (ID: {story.get('id', 'N/A')}).") # Use .get() for safety # return story.to_dict() # Remove .to_dict() return story # Return directly except TaigaException as e: logger.error( f"Taiga API error creating user story '{subject}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating user story '{subject}': {e}", exc_info=True) raise RuntimeError(f"Server error creating user story: {e}") @mcp.tool("get_user_story", description="Gets detailed information about a specific user story by its ID.") def get_user_story(session_id: str, user_story_id: int) -> Dict[str, Any]: """Retrieves user story details by ID.""" logger.info( f"Executing get_user_story ID {user_story_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # User stories expects user_story_id as a positional argument story = taiga_client_wrapper.api.user_stories.get(user_story_id) # return story.to_dict() # Remove .to_dict() return story # Return directly except TaigaException as e: logger.error( f"Taiga API error getting user story {user_story_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting user story {user_story_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting user story: {e}") # @mcp.tool("get_user_story_by_ref", description="Gets detailed information about a specific user story by its reference number within a project.") # def get_user_story_by_ref(session_id: str, project_id: int, ref: int) -> Dict[str, Any]: # """Retrieves user story details by project ID and reference number. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_user_story_by_ref called, but not supported by pytaigaclient. Project: {project_id}, Ref: {ref}") # raise NotImplementedError("Getting user stories by reference number is not currently supported by the pytaigaclient wrapper.") @mcp.tool("update_user_story", description="Updates details of an existing user story.") def update_user_story(session_id: str, user_story_id: int, **kwargs) -> Dict[str, Any]: """Updates a user story. Pass fields to update as keyword arguments (e.g., subject, description, status_id, assigned_to).""" logger.info( f"Executing update_user_story ID {user_story_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient update pattern: client.resource.edit for partial updates if not kwargs: logger.info(f"No fields provided for update on user story {user_story_id}") return taiga_client_wrapper.api.user_stories.get(user_story_id) # Get current user story data to retrieve version current_story = taiga_client_wrapper.api.user_stories.get(user_story_id) version = current_story.get('version') if not version: raise ValueError(f"Could not determine version for user story {user_story_id}") # Use edit method for partial updates with keyword arguments updated_story = taiga_client_wrapper.api.user_stories.edit( user_story_id=user_story_id, version=version, **kwargs ) logger.info(f"User story {user_story_id} update request sent.") return updated_story except TaigaException as e: logger.error( f"Taiga API error updating user story {user_story_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating user story {user_story_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating user story: {e}") @mcp.tool("delete_user_story", description="Deletes a user story by its ID.") def delete_user_story(session_id: str, user_story_id: int) -> Dict[str, Any]: """Deletes a user story by ID.""" logger.warning( f"Executing delete_user_story ID {user_story_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.user_stories.delete(id=user_story_id) logger.info(f"User story {user_story_id} deleted successfully.") return {"status": "deleted", "user_story_id": user_story_id} except TaigaException as e: logger.error( f"Taiga API error deleting user story {user_story_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting user story {user_story_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting user story: {e}") @mcp.tool("assign_user_story_to_user", description="Assigns a specific user story to a specific user.") def assign_user_story_to_user(session_id: str, user_story_id: int, user_id: int) -> Dict[str, Any]: """Assigns a user story to a user.""" logger.info( f"Executing assign_user_story_to_user: US {user_story_id} -> User {user_id}, session {session_id[:8]}...") # Delegate to update_user_story, assuming 'assigned_to' key works return update_user_story(session_id, user_story_id, assigned_to=user_id) @mcp.tool("unassign_user_story_from_user", description="Unassigns a specific user story (sets assigned user to null).") def unassign_user_story_from_user(session_id: str, user_story_id: int) -> Dict[str, Any]: """Unassigns a user story.""" logger.info( f"Executing unassign_user_story_from_user: US {user_story_id}, session {session_id[:8]}...") # Delegate to update_user_story with assigned_to=None return update_user_story(session_id, user_story_id, assigned_to=None) @mcp.tool("get_user_story_statuses", description="Lists the available statuses for user stories within a specific project.") def get_user_story_statuses(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of user story statuses for a project.""" logger.info( f"Executing get_user_story_statuses for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) # Update resource name: user_story_statuses -> userstory_statuses statuses = taiga_client_wrapper.api.userstory_statuses.list(project_id=project_id) # return [s.to_dict() for s in statuses] # Remove .to_dict() return statuses # Return directly except TaigaException as e: logger.error( f"Taiga API error getting user story statuses for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting user story statuses for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting user story statuses: {e}") # --- Task Tools --- @mcp.tool("list_tasks", description="Lists tasks within a specific project, optionally filtered.") def list_tasks(session_id: str, project_id: int, **filters) -> List[Dict[str, Any]]: """Lists tasks for a project. Optional filters like 'milestone', 'status', 'user_story', 'assigned_to' can be passed as keyword arguments.""" logger.info( f"Executing list_tasks for project {project_id}, session {session_id[:8]}, filters: {filters}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=..., **filters) tasks = taiga_client_wrapper.api.tasks.list(project_id=project_id, **filters) # return [t.to_dict() for t in tasks] # Remove .to_dict() return tasks # Return directly except TaigaException as e: logger.error( f"Taiga API error listing tasks for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing tasks for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing tasks: {e}") @mcp.tool("create_task", description="Creates a new task within a project.") def create_task(session_id: str, project_id: int, subject: str, **kwargs) -> Dict[str, Any]: """Creates a task. Requires project_id and subject. Optional fields (description, milestone_id, status_id, user_story_id, assigned_to_id, etc.) via kwargs.""" logger.info( f"Executing create_task '{subject}' in project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not subject: raise ValueError("Task subject cannot be empty.") try: # Use pytaigaclient syntax: client.resource.create(project=..., subject=..., **kwargs) task = taiga_client_wrapper.api.tasks.create(project=project_id, subject=subject, **kwargs) logger.info(f"Task '{subject}' created successfully (ID: {task.get('id', 'N/A')}).") # return task.to_dict() # Remove .to_dict() return task # Return directly except TaigaException as e: logger.error( f"Taiga API error creating task '{subject}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating task '{subject}': {e}", exc_info=True) raise RuntimeError(f"Server error creating task: {e}") @mcp.tool("get_task", description="Gets detailed information about a specific task by its ID.") def get_task(session_id: str, task_id: int) -> Dict[str, Any]: """Retrieves task details by ID.""" logger.info( f"Executing get_task ID {task_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Tasks expects task_id as a positional argument task = taiga_client_wrapper.api.tasks.get(task_id) # return task.to_dict() # Remove .to_dict() return task # Return directly except TaigaException as e: logger.error( f"Taiga API error getting task {task_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting task {task_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting task: {e}") # @mcp.tool("get_task_by_ref", description="Gets detailed information about a specific task by its reference number within a project.") # def get_task_by_ref(session_id: str, project_id: int, ref: int) -> Dict[str, Any]: # """Retrieves task details by project ID and reference number. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_task_by_ref called, but not supported by pytaigaclient. Project: {project_id}, Ref: {ref}") # raise NotImplementedError("Getting tasks by reference number is not currently supported by the pytaigaclient wrapper.") @mcp.tool("update_task", description="Updates details of an existing task.") def update_task(session_id: str, task_id: int, **kwargs) -> Dict[str, Any]: """Updates a task. Pass fields to update as keyword arguments (e.g., subject, description, status_id, assigned_to).""" logger.info( f"Executing update_task ID {task_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient edit pattern for partial updates if not kwargs: logger.info(f"No fields provided for update on task {task_id}") return taiga_client_wrapper.api.tasks.get(task_id) # Get current task data to retrieve version current_task = taiga_client_wrapper.api.tasks.get(task_id) version = current_task.get('version') if not version: raise ValueError(f"Could not determine version for task {task_id}") # Use edit method for partial updates with keyword arguments updated_task = taiga_client_wrapper.api.tasks.edit( task_id=task_id, version=version, **kwargs ) logger.info(f"Task {task_id} update request sent.") return updated_task except TaigaException as e: logger.error( f"Taiga API error updating task {task_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating task {task_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating task: {e}") @mcp.tool("delete_task", description="Deletes a task by its ID.") def delete_task(session_id: str, task_id: int) -> Dict[str, Any]: """Deletes a task by ID.""" logger.warning( f"Executing delete_task ID {task_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.tasks.delete(id=task_id) logger.info(f"Task {task_id} deleted successfully.") return {"status": "deleted", "task_id": task_id} except TaigaException as e: logger.error( f"Taiga API error deleting task {task_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting task {task_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting task: {e}") @mcp.tool("assign_task_to_user", description="Assigns a specific task to a specific user.") def assign_task_to_user(session_id: str, task_id: int, user_id: int) -> Dict[str, Any]: """Assigns a task to a user.""" logger.info( f"Executing assign_task_to_user: Task {task_id} -> User {user_id}, session {session_id[:8]}...") # Delegate to update_task return update_task(session_id, task_id, assigned_to=user_id) @mcp.tool("unassign_task_from_user", description="Unassigns a specific task (sets assigned user to null).") def unassign_task_from_user(session_id: str, task_id: int) -> Dict[str, Any]: """Unassigns a task.""" logger.info( f"Executing unassign_task_from_user: Task {task_id}, session {session_id[:8]}...") # Delegate to update_task return update_task(session_id, task_id, assigned_to=None) # @mcp.tool("get_task_statuses", description="Lists the available statuses for tasks within a specific project.") # def get_task_statuses(session_id: str, project_id: int) -> List[Dict[str, Any]]: # """Retrieves the list of task statuses for a project. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_task_statuses called, but not supported by pytaigaclient. Project: {project_id}") # raise NotImplementedError("Listing task statuses is not currently supported by the pytaigaclient wrapper.") # --- Issue Tools --- @mcp.tool("list_issues", description="Lists issues within a specific project, optionally filtered.") def list_issues(session_id: str, project_id: int, **filters) -> List[Dict[str, Any]]: """Lists issues for a project. Optional filters like 'milestone', 'status', 'priority', 'severity', 'type', 'assigned_to' can be passed as kwargs.""" logger.info( f"Executing list_issues for project {project_id}, session {session_id[:8]}, filters: {filters}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=..., **filters) issues = taiga_client_wrapper.api.issues.list(project_id=project_id, **filters) # return [i.to_dict() for i in issues] # Remove .to_dict() return issues # Return directly except TaigaException as e: logger.error( f"Taiga API error listing issues for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing issues for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing issues: {e}") @mcp.tool("create_issue", description="Creates a new issue within a project.") def create_issue(session_id: str, project_id: int, subject: str, priority_id: int, status_id: int, severity_id: int, type_id: int, **kwargs) -> Dict[str, Any]: """Creates an issue. Requires project_id, subject, priority_id, status_id, severity_id, type_id. Optional fields (description, assigned_to_id, etc.) via kwargs.""" logger.info( f"Executing create_issue '{subject}' in project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not subject: raise ValueError("Issue subject cannot be empty.") try: # Use pytaigaclient syntax: client.resource.create(...) # Assuming pytaigaclient expects _id suffix for relational fields in create, but 'project' for project itself issue = taiga_client_wrapper.api.issues.create( project=project_id, # Changed project_id to project subject=subject, priority_id=priority_id, # Assume _id suffix status_id=status_id, # Assume _id suffix type_id=type_id, # Assume _id suffix severity_id=severity_id, # Assume _id suffix **kwargs ) logger.info( f"Issue '{subject}' created successfully (ID: {issue.get('id', 'N/A')}).") # return issue.to_dict() # Remove .to_dict() return issue # Return directly except TaigaException as e: logger.error( f"Taiga API error creating issue '{subject}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating issue '{subject}': {e}", exc_info=True) raise RuntimeError(f"Server error creating issue: {e}") @mcp.tool("get_issue", description="Gets detailed information about a specific issue by its ID.") def get_issue(session_id: str, issue_id: int) -> Dict[str, Any]: """Retrieves issue details by ID.""" logger.info( f"Executing get_issue ID {issue_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Issues expects issue_id as a positional argument issue = taiga_client_wrapper.api.issues.get(issue_id) # return issue.to_dict() # Remove .to_dict() return issue # Return directly except TaigaException as e: logger.error( f"Taiga API error getting issue {issue_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting issue {issue_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting issue: {e}") # @mcp.tool("get_issue_by_ref", description="Gets detailed information about a specific issue by its reference number within a project.") # def get_issue_by_ref(session_id: str, project_id: int, ref: int) -> Dict[str, Any]: # """Retrieves issue details by project ID and reference number. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_issue_by_ref called, but not supported by pytaigaclient. Project: {project_id}, Ref: {ref}") # raise NotImplementedError("Getting issues by reference number is not currently supported by the pytaigaclient wrapper.") @mcp.tool("update_issue", description="Updates details of an existing issue.") def update_issue(session_id: str, issue_id: int, **kwargs) -> Dict[str, Any]: """Updates an issue. Pass fields to update as keyword arguments (e.g., subject, description, status_id, assigned_to).""" logger.info( f"Executing update_issue ID {issue_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient edit pattern for partial updates if not kwargs: logger.info(f"No fields provided for update on issue {issue_id}") return taiga_client_wrapper.api.issues.get(issue_id) # Get current issue data to retrieve version current_issue = taiga_client_wrapper.api.issues.get(issue_id) version = current_issue.get('version') if not version: raise ValueError(f"Could not determine version for issue {issue_id}") # Use edit method for partial updates with keyword arguments updated_issue = taiga_client_wrapper.api.issues.edit( issue_id=issue_id, version=version, **kwargs ) logger.info(f"Issue {issue_id} update request sent.") return updated_issue except TaigaException as e: logger.error( f"Taiga API error updating issue {issue_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating issue {issue_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating issue: {e}") @mcp.tool("delete_issue", description="Deletes an issue by its ID.") def delete_issue(session_id: str, issue_id: int) -> Dict[str, Any]: """Deletes an issue by ID.""" logger.warning( f"Executing delete_issue ID {issue_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.issues.delete(id=issue_id) logger.info(f"Issue {issue_id} deleted successfully.") return {"status": "deleted", "issue_id": issue_id} except TaigaException as e: logger.error( f"Taiga API error deleting issue {issue_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting issue {issue_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting issue: {e}") @mcp.tool("assign_issue_to_user", description="Assigns a specific issue to a specific user.") def assign_issue_to_user(session_id: str, issue_id: int, user_id: int) -> Dict[str, Any]: """Assigns an issue to a user.""" logger.info( f"Executing assign_issue_to_user: Issue {issue_id} -> User {user_id}, session {session_id[:8]}...") # Delegate to update_issue return update_issue(session_id, issue_id, assigned_to=user_id) @mcp.tool("unassign_issue_from_user", description="Unassigns a specific issue (sets assigned user to null).") def unassign_issue_from_user(session_id: str, issue_id: int) -> Dict[str, Any]: """Unassigns an issue.""" logger.info( f"Executing unassign_issue_from_user: Issue {issue_id}, session {session_id[:8]}...") # Delegate to update_issue return update_issue(session_id, issue_id, assigned_to=None) @mcp.tool("get_issue_statuses", description="Lists the available statuses for issues within a specific project.") def get_issue_statuses(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of issue statuses for a project.""" logger.info( f"Executing get_issue_statuses for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) statuses = taiga_client_wrapper.api.issue_statuses.list(project_id=project_id) # return [s.to_dict() for s in statuses] # Remove .to_dict() return statuses # Return directly except TaigaException as e: logger.error( f"Taiga API error getting issue statuses for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting issue statuses for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting issue statuses: {e}") @mcp.tool("get_issue_priorities", description="Lists the available priorities for issues within a specific project.") def get_issue_priorities(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of issue priorities for a project.""" logger.info( f"Executing get_issue_priorities for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) # Update resource name: priorities -> issue_priorities priorities = taiga_client_wrapper.api.issue_priorities.list(project_id=project_id) # return [p.to_dict() for p in priorities] # Remove .to_dict() return priorities # Return directly except TaigaException as e: logger.error( f"Taiga API error getting issue priorities for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting issue priorities for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting issue priorities: {e}") @mcp.tool("get_issue_severities", description="Lists the available severities for issues within a specific project.") def get_issue_severities(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of issue severities for a project.""" logger.info( f"Executing get_issue_severities for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) # Update resource name: severities -> issue_severities severities = taiga_client_wrapper.api.issue_severities.list(project_id=project_id) # return [s.to_dict() for s in severities] # Remove .to_dict() return severities # Return directly except TaigaException as e: logger.error( f"Taiga API error getting issue severities for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting issue severities for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting issue severities: {e}") @mcp.tool("get_issue_types", description="Lists the available types for issues within a specific project.") def get_issue_types(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of issue types for a project.""" logger.info( f"Executing get_issue_types for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) types = taiga_client_wrapper.api.issue_types.list(project_id=project_id) # return [t.to_dict() for t in types] # Remove .to_dict() return types # Return directly except TaigaException as e: logger.error( f"Taiga API error getting issue types for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting issue types for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting issue types: {e}") # --- Epic Tools --- @mcp.tool("list_epics", description="Lists epics within a specific project, optionally filtered.") def list_epics(session_id: str, project_id: int, **filters) -> List[Dict[str, Any]]: """Lists epics for a project. Optional filters like 'status', 'assigned_to' can be passed as keyword arguments.""" logger.info( f"Executing list_epics for project {project_id}, session {session_id[:8]}, filters: {filters}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=..., **filters) epics = taiga_client_wrapper.api.epics.list(project_id=project_id, **filters) # return [e.to_dict() for e in epics] # Remove .to_dict() return epics # Return directly except TaigaException as e: logger.error( f"Taiga API error listing epics for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing epics for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing epics: {e}") @mcp.tool("create_epic", description="Creates a new epic within a project.") def create_epic(session_id: str, project_id: int, subject: str, **kwargs) -> Dict[str, Any]: """Creates an epic. Requires project_id and subject. Optional fields (description, status_id, assigned_to_id, color, etc.) via kwargs.""" logger.info( f"Executing create_epic '{subject}' in project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not subject: raise ValueError("Epic subject cannot be empty.") try: # Use pytaigaclient syntax: client.resource.create(project=..., subject=..., **kwargs) epic = taiga_client_wrapper.api.epics.create(project=project_id, subject=subject, **kwargs) logger.info(f"Epic '{subject}' created successfully (ID: {epic.get('id', 'N/A')}).") # return epic.to_dict() # Remove .to_dict() return epic # Return directly except TaigaException as e: logger.error( f"Taiga API error creating epic '{subject}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating epic '{subject}': {e}", exc_info=True) raise RuntimeError(f"Server error creating epic: {e}") @mcp.tool("get_epic", description="Gets detailed information about a specific epic by its ID.") def get_epic(session_id: str, epic_id: int) -> Dict[str, Any]: """Retrieves epic details by ID.""" logger.info( f"Executing get_epic ID {epic_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Epics expects epic_id as a positional argument epic = taiga_client_wrapper.api.epics.get(epic_id) # return epic.to_dict() # Remove .to_dict() return epic # Return directly except TaigaException as e: logger.error( f"Taiga API error getting epic {epic_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting epic {epic_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting epic: {e}") # @mcp.tool("get_epic_by_ref", description="Gets detailed information about a specific epic by its reference number within a project.") # def get_epic_by_ref(session_id: str, project_id: int, ref: int) -> Dict[str, Any]: # """Retrieves epic details by project ID and reference number. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_epic_by_ref called, but not supported by pytaigaclient. Project: {project_id}, Ref: {ref}") # raise NotImplementedError("Getting epics by reference number is not currently supported by the pytaigaclient wrapper.") @mcp.tool("update_epic", description="Updates details of an existing epic.") def update_epic(session_id: str, epic_id: int, **kwargs) -> Dict[str, Any]: """Updates an epic. Pass fields to update as keyword arguments (e.g., subject, description, status_id, assigned_to, color).""" logger.info( f"Executing update_epic ID {epic_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient edit pattern for partial updates if not kwargs: logger.info(f"No fields provided for update on epic {epic_id}") return taiga_client_wrapper.api.epics.get(epic_id) # Get current epic data to retrieve version current_epic = taiga_client_wrapper.api.epics.get(epic_id) version = current_epic.get('version') if not version: raise ValueError(f"Could not determine version for epic {epic_id}") # Use edit method for partial updates with keyword arguments updated_epic = taiga_client_wrapper.api.epics.edit( epic_id=epic_id, version=version, **kwargs ) logger.info(f"Epic {epic_id} update request sent.") return updated_epic except TaigaException as e: logger.error( f"Taiga API error updating epic {epic_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating epic {epic_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating epic: {e}") @mcp.tool("delete_epic", description="Deletes an epic by its ID.") def delete_epic(session_id: str, epic_id: int) -> Dict[str, Any]: """Deletes an epic by ID.""" logger.warning( f"Executing delete_epic ID {epic_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.epics.delete(id=epic_id) logger.info(f"Epic {epic_id} deleted successfully.") return {"status": "deleted", "epic_id": epic_id} except TaigaException as e: logger.error( f"Taiga API error deleting epic {epic_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting epic {epic_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting epic: {e}") @mcp.tool("assign_epic_to_user", description="Assigns a specific epic to a specific user.") def assign_epic_to_user(session_id: str, epic_id: int, user_id: int) -> Dict[str, Any]: """Assigns an epic to a user.""" logger.info( f"Executing assign_epic_to_user: Epic {epic_id} -> User {user_id}, session {session_id[:8]}...") # Delegate to update_epic return update_epic(session_id, epic_id, assigned_to=user_id) @mcp.tool("unassign_epic_from_user", description="Unassigns a specific epic (sets assigned user to null).") def unassign_epic_from_user(session_id: str, epic_id: int) -> Dict[str, Any]: """Unassigns an epic.""" logger.info( f"Executing unassign_epic_from_user: Epic {epic_id}, session {session_id[:8]}...") # Delegate to update_epic return update_epic(session_id, epic_id, assigned_to=None) # @mcp.tool("get_epic_statuses", description="Lists the available statuses for epics within a specific project.") # def get_epic_statuses(session_id: str, project_id: int) -> List[Dict[str, Any]]: # """Retrieves the list of epic statuses for a project. (REMOVED - Resource not found in pytaigaclient)""" # logger.warning(f"get_epic_statuses called, but epic_statuses resource not found in pytaigaclient. Project: {project_id}") # raise NotImplementedError("Listing epic statuses is not currently supported by the pytaigaclient wrapper.") # --- Milestone (Sprint) Tools --- @mcp.tool("list_milestones", description="Lists milestones (sprints) within a specific project.") def list_milestones(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Lists milestones for a project.""" logger.info( f"Executing list_milestones for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.list(project_id=...) milestones = taiga_client_wrapper.api.milestones.list(project_id=project_id) # return [m.to_dict() for m in milestones] # Remove .to_dict() return milestones # Return directly except TaigaException as e: logger.error( f"Taiga API error listing milestones for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing milestones for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing milestones: {e}") @mcp.tool("create_milestone", description="Creates a new milestone (sprint) within a project.") def create_milestone(session_id: str, project_id: int, name: str, estimated_start: str, estimated_finish: str) -> Dict[str, Any]: """Creates a milestone. Requires project_id, name, estimated_start (YYYY-MM-DD), and estimated_finish (YYYY-MM-DD).""" logger.info( f"Executing create_milestone '{name}' in project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not all([name, estimated_start, estimated_finish]): raise ValueError( "Milestone requires name, estimated_start, and estimated_finish.") try: # Use pytaigaclient syntax: client.resource.create(...) milestone = taiga_client_wrapper.api.milestones.create( project=project_id, # Changed project_id to project name=name, estimated_start=estimated_start, estimated_finish=estimated_finish ) logger.info( f"Milestone '{name}' created successfully (ID: {milestone.get('id', 'N/A')}).") # return milestone.to_dict() # Remove .to_dict() return milestone # Return directly except TaigaException as e: logger.error( f"Taiga API error creating milestone '{name}': {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error creating milestone '{name}': {e}", exc_info=True) raise RuntimeError(f"Server error creating milestone: {e}") @mcp.tool("get_milestone", description="Gets detailed information about a specific milestone by its ID.") def get_milestone(session_id: str, milestone_id: int) -> Dict[str, Any]: """Retrieves milestone details by ID.""" logger.info( f"Executing get_milestone ID {milestone_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Milestones expects milestone_id as a positional argument milestone = taiga_client_wrapper.api.milestones.get(milestone_id) # return milestone.to_dict() # Remove .to_dict() return milestone # Return directly except TaigaException as e: logger.error( f"Taiga API error getting milestone {milestone_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting milestone {milestone_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting milestone: {e}") @mcp.tool("update_milestone", description="Updates details of an existing milestone.") def update_milestone(session_id: str, milestone_id: int, **kwargs) -> Dict[str, Any]: """Updates a milestone. Pass fields to update as kwargs (e.g., name, estimated_start, estimated_finish).""" logger.info( f"Executing update_milestone ID {milestone_id} for session {session_id[:8]} with data: {kwargs}") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient edit pattern for partial updates if not kwargs: logger.info(f"No fields provided for update on milestone {milestone_id}") return taiga_client_wrapper.api.milestones.get(milestone_id) # Get current milestone data to retrieve version current_milestone = taiga_client_wrapper.api.milestones.get(milestone_id) version = current_milestone.get('version') if not version: raise ValueError(f"Could not determine version for milestone {milestone_id}") # Use edit method for partial updates with keyword arguments updated_milestone = taiga_client_wrapper.api.milestones.edit( milestone_id=milestone_id, version=version, **kwargs ) logger.info(f"Milestone {milestone_id} update request sent.") return updated_milestone except TaigaException as e: logger.error( f"Taiga API error updating milestone {milestone_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error updating milestone {milestone_id}: {e}", exc_info=True) raise RuntimeError(f"Server error updating milestone: {e}") @mcp.tool("delete_milestone", description="Deletes a milestone by its ID.") def delete_milestone(session_id: str, milestone_id: int) -> Dict[str, Any]: """Deletes a milestone by ID.""" logger.warning( f"Executing delete_milestone ID {milestone_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.resource.delete(id=...) taiga_client_wrapper.api.milestones.delete(id=milestone_id) logger.info(f"Milestone {milestone_id} deleted successfully.") return {"status": "deleted", "milestone_id": milestone_id} except TaigaException as e: logger.error( f"Taiga API error deleting milestone {milestone_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error deleting milestone {milestone_id}: {e}", exc_info=True) raise RuntimeError(f"Server error deleting milestone: {e}") # @mcp.tool("get_milestone_stats", description="Gets statistics (total points, completed points, etc.) for a specific milestone.") # def get_milestone_stats(session_id: str, milestone_id: int) -> Dict[str, Any]: # """Retrieves statistics for a milestone. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_milestone_stats called, but not supported by pytaigaclient. Milestone: {milestone_id}") # raise NotImplementedError("Getting milestone statistics is not currently supported by the pytaigaclient wrapper.") # --- User Management Tools --- @mcp.tool("get_project_members", description="Lists members of a specific project.") def get_project_members(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Retrieves the list of members for a project.""" logger.info( f"Executing get_project_members for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient memberships resource list method members = taiga_client_wrapper.api.memberships.list(project_id=project_id) # return [m.to_dict() for m in members] # Remove .to_dict() return members # Return directly except TaigaException as e: logger.error( f"Taiga API error getting members for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting members for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting project members: {e}") @mcp.tool("invite_project_user", description="Invites a user to a project by email with a specific role.") def invite_project_user(session_id: str, project_id: int, email: str, role_id: int) -> Dict[str, Any]: """Invites a user via email to join the project with the specified role ID.""" logger.info( f"Executing invite_project_user {email} to project {project_id} (role {role_id}), session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name if not email: raise ValueError("Email cannot be empty.") try: # Use pytaigaclient memberships resource invite method # Check pytaigaclient signature for param names (project, email, role_id) invitation_result = taiga_client_wrapper.api.memberships.invite( project=project_id, email=email, role_id=role_id # Changed project_id to project ) logger.info(f"Invitation request sent to {email} for project {project_id}.") # Return the result from the invite call (might be dict or status) return invitation_result if isinstance(invitation_result, dict) else {"status": "invited", "email": email, "details": invitation_result} except TaigaException as e: logger.error( f"Taiga API error inviting user {email} to project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error inviting user {email} to project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error inviting user: {e}") # --- Wiki Tools --- @mcp.tool("list_wiki_pages", description="Lists wiki pages within a specific project.") def list_wiki_pages(session_id: str, project_id: int) -> List[Dict[str, Any]]: """Lists wiki pages for a project.""" logger.info( f"Executing list_wiki_pages for project {project_id}, session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Use pytaigaclient syntax: client.wiki.list(project_id=...) pages = taiga_client_wrapper.api.wiki.list(project_id=project_id) # return [p.to_dict() for p in pages] # Remove .to_dict() return pages # Return directly except TaigaException as e: logger.error( f"Taiga API error listing wiki pages for project {project_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error listing wiki pages for project {project_id}: {e}", exc_info=True) raise RuntimeError(f"Server error listing wiki pages: {e}") @mcp.tool("get_wiki_page", description="Gets a specific wiki page by its ID.") def get_wiki_page(session_id: str, wiki_page_id: int) -> Dict[str, Any]: """Retrieves wiki page details by ID.""" logger.info( f"Executing get_wiki_page ID {wiki_page_id} for session {session_id[:8]}...") taiga_client_wrapper = _get_authenticated_client(session_id) # Use wrapper variable name try: # Wiki expects wiki_page_id as a positional argument page = taiga_client_wrapper.api.wiki.get(wiki_page_id) # return page.to_dict() # Remove .to_dict() return page # Return directly except TaigaException as e: logger.error( f"Taiga API error getting wiki page {wiki_page_id}: {e}", exc_info=False) raise e except Exception as e: logger.error( f"Unexpected error getting wiki page {wiki_page_id}: {e}", exc_info=True) raise RuntimeError(f"Server error getting wiki page: {e}") # @mcp.tool("get_wiki_page_by_slug", description="Gets a specific wiki page by its slug within a project.") # def get_wiki_page_by_slug(session_id: str, project_id: int, slug: str) -> Dict[str, Any]: # """Retrieves wiki page details by project ID and slug. (REMOVED - Not directly supported by pytaigaclient)""" # logger.warning(f"get_wiki_page_by_slug called, but not supported by pytaigaclient. Project: {project_id}, Slug: {slug}") # raise NotImplementedError("Getting wiki pages by slug is not currently supported by the pytaigaclient wrapper.") # --- Session Management Tools --- @mcp.tool("logout", description="Invalidates the current session_id.") def logout(session_id: str) -> Dict[str, Any]: """Logs out the current session, invalidating the session_id.""" logger.info(f"Executing logout for session {session_id[:8]}...") # Remove from dict, return None if not found client_wrapper = active_sessions.pop(session_id, None) # Use consistent var name if client_wrapper: logger.info(f"Session {session_id[:8]} logged out successfully.") # No specific API logout call needed usually for token-based auth return {"status": "logged_out", "session_id": session_id} else: logger.warning( f"Attempted to log out non-existent session: {session_id}") return {"status": "session_not_found", "session_id": session_id} @mcp.tool("session_status", description="Checks if the provided session_id is currently active and valid.") def session_status(session_id: str) -> Dict[str, Any]: """Checks the validity of the current session_id.""" logger.debug( f"Executing session_status check for session {session_id[:8]}...") client_wrapper = active_sessions.get(session_id) # Use consistent var name if client_wrapper and client_wrapper.is_authenticated: try: # Use pytaigaclient users.me() call me = client_wrapper.api.users.me() # Extract username from the returned dict username = me.get('username', 'Unknown') logger.debug( f"Session {session_id[:8]} is active for user {username}.") return {"status": "active", "session_id": session_id, "username": username} except TaigaException: logger.warning( f"Session {session_id[:8]} found but token seems invalid (API check failed).") # Clean up invalid session active_sessions.pop(session_id, None) return {"status": "inactive", "reason": "token_invalid", "session_id": session_id} except Exception as e: # Catch broader exceptions during the 'me' call logger.error(f"Unexpected error during session status check for {session_id[:8]}: {e}", exc_info=True) # Return a distinct status for unexpected errors during check return {"status": "error", "reason": "check_failed", "session_id": session_id} elif client_wrapper: # Client exists but not authenticated (shouldn't happen with current login logic) logger.warning( f"Session {session_id[:8]} exists but client wrapper is not authenticated.") return {"status": "inactive", "reason": "not_authenticated", "session_id": session_id} else: # Session ID not found logger.debug(f"Session {session_id[:8]} not found.") return {"status": "inactive", "reason": "not_found", "session_id": session_id} # --- Run the server --- if __name__ == "__main__": mcp.run()
ID: bd5ajys5zl