diff --git a/README.md b/README.md index a383d98..d50a433 100644 --- a/README.md +++ b/README.md @@ -470,7 +470,7 @@ Update an existing work package. - `status_id` (integer, optional): Status ID - `priority_id` (integer, optional): Priority ID - `assignee_id` (integer, optional): User ID to assign to -- `percentage_done` (integer, optional): Completion percentage (0-100) +- `percentage_done` (integer, optional): Completion percentage (0-100). **Known instance behavior:** on some OpenProject instances progress is derived from status and `percentageDone` is read-only — sending it returns HTTP 422 `PropertyIsReadOnly`. The tool detects this, skips `percentage_done`, and still applies the other fields (with a note in the response). #### 13. `delete_work_package` Delete a work package. diff --git a/pyproject.toml b/pyproject.toml index f426c81..f265f11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ ] dependencies = [ - "fastmcp>=2.0.0", + "fastmcp>=2.0.0,<3", # 2.x tool API (@mcp.tool -> FunctionTool.fn); 3.x changes this "mcp>=1.0.0", "aiohttp>=3.8.0", "python-dotenv>=1.0.0", @@ -33,6 +33,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", "black>=22.0.0", "flake8>=4.0.0", ] @@ -51,3 +52,7 @@ target-version = ['py38'] [tool.flake8] max-line-length = 88 extend-ignore = ["E203", "W503"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/tools/memberships.py b/src/tools/memberships.py index a3d6b2d..330a8d9 100644 --- a/src/tools/memberships.py +++ b/src/tools/memberships.py @@ -1,12 +1,13 @@ """Membership management tools.""" from typing import Optional, List -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error -class CreateMembershipInput(BaseModel): +class CreateMembershipInput(CoercibleModel): """Input model for creating memberships.""" project_id: int = Field(..., description="Project ID", gt=0) user_id: Optional[int] = Field(None, description="User ID (required if group_id not provided)", gt=0) @@ -16,7 +17,7 @@ class CreateMembershipInput(BaseModel): notification_message: Optional[str] = Field(None, description="Optional notification message") -class UpdateMembershipInput(BaseModel): +class UpdateMembershipInput(CoercibleModel): """Input model for updating memberships.""" membership_id: int = Field(..., description="Membership ID to update", gt=0) role_ids: Optional[List[int]] = Field(None, description="New list of role IDs") diff --git a/src/tools/news.py b/src/tools/news.py index 7d2705a..8ac3de4 100644 --- a/src/tools/news.py +++ b/src/tools/news.py @@ -2,9 +2,10 @@ import json from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import ( format_news_list, format_news_detail, @@ -18,7 +19,7 @@ # ============================================================ -class CreateNewsInput(BaseModel): +class CreateNewsInput(CoercibleModel): """Input model for creating news.""" project_id: int = Field(..., description="Project ID", gt=0) @@ -29,7 +30,7 @@ class CreateNewsInput(BaseModel): description: str = Field(..., description="Main content (supports Markdown)") -class UpdateNewsInput(BaseModel): +class UpdateNewsInput(CoercibleModel): """Input model for updating news.""" news_id: int = Field(..., description="News ID to update", gt=0) diff --git a/src/tools/projects.py b/src/tools/projects.py index 465529f..bf935f0 100644 --- a/src/tools/projects.py +++ b/src/tools/projects.py @@ -3,7 +3,8 @@ import json from typing import Optional from src.server import mcp, get_client -from pydantic import BaseModel, Field +from pydantic import Field +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error from src.utils.formatting import format_project_list @@ -140,7 +141,7 @@ async def get_project(project_id: int) -> str: return f"❌ Failed to get project: {str(e)}" -class CreateProjectInput(BaseModel): +class CreateProjectInput(CoercibleModel): """Input model for creating projects.""" name: str = Field(..., description="Project name", min_length=1, max_length=255) identifier: str = Field(..., description="Project identifier (lowercase, no spaces)", min_length=1, max_length=100) @@ -150,7 +151,7 @@ class CreateProjectInput(BaseModel): parent_id: Optional[int] = Field(None, description="Parent project ID for sub-projects", gt=0) -class AddSubprojectInput(BaseModel): +class AddSubprojectInput(CoercibleModel): """Input model for adding subprojects.""" parent_id: int = Field(..., description="Parent project ID", gt=0) name: str = Field(..., description="Subproject name", min_length=1, max_length=255) @@ -159,7 +160,7 @@ class AddSubprojectInput(BaseModel): public: Optional[bool] = Field(None, description="Whether subproject is public") -class UpdateProjectInput(BaseModel): +class UpdateProjectInput(CoercibleModel): """Input model for updating projects.""" project_id: int = Field(..., description="Project ID to update", gt=0) name: Optional[str] = Field(None, description="New project name", min_length=1, max_length=255) diff --git a/src/tools/relations.py b/src/tools/relations.py index 092b22a..68a49f4 100644 --- a/src/tools/relations.py +++ b/src/tools/relations.py @@ -1,12 +1,13 @@ """Work package relation management tools (follows, blocks, relates, etc.).""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error -class CreateRelationInput(BaseModel): +class CreateRelationInput(CoercibleModel): """Input model for creating work package relations.""" from_id: int = Field(..., description="Source work package ID", gt=0) to_id: int = Field(..., description="Target work package ID", gt=0) @@ -15,7 +16,7 @@ class CreateRelationInput(BaseModel): description: Optional[str] = Field(None, description="Relation description") -class UpdateRelationInput(BaseModel): +class UpdateRelationInput(CoercibleModel): """Input model for updating work package relations.""" relation_id: int = Field(..., description="Relation ID to update", gt=0) lag: Optional[int] = Field(None, description="New lag in working days") diff --git a/src/tools/time_entries.py b/src/tools/time_entries.py index c19a579..c994e8c 100644 --- a/src/tools/time_entries.py +++ b/src/tools/time_entries.py @@ -1,12 +1,13 @@ """Time entry management tools for time tracking.""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error -class CreateTimeEntryInput(BaseModel): +class CreateTimeEntryInput(CoercibleModel): """Input model for creating time entries.""" work_package_id: int = Field(..., description="Work package ID", gt=0) hours: float = Field(..., description="Hours spent", gt=0) @@ -15,7 +16,7 @@ class CreateTimeEntryInput(BaseModel): comment: Optional[str] = Field(None, description="Optional comment") -class UpdateTimeEntryInput(BaseModel): +class UpdateTimeEntryInput(CoercibleModel): """Input model for updating time entries.""" time_entry_id: int = Field(..., description="Time entry ID to update", gt=0) hours: Optional[float] = Field(None, description="New hours spent", gt=0) diff --git a/src/tools/versions.py b/src/tools/versions.py index e417fbd..0f4fbff 100644 --- a/src/tools/versions.py +++ b/src/tools/versions.py @@ -1,12 +1,13 @@ """Version/milestone management tools.""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error -class CreateVersionInput(BaseModel): +class CreateVersionInput(CoercibleModel): """Input model for creating versions.""" project_id: int = Field(..., description="Project ID", gt=0) name: str = Field(..., description="Version name", min_length=1, max_length=255) diff --git a/src/tools/weekly_reports.py b/src/tools/weekly_reports.py index 70af318..64b788f 100644 --- a/src/tools/weekly_reports.py +++ b/src/tools/weekly_reports.py @@ -3,9 +3,10 @@ import json from typing import Optional from datetime import datetime, timedelta -from pydantic import BaseModel, Field +from pydantic import Field from src.server import mcp, get_client +from src.utils.inputs import CoercibleModel from src.utils.formatting import format_success, format_error from src.utils.report_formatter import ( format_weekly_report_markdown, @@ -13,7 +14,7 @@ ) -class GenerateWeeklyReportInput(BaseModel): +class GenerateWeeklyReportInput(CoercibleModel): """Input model for generating weekly reports.""" project_id: int = Field(..., description="Project ID to generate report for", gt=0) from_date: str = Field(..., description="Report start date (YYYY-MM-DD)") @@ -23,7 +24,7 @@ class GenerateWeeklyReportInput(BaseModel): format: str = Field("markdown", description="Output format: 'markdown' or 'json'") -class GetReportDataInput(BaseModel): +class GetReportDataInput(CoercibleModel): """Input model for getting raw report data.""" project_id: int = Field(..., description="Project ID", gt=0) from_date: str = Field(..., description="Start date (YYYY-MM-DD)") diff --git a/src/tools/work_packages.py b/src/tools/work_packages.py index 5348d08..ab777af 100644 --- a/src/tools/work_packages.py +++ b/src/tools/work_packages.py @@ -1,8 +1,8 @@ """Work package management tools - Priority CRITICAL tools for 12 users.""" import json -from typing import Optional -from pydantic import BaseModel, Field +from typing import Annotated, Optional +from pydantic import Field from src.server import mcp, get_client from src.utils.formatting import ( @@ -13,36 +13,11 @@ ) -# Pydantic models for type-safe input validation -class CreateWorkPackageInput(BaseModel): - """Input model for creating work packages with validation.""" - - project_id: int = Field(..., description="Project ID where work package will be created", gt=0) - subject: str = Field(..., description="Work package title/subject", min_length=1, max_length=255) - type_id: int = Field(..., description="Type ID (use list_types to see available types)", gt=0) - description: Optional[str] = Field(None, description="Detailed description in raw format") - start_date: Optional[str] = Field(None, description="Start date in ISO format (YYYY-MM-DD)") - due_date: Optional[str] = Field(None, description="Due date in ISO format (YYYY-MM-DD)") - assignee_id: Optional[int] = Field(None, description="Assignee user ID", gt=0) - status_id: Optional[int] = Field(None, description="Status ID", gt=0) - priority_id: Optional[int] = Field(None, description="Priority ID", gt=0) - version_id: Optional[int] = Field(None, description="Version/milestone ID to assign work package to", gt=0) - - -class UpdateWorkPackageInput(BaseModel): - """Input model for updating work packages with validation.""" - - work_package_id: int = Field(..., description="Work package ID to update", gt=0) - subject: Optional[str] = Field(None, description="New subject/title", min_length=1, max_length=255) - description: Optional[str] = Field(None, description="New description") - type_id: Optional[int] = Field(None, description="New type ID", gt=0) - status_id: Optional[int] = Field(None, description="New status ID", gt=0) - priority_id: Optional[int] = Field(None, description="New priority ID", gt=0) - assignee_id: Optional[int] = Field(None, description="New assignee user ID", gt=0) - start_date: Optional[str] = Field(None, description="New start date (YYYY-MM-DD)") - due_date: Optional[str] = Field(None, description="New due date (YYYY-MM-DD)") - percentage_done: Optional[int] = Field(None, description="Progress percentage (0-100)", ge=0, le=100) - version_id: Optional[int] = Field(None, description="Version/milestone ID to assign work package to", gt=0) +# create_work_package / update_work_package use explicit (flat) parameters rather +# than a single wrapped Pydantic model. This produces a self-describing object +# schema and avoids the opaque-parameter encoding that some MCP clients serialize +# as a JSON string. The other tools keep wrapped models with the CoercibleModel +# base as a safety net (see src/utils/inputs.py). @mcp.tool @@ -53,37 +28,32 @@ async def list_work_packages( active_only: bool = True, offset: int = 0, page_size: int = 20, - # NEW: Multi-value filters (comma-separated IDs) priority_ids: Optional[str] = None, type_ids: Optional[str] = None, status_ids: Optional[str] = None, version_ids: Optional[str] = None, - # NEW: Date filters due_before: Optional[str] = None, # YYYY-MM-DD - due_after: Optional[str] = None, # YYYY-MM-DD + due_after: Optional[str] = None, # YYYY-MM-DD created_after: Optional[str] = None, # YYYY-MM-DD updated_after: Optional[str] = None, # YYYY-MM-DD - # NEW: Boolean filters unassigned_only: bool = False, overdue_only: bool = False, - # NEW: Percentage filters percentage_done_min: Optional[int] = None, percentage_done_max: Optional[int] = None, - # NEW: Additional filters author_id: Optional[int] = None, parent_id: Optional[int] = None, - no_parent_only: bool = False + no_parent_only: bool = False, ) -> str: """List work packages (tasks) with advanced filtering - CRITICAL tool for flexible task search. - + This is the most powerful search tool with 20+ filter parameters for finding exactly the tasks you need. Supports multiple filters combined with AND logic. - + Args: # Basic filters project_id: Optional project ID to filter by @@ -91,35 +61,35 @@ async def list_work_packages( active_only: If True, only show open work packages (default: True) offset: Starting index for pagination (default: 0) page_size: Number of results per page (default: 20, max: 100) - + # Multi-value filters (comma-separated IDs) priority_ids: Comma-separated priority IDs (e.g., "3,4" for high+urgent) type_ids: Comma-separated type IDs (e.g., "1,2" for bugs+features) status_ids: Comma-separated status IDs (overrides active_only if provided) version_ids: Comma-separated version/sprint IDs - + # Date filters due_before: Due date before this date (YYYY-MM-DD format) due_after: Due date after this date (YYYY-MM-DD format) created_after: Created after this date (YYYY-MM-DD format) updated_after: Updated after this date (YYYY-MM-DD format) - + # Boolean filters unassigned_only: If True, only show tasks without assignee overdue_only: If True, only show tasks past their due date - + # Percentage filters percentage_done_min: Minimum completion percentage (0-100) percentage_done_max: Maximum completion percentage (0-100) - + # Additional filters author_id: Filter by task creator/author parent_id: Filter by parent work package ID (child tasks) no_parent_only: If True, only show top-level tasks (no parent) - + Returns: Formatted list of work packages matching all specified filters - + Examples: Find high-priority bugs due this week: { @@ -128,14 +98,14 @@ async def list_work_packages( "due_before": "2025-12-15", "due_after": "2025-12-08" } - + Find overdue unassigned tasks in project #5: { "project_id": 5, "unassigned_only": true, "overdue_only": true } - + Find nearly complete tasks (>80%): { "percentage_done_min": 80, @@ -144,19 +114,21 @@ async def list_work_packages( """ try: from datetime import date, datetime - + client = get_client() # Build filters list filters_list = [] - + # === STATUS FILTER === # Priority: status_ids > overdue_only > active_only if status_ids: # Explicit status IDs provided status_list = [s.strip() for s in status_ids.split(",") if s.strip()] if status_list: - filters_list.append({"status": {"operator": "=", "values": status_list}}) + filters_list.append( + {"status": {"operator": "=", "values": status_list}} + ) elif overdue_only: # Overdue mode: must be open filters_list.append({"status": {"operator": "o", "values": []}}) @@ -166,91 +138,163 @@ async def list_work_packages( else: # All statuses (open + closed) filters_list.append({"status": {"operator": "*", "values": []}}) - + # === ASSIGNEE FILTER === if unassigned_only: # Unassigned takes priority over assignee_id filters_list.append({"assignee": {"operator": "!*", "values": []}}) elif assignee_id: - filters_list.append({"assignee": {"operator": "=", "values": [str(assignee_id)]}}) - + filters_list.append( + {"assignee": {"operator": "=", "values": [str(assignee_id)]}} + ) + # === PRIORITY FILTER === if priority_ids: priority_list = [p.strip() for p in priority_ids.split(",") if p.strip()] if priority_list: - filters_list.append({"priority": {"operator": "=", "values": priority_list}}) - + filters_list.append( + {"priority": {"operator": "=", "values": priority_list}} + ) + # === TYPE FILTER === if type_ids: type_list = [t.strip() for t in type_ids.split(",") if t.strip()] if type_list: filters_list.append({"type": {"operator": "=", "values": type_list}}) - + # === VERSION FILTER === if version_ids: version_list = [v.strip() for v in version_ids.split(",") if v.strip()] if version_list: - filters_list.append({"version": {"operator": "=", "values": version_list}}) - + filters_list.append( + {"version": {"operator": "=", "values": version_list}} + ) + # === DATE FILTERS === # Overdue filter (special case) if overdue_only: # Due date < today today = date.today().isoformat() - filters_list.append({"dueDate": {"operator": "<>d", "values": ["2000-01-01", today]}}) + filters_list.append( + {"dueDate": {"operator": "<>d", "values": ["2000-01-01", today]}} + ) else: # Regular due date filters if due_before and due_after: # Date range - filters_list.append({"dueDate": {"operator": "<>d", "values": [due_after, due_before]}}) + filters_list.append( + {"dueDate": {"operator": "<>d", "values": [due_after, due_before]}} + ) elif due_before: # Before specific date (use range from old date to due_before) - filters_list.append({"dueDate": {"operator": "<>d", "values": ["2000-01-01", due_before]}}) + filters_list.append( + { + "dueDate": { + "operator": "<>d", + "values": ["2000-01-01", due_before], + } + } + ) elif due_after: # After specific date (use range from due_after to far future) - filters_list.append({"dueDate": {"operator": "<>d", "values": [due_after, "2099-12-31"]}}) - + filters_list.append( + { + "dueDate": { + "operator": "<>d", + "values": [due_after, "2099-12-31"], + } + } + ) + # Created after filter if created_after: # Use date range from created_after to far future - filters_list.append({"createdAt": {"operator": "<>d", "values": [created_after, "2099-12-31"]}}) - + filters_list.append( + { + "createdAt": { + "operator": "<>d", + "values": [created_after, "2099-12-31"], + } + } + ) + # Updated after filter if updated_after: # Use date range from updated_after to far future - filters_list.append({"updatedAt": {"operator": "<>d", "values": [updated_after, "2099-12-31"]}}) - + filters_list.append( + { + "updatedAt": { + "operator": "<>d", + "values": [updated_after, "2099-12-31"], + } + } + ) + # === PERCENTAGE FILTER === if percentage_done_min is not None and percentage_done_max is not None: # Range filter if percentage_done_min > percentage_done_max: - return format_error("percentage_done_min cannot be greater than percentage_done_max") + return format_error( + "percentage_done_min cannot be greater than percentage_done_max" + ) # Use two filters: >= min AND <= max - filters_list.append({"percentageDone": {"operator": ">=", "values": [str(percentage_done_min)]}}) - filters_list.append({"percentageDone": {"operator": "<=", "values": [str(percentage_done_max)]}}) + filters_list.append( + { + "percentageDone": { + "operator": ">=", + "values": [str(percentage_done_min)], + } + } + ) + filters_list.append( + { + "percentageDone": { + "operator": "<=", + "values": [str(percentage_done_max)], + } + } + ) elif percentage_done_min is not None: # Minimum only if percentage_done_min < 0 or percentage_done_min > 100: return format_error("percentage_done_min must be between 0 and 100") - filters_list.append({"percentageDone": {"operator": ">=", "values": [str(percentage_done_min)]}}) + filters_list.append( + { + "percentageDone": { + "operator": ">=", + "values": [str(percentage_done_min)], + } + } + ) elif percentage_done_max is not None: # Maximum only if percentage_done_max < 0 or percentage_done_max > 100: return format_error("percentage_done_max must be between 0 and 100") - filters_list.append({"percentageDone": {"operator": "<=", "values": [str(percentage_done_max)]}}) - + filters_list.append( + { + "percentageDone": { + "operator": "<=", + "values": [str(percentage_done_max)], + } + } + ) + # === AUTHOR FILTER === if author_id: - filters_list.append({"author": {"operator": "=", "values": [str(author_id)]}}) - + filters_list.append( + {"author": {"operator": "=", "values": [str(author_id)]}} + ) + # === PARENT FILTER === if no_parent_only: # Top-level tasks only (no parent) filters_list.append({"parent": {"operator": "!*", "values": []}}) elif parent_id: # Specific parent - filters_list.append({"parent": {"operator": "=", "values": [str(parent_id)]}}) - + filters_list.append( + {"parent": {"operator": "=", "values": [str(parent_id)]}} + ) + # Convert filters to JSON filters = json.dumps(filters_list) if filters_list else None @@ -261,10 +305,7 @@ async def list_work_packages( return format_error("page_size must be between 1 and 100") result = await client.get_work_packages( - project_id=project_id, - filters=filters, - offset=offset, - page_size=page_size + project_id=project_id, filters=filters, offset=offset, page_size=page_size ) work_packages = result.get("_embedded", {}).get("elements", []) @@ -284,15 +325,13 @@ async def list_work_packages( return format_error(f"Failed to list work packages: {str(e)}") - - @mcp.tool async def search_work_packages( query: str, project_id: Optional[int] = None, active_only: bool = True, offset: int = 0, - page_size: int = 20 + page_size: int = 20, ) -> str: """Search work packages by subject or ID - Fast search without pagination. @@ -331,12 +370,9 @@ async def search_work_packages( filters_list = [] # Add subjectOrId filter for search - filters_list.append({ - "subjectOrId": { - "operator": "**", - "values": [query.strip()] - } - }) + filters_list.append( + {"subjectOrId": {"operator": "**", "values": [query.strip()]}} + ) # Add active_only filter if requested (same fix as list_work_packages) if active_only: @@ -354,10 +390,7 @@ async def search_work_packages( return format_error("page_size must be between 1 and 100") result = await client.get_work_packages( - project_id=project_id, - filters=filters, - offset=offset, - page_size=page_size + project_id=project_id, filters=filters, offset=offset, page_size=page_size ) work_packages = result.get("_embedded", {}).get("elements", []) @@ -387,54 +420,83 @@ async def search_work_packages( @mcp.tool -async def create_work_package(input: CreateWorkPackageInput) -> str: +async def create_work_package( + project_id: Annotated[ + int, Field(gt=0, description="Project ID where work package will be created") + ], + subject: Annotated[ + str, + Field(min_length=1, max_length=255, description="Work package title/subject"), + ], + type_id: Annotated[ + int, Field(gt=0, description="Type ID (use list_types to see available types)") + ], + description: Annotated[ + Optional[str], Field(description="Detailed description in raw format") + ] = None, + start_date: Annotated[ + Optional[str], Field(description="Start date in ISO format (YYYY-MM-DD)") + ] = None, + due_date: Annotated[ + Optional[str], Field(description="Due date in ISO format (YYYY-MM-DD)") + ] = None, + assignee_id: Annotated[ + Optional[int], Field(gt=0, description="Assignee user ID") + ] = None, + status_id: Annotated[Optional[int], Field(gt=0, description="Status ID")] = None, + priority_id: Annotated[ + Optional[int], Field(gt=0, description="Priority ID") + ] = None, + version_id: Annotated[ + Optional[int], + Field(gt=0, description="Version/milestone ID to assign work package to"), + ] = None, +) -> str: """Create a new work package (task) - CRITICAL tool for creating tasks. This is one of the most important tools for your 12 users to create new work items. Args: - input: Work package data including project_id, subject, type_id, and optional fields + project_id: Project where the work package will be created. + subject: Work package title/subject. + type_id: Type ID (use list_types to see available types). + description: Detailed description in raw format. + start_date: Start date (YYYY-MM-DD). + due_date: Due date (YYYY-MM-DD). + assignee_id: Assignee user ID. + status_id: Status ID (accepted for compatibility; status on creation follows + the project/type default -- use update_work_package to change it). + priority_id: Priority ID. + version_id: Version/milestone ID. Returns: Success message with created work package ID and details - - Example: - To create a bug in project 5: - { - "project_id": 5, - "subject": "Fix login issue", - "type_id": 1, - "description": "Users cannot login with valid credentials", - "priority_id": 3, - "assignee_id": 7, - "due_date": "2025-01-15" - } """ try: client = get_client() # Build data dict for API data = { - "project": input.project_id, - "subject": input.subject, - "type": input.type_id, + "project": project_id, + "subject": subject, + "type": type_id, } # Add optional fields - if input.description: - data["description"] = input.description - if input.priority_id: - data["priority_id"] = input.priority_id - if input.assignee_id: - data["assignee_id"] = input.assignee_id - if input.version_id: - data["version_id"] = input.version_id + if description: + data["description"] = description + if priority_id: + data["priority_id"] = priority_id + if assignee_id: + data["assignee_id"] = assignee_id + if version_id: + data["version_id"] = version_id # Add date fields (use camelCase for API) - if input.start_date: - data["startDate"] = input.start_date - if input.due_date: - data["dueDate"] = input.due_date + if start_date: + data["startDate"] = start_date + if due_date: + data["dueDate"] = due_date # Create work package result = await client.create_work_package(data) @@ -457,9 +519,9 @@ async def create_work_package(input: CreateWorkPackageInput) -> str: if "assignee" in embedded: text += f"**Assignee**: {embedded['assignee'].get('name', 'Unassigned')}\n" - if result.get('startDate'): + if result.get("startDate"): text += f"**Start Date**: {result['startDate']}\n" - if result.get('dueDate'): + if result.get("dueDate"): text += f"**Due Date**: {result['dueDate']}\n" return text @@ -469,27 +531,58 @@ async def create_work_package(input: CreateWorkPackageInput) -> str: @mcp.tool -async def update_work_package(input: UpdateWorkPackageInput) -> str: +async def update_work_package( + work_package_id: Annotated[ + int, Field(gt=0, description="Work package ID to update") + ], + subject: Annotated[ + Optional[str], + Field(min_length=1, max_length=255, description="New subject/title"), + ] = None, + description: Annotated[Optional[str], Field(description="New description")] = None, + type_id: Annotated[Optional[int], Field(gt=0, description="New type ID")] = None, + status_id: Annotated[ + Optional[int], Field(gt=0, description="New status ID") + ] = None, + priority_id: Annotated[ + Optional[int], Field(gt=0, description="New priority ID") + ] = None, + assignee_id: Annotated[ + Optional[int], Field(gt=0, description="New assignee user ID") + ] = None, + start_date: Annotated[ + Optional[str], Field(description="New start date (YYYY-MM-DD)") + ] = None, + due_date: Annotated[ + Optional[str], Field(description="New due date (YYYY-MM-DD)") + ] = None, + percentage_done: Annotated[ + Optional[int], Field(ge=0, le=100, description="Progress percentage (0-100)") + ] = None, + version_id: Annotated[ + Optional[int], + Field(gt=0, description="Version/milestone ID to assign work package to"), + ] = None, +) -> str: """Update an existing work package (task) - CRITICAL tool for updating tasks. This is one of the most important tools for your 12 users to update work items, - including changing status, assignee, dates, and progress. + including changing status, assignee, dates, and progress. Only the fields you + pass are changed. Args: - input: Work package update data including work_package_id and fields to update + work_package_id: Work package to update. + subject, description, type_id, status_id, priority_id, assignee_id, + start_date, due_date, percentage_done, version_id: Optional fields; only + provided values are applied. + + Note: + percentage_done is read-only on some OpenProject instances (progress is + derived from status/work). If the instance rejects it, that field is + skipped and the remaining fields are still applied. Returns: Success message with updated work package details - - Example: - To update work package #123 status and assignee: - { - "work_package_id": 123, - "status_id": 5, - "assignee_id": 7, - "percentage_done": 50, - "due_date": "2025-01-20" - } """ try: client = get_client() @@ -497,34 +590,68 @@ async def update_work_package(input: UpdateWorkPackageInput) -> str: # Build data dict for API (only include provided fields) data = {} - if input.subject is not None: - data["subject"] = input.subject - if input.description is not None: - data["description"] = input.description - if input.type_id is not None: - data["type_id"] = input.type_id - if input.status_id is not None: - data["status_id"] = input.status_id - if input.priority_id is not None: - data["priority_id"] = input.priority_id - if input.assignee_id is not None: - data["assignee_id"] = input.assignee_id - if input.percentage_done is not None: - data["percentage_done"] = input.percentage_done - if input.version_id is not None: - data["version_id"] = input.version_id + if subject is not None: + data["subject"] = subject + if description is not None: + data["description"] = description + if type_id is not None: + data["type_id"] = type_id + if status_id is not None: + data["status_id"] = status_id + if priority_id is not None: + data["priority_id"] = priority_id + if assignee_id is not None: + data["assignee_id"] = assignee_id + if percentage_done is not None: + data["percentage_done"] = percentage_done + if version_id is not None: + data["version_id"] = version_id # Add date fields (use camelCase for API) - if input.start_date is not None: - data["startDate"] = input.start_date - if input.due_date is not None: - data["dueDate"] = input.due_date + if start_date is not None: + data["startDate"] = start_date + if due_date is not None: + data["dueDate"] = due_date if not data: return format_error("No fields provided to update") - # Update work package - result = await client.update_work_package(input.work_package_id, data) + # Update work package. percentage_done (percentageDone) is read-only on some + # OpenProject instances (progress is derived from status). If the API rejects + # it as read-only, drop that field and retry once so the rest of the update + # still applies rather than failing the whole request. + readonly_note = "" + try: + result = await client.update_work_package(work_package_id, data) + except Exception as exc: + # Detect via the stable v3 error identifier ("PropertyIsReadOnly") and + # API attribute name ("percentageDone"), both present in the 422 body, + # rather than the localizable human message -- so this keeps working if + # OpenProject rewords or translates the error text. + low = str(exc).lower() + pct_read_only = ( + percentage_done is not None + and "percentage" in low + and ( + "propertyisreadonly" in low + or "readonly" in low.replace("-", "").replace(" ", "") + or "not writable" in low + ) + ) + if not pct_read_only: + raise + data.pop("percentage_done", None) + if not data: + return format_error( + "percentage_done is read-only on this OpenProject instance " + "(progress is derived from status), and no other fields were " + "provided to update." + ) + result = await client.update_work_package(work_package_id, data) + readonly_note = ( + "\n⚠️ percentage_done is read-only on this instance and was " + "skipped; the other fields were updated." + ) # Format success response wp_id = result.get("id") @@ -544,13 +671,14 @@ async def update_work_package(input: UpdateWorkPackageInput) -> str: if "assignee" in embedded: text += f"**Assignee**: {embedded['assignee'].get('name', 'Unassigned')}\n" - if result.get('startDate'): + if result.get("startDate"): text += f"**Start Date**: {result['startDate']}\n" - if result.get('dueDate'): + if result.get("dueDate"): text += f"**Due Date**: {result['dueDate']}\n" - if 'percentageDone' in result: + if "percentageDone" in result: text += f"**Progress**: {result['percentageDone']}%\n" + text += readonly_note return text except Exception as e: @@ -573,7 +701,9 @@ async def delete_work_package(work_package_id: int) -> str: success = await client.delete_work_package(work_package_id) if success: - return format_success(f"Work package #{work_package_id} deleted successfully") + return format_success( + f"Work package #{work_package_id} deleted successfully" + ) else: return format_error(f"Failed to delete work package #{work_package_id}") @@ -715,7 +845,7 @@ async def assign_work_package(work_package_id: int, assignee_id: int) -> str: if "assignee" in embedded: assignee_name = embedded["assignee"].get("name", "Unknown") text += f"**Assigned to**: {assignee_name}\n" - + if "type" in embedded: text += f"**Type**: {embedded['type'].get('name', 'Unknown')}\n" if "status" in embedded: @@ -723,7 +853,7 @@ async def assign_work_package(work_package_id: int, assignee_id: int) -> str: if "priority" in embedded: text += f"**Priority**: {embedded['priority'].get('name', 'Unknown')}\n" - if result.get('dueDate'): + if result.get("dueDate"): text += f"**Due Date**: {result['dueDate']}\n" return text @@ -749,7 +879,9 @@ async def unassign_work_package(work_package_id: int) -> str: # Update work package with null assignee (unassign) # Note: We need to use the API directly since setting to None might not work - result = await client.update_work_package(work_package_id, {"assignee_id": None}) + result = await client.update_work_package( + work_package_id, {"assignee_id": None} + ) wp_id = result.get("id") wp_subject = result.get("subject") @@ -772,9 +904,7 @@ async def unassign_work_package(work_package_id: int) -> str: @mcp.tool async def add_work_package_comment( - work_package_id: int, - comment: str, - internal: bool = False + work_package_id: int, comment: str, internal: bool = False ) -> str: """Add a comment/activity to a work package - CRITICAL for reporting and communication. @@ -801,9 +931,7 @@ async def add_work_package_comment( client = get_client() result = await client.add_work_package_comment( - work_package_id=work_package_id, - comment=comment, - internal=internal + work_package_id=work_package_id, comment=comment, internal=internal ) activity_id = result.get("id", "N/A") @@ -811,7 +939,9 @@ async def add_work_package_comment( comment_html = comment_data.get("html", "") comment_raw = comment_data.get("raw", comment) - text = format_success(f"Comment added to work package #{work_package_id} successfully!\n\n") + text = format_success( + f"Comment added to work package #{work_package_id} successfully!\n\n" + ) text += f"**Activity ID**: {activity_id}\n" text += f"**Internal**: {'Yes' if internal else 'No'}\n" text += f"**Comment**: {comment_raw[:200]}{'...' if len(comment_raw) > 200 else ''}\n" @@ -853,7 +983,9 @@ async def list_work_package_activities(work_package_id: int) -> str: if not activities: return f"No activities found for work package #{work_package_id}." - text = format_success(f"Work Package #{work_package_id} Activities ({len(activities)}):\n\n") + text = format_success( + f"Work Package #{work_package_id} Activities ({len(activities)}):\n\n" + ) for activity in activities: activity_id = activity.get("id", "N/A") @@ -903,29 +1035,30 @@ async def list_work_package_activities(work_package_id: int) -> str: # ADVANCED FILTERS - New high-priority tools for better task discovery # ============================================================================ + @mcp.tool async def list_overdue_work_packages( project_id: Optional[int] = None, assignee_id: Optional[int] = None, priority_ids: Optional[str] = None, # Comma-separated IDs like "3,4" type_ids: Optional[str] = None, # Comma-separated IDs like "1,2" - page_size: int = 50 + page_size: int = 50, ) -> str: """List all overdue work packages (tasks past their due date). - + This tool helps identify tasks that are past their due date and need urgent attention. Only searches through open (non-closed) work packages. - + Args: project_id: Optional project ID to filter by assignee_id: Optional user ID to filter by assignee priority_ids: Optional comma-separated priority IDs (e.g., "3" for high, or "3,4" for high+urgent) type_ids: Optional comma-separated type IDs (e.g., "1" for bugs, or "1,2" for bugs+features) page_size: Number of results to return (default: 50, max: 100) - + Returns: Formatted list of overdue work packages sorted by most overdue first - + Example: Find all high-priority overdue tasks assigned to user #5: { @@ -935,9 +1068,9 @@ async def list_overdue_work_packages( """ try: from datetime import date, datetime - + client = get_client() - + # Build filters list filters_list = [ # Status must be open (not closed) @@ -945,43 +1078,50 @@ async def list_overdue_work_packages( # Due date < today (overdue) # Note: OpenProject API doesn't support "d" (between) with old start date and today - {"dueDate": {"operator": "<>d", "values": ["2000-01-01", date.today().isoformat()]}} + { + "dueDate": { + "operator": "<>d", + "values": ["2000-01-01", date.today().isoformat()], + } + }, ] - + # Add optional filters if assignee_id: - filters_list.append({"assignee": {"operator": "=", "values": [str(assignee_id)]}}) - + filters_list.append( + {"assignee": {"operator": "=", "values": [str(assignee_id)]}} + ) + if priority_ids: # Parse comma-separated IDs priority_list = [p.strip() for p in priority_ids.split(",") if p.strip()] if priority_list: - filters_list.append({"priority": {"operator": "=", "values": priority_list}}) - + filters_list.append( + {"priority": {"operator": "=", "values": priority_list}} + ) + if type_ids: # Parse comma-separated IDs type_list = [t.strip() for t in type_ids.split(",") if t.strip()] if type_list: filters_list.append({"type": {"operator": "=", "values": type_list}}) - + filters = json.dumps(filters_list) - + # Validate page_size if page_size < 1 or page_size > 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return "✅ No overdue work packages found!" - + # Calculate days overdue for each task today = date.today() for wp in work_packages: @@ -995,16 +1135,16 @@ async def list_overdue_work_packages( wp["_days_overdue"] = 0 else: wp["_days_overdue"] = 0 - + # Sort by most overdue first work_packages.sort(key=lambda w: w.get("_days_overdue", 0), reverse=True) - + # Format response text = f"⚠️ **Overdue Work Packages**: {total} task(s) past due date\n\n" text += format_work_package_list(work_packages, show_days_overdue=True) - + return text - + except Exception as e: return format_error(f"Failed to list overdue work packages: {str(e)}") @@ -1015,23 +1155,23 @@ async def list_work_packages_due_soon( project_id: Optional[int] = None, assignee_id: Optional[int] = None, priority_ids: Optional[str] = None, - page_size: int = 50 + page_size: int = 50, ) -> str: """List work packages due within the next N days. - + This helps identify upcoming deadlines and prioritize work accordingly. Only searches through open (non-closed) work packages. - + Args: days: Number of days to look ahead (default: 7) project_id: Optional project ID to filter by assignee_id: Optional user ID to filter by assignee priority_ids: Optional comma-separated priority IDs (e.g., "3,4") page_size: Number of results to return (default: 50) - + Returns: Formatted list of work packages due soon, sorted by soonest first - + Example: Show my tasks due in the next 3 days: { @@ -1041,54 +1181,61 @@ async def list_work_packages_due_soon( """ try: from datetime import date, timedelta, datetime - + client = get_client() - + # Validate days parameter if days < 1: return format_error("days must be at least 1") if days > 365: return format_error("days cannot exceed 365") - + # Calculate date range today = date.today() target_date = today + timedelta(days=days) - + # Build filters filters_list = [ # Status must be open {"status": {"operator": "o", "values": []}}, # Due date between today and target_date - {"dueDate": {"operator": "<>d", "values": [today.isoformat(), target_date.isoformat()]}} + { + "dueDate": { + "operator": "<>d", + "values": [today.isoformat(), target_date.isoformat()], + } + }, ] - + # Add optional filters if assignee_id: - filters_list.append({"assignee": {"operator": "=", "values": [str(assignee_id)]}}) - + filters_list.append( + {"assignee": {"operator": "=", "values": [str(assignee_id)]}} + ) + if priority_ids: priority_list = [p.strip() for p in priority_ids.split(",") if p.strip()] if priority_list: - filters_list.append({"priority": {"operator": "=", "values": priority_list}}) - + filters_list.append( + {"priority": {"operator": "=", "values": priority_list}} + ) + filters = json.dumps(filters_list) - + # Validate page_size if page_size < 1 or page_size > 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return f"✅ No work packages due in the next {days} day(s)!" - + # Calculate days until due for wp in work_packages: due_date_str = wp.get("dueDate") @@ -1101,16 +1248,16 @@ async def list_work_packages_due_soon( wp["_days_until"] = 999 else: wp["_days_until"] = 999 - + # Sort by soonest first work_packages.sort(key=lambda w: w.get("_days_until", 999)) - + # Format response text = f"⏰ **Work Packages Due Soon**: {total} task(s) due in next {days} day(s)\n\n" text += format_work_package_list(work_packages, show_days_until=True) - + return text - + except Exception as e: return format_error(f"Failed to list work packages due soon: {str(e)}") @@ -1121,23 +1268,23 @@ async def list_unassigned_work_packages( priority_ids: Optional[str] = None, type_ids: Optional[str] = None, active_only: bool = True, - page_size: int = 50 + page_size: int = 50, ) -> str: """List work packages that have no assignee. - + This helps identify tasks that need to be assigned to team members. Useful for sprint planning and workload distribution. - + Args: project_id: Optional project ID to filter by priority_ids: Optional comma-separated priority IDs (e.g., "3,4" for high+urgent) type_ids: Optional comma-separated type IDs (e.g., "1" for bugs only) active_only: If True, only show open work packages (default: True) page_size: Number of results to return (default: 50, max: 100) - + Returns: Formatted list of unassigned work packages - + Example: Find all unassigned high-priority bugs in project #5: { @@ -1148,57 +1295,59 @@ async def list_unassigned_work_packages( """ try: client = get_client() - + # Build filters list filters_list = [ # Assignee must be empty (unassigned) {"assignee": {"operator": "!*", "values": []}} ] - + # Add status filter if active_only: filters_list.append({"status": {"operator": "o", "values": []}}) else: filters_list.append({"status": {"operator": "*", "values": []}}) - + # Add optional filters if priority_ids: priority_list = [p.strip() for p in priority_ids.split(",") if p.strip()] if priority_list: - filters_list.append({"priority": {"operator": "=", "values": priority_list}}) - + filters_list.append( + {"priority": {"operator": "=", "values": priority_list}} + ) + if type_ids: type_list = [t.strip() for t in type_ids.split(",") if t.strip()] if type_list: filters_list.append({"type": {"operator": "=", "values": type_list}}) - + filters = json.dumps(filters_list) - + # Validate page_size if page_size < 1 or page_size > 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return "✅ No unassigned work packages found!" - + # Format response text = f"👤 **Unassigned Work Packages**: {total} task(s) without assignee\n\n" text += format_work_package_list(work_packages) - + if total > page_size: - text += f"\n📄 Showing first {page_size} of {total} total unassigned tasks\n" - + text += ( + f"\n📄 Showing first {page_size} of {total} total unassigned tasks\n" + ) + return text - + except Exception as e: return format_error(f"Failed to list unassigned work packages: {str(e)}") @@ -1210,12 +1359,12 @@ async def list_work_packages_created_recently( assignee_id: Optional[int] = None, type_ids: Optional[str] = None, active_only: bool = True, - page_size: int = 50 + page_size: int = 50, ) -> str: """List work packages created in the last N days. - + This helps identify new tasks and track task creation patterns. - + Args: days: Number of days to look back (default: 7) project_id: Optional project ID to filter by @@ -1223,10 +1372,10 @@ async def list_work_packages_created_recently( type_ids: Optional comma-separated type IDs (e.g., "1,2" for bugs+features) active_only: If True, only show open work packages (default: True) page_size: Number of results to return (default: 50, max: 100) - + Returns: Formatted list of recently created work packages, sorted by newest first - + Example: Show all bugs created in the last 3 days: { @@ -1236,67 +1385,67 @@ async def list_work_packages_created_recently( """ try: from datetime import date, timedelta, datetime - + client = get_client() - + # Validate days parameter if days < 1: return format_error("days must be at least 1") if days > 365: return format_error("days cannot exceed 365") - + # Calculate date range # Note: Use 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return f"✅ No work packages created in the last {days} day(s)!" - + # Sort by creation date (newest first) work_packages.sort(key=lambda w: w.get("createdAt", ""), reverse=True) - + # Format response text = f"🆕 **Recently Created Work Packages**: {total} task(s) created in last {days} day(s)\n\n" text += format_work_package_list(work_packages) - + if total > page_size: text += f"\n📄 Showing first {page_size} of {total} total\n" - + return text - + except Exception as e: return format_error(f"Failed to list recently created work packages: {str(e)}") @@ -1307,31 +1456,31 @@ async def list_high_priority_work_packages( assignee_id: Optional[int] = None, type_ids: Optional[str] = None, active_only: bool = True, - page_size: int = 50 + page_size: int = 50, ) -> str: """List work packages with high priority. - + This tool finds tasks marked as high priority or urgent. Note that you need to know the priority ID for "High" in your OpenProject instance (typically 3 or 4). Use list_priorities tool first if you don't know the priority IDs. - + Args: project_id: Optional project ID to filter by assignee_id: Optional user ID to filter by assignee type_ids: Optional comma-separated type IDs (e.g., "1" for bugs only) active_only: If True, only show open work packages (default: True) page_size: Number of results to return (default: 50, max: 100) - + Returns: Formatted list of high priority work packages - + Example: Show all high-priority bugs in project #5: { "project_id": 5, "type_ids": "1" } - + Note: This assumes priority ID 3 = "High". If your instance uses different IDs, use list_priorities to find the correct ID, then use list_work_packages @@ -1339,58 +1488,58 @@ async def list_high_priority_work_packages( """ try: client = get_client() - + # Build filters - assume priority ID 3 is "High" # Users can override by using list_work_packages with specific priority_ids filters_list = [ # Priority = 3 (typically "High" in OpenProject) {"priority": {"operator": "=", "values": ["3"]}} ] - + # Add status filter if active_only: filters_list.append({"status": {"operator": "o", "values": []}}) else: filters_list.append({"status": {"operator": "*", "values": []}}) - + # Add optional filters if assignee_id: - filters_list.append({"assignee": {"operator": "=", "values": [str(assignee_id)]}}) - + filters_list.append( + {"assignee": {"operator": "=", "values": [str(assignee_id)]}} + ) + if type_ids: type_list = [t.strip() for t in type_ids.split(",") if t.strip()] if type_list: filters_list.append({"type": {"operator": "=", "values": type_list}}) - + filters = json.dumps(filters_list) - + # Validate page_size if page_size < 1 or page_size > 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return "✅ No high priority work packages found!" - + # Format response text = f"🔴 **High Priority Work Packages**: {total} task(s)\n\n" text += "💡 Note: This lists tasks with priority ID 3 (typically 'High').\n" text += " Use list_priorities to see all priority IDs in your instance.\n\n" text += format_work_package_list(work_packages) - + if total > page_size: text += f"\n📄 Showing first {page_size} of {total} total\n" - + return text - + except Exception as e: return format_error(f"Failed to list high priority work packages: {str(e)}") @@ -1401,23 +1550,23 @@ async def list_work_packages_nearly_complete( assignee_id: Optional[int] = None, min_percentage: int = 80, active_only: bool = True, - page_size: int = 50 + page_size: int = 50, ) -> str: """List work packages that are nearly complete (high percentage done). - + This helps identify tasks that are almost finished and may need a final push. Useful for sprint reviews and workload tracking. - + Args: project_id: Optional project ID to filter by assignee_id: Optional user ID to filter by assignee min_percentage: Minimum completion percentage (default: 80, range: 1-99) active_only: If True, only show open work packages (default: True) page_size: Number of results to return (default: 50, max: 100) - + Returns: Formatted list of nearly complete work packages - + Example: Show tasks >90% complete in project #5: { @@ -1427,52 +1576,52 @@ async def list_work_packages_nearly_complete( """ try: client = get_client() - + # Validate min_percentage if min_percentage < 1 or min_percentage > 99: return format_error("min_percentage must be between 1 and 99") - + # Build filters filters_list = [ # Percentage done >= min_percentage {"percentageDone": {"operator": ">=", "values": [str(min_percentage)]}} ] - + # Add status filter if active_only: filters_list.append({"status": {"operator": "o", "values": []}}) else: filters_list.append({"status": {"operator": "*", "values": []}}) - + # Add optional filters if assignee_id: - filters_list.append({"assignee": {"operator": "=", "values": [str(assignee_id)]}}) - + filters_list.append( + {"assignee": {"operator": "=", "values": [str(assignee_id)]}} + ) + filters = json.dumps(filters_list) - + # Validate page_size if page_size < 1 or page_size > 100: return format_error("page_size must be between 1 and 100") - + result = await client.get_work_packages( - project_id=project_id, - filters=filters, - page_size=page_size + project_id=project_id, filters=filters, page_size=page_size ) - + work_packages = result.get("_embedded", {}).get("elements", []) total = result.get("total", 0) - + if not work_packages: return f"✅ No work packages with ≥{min_percentage}% completion found!" - + # Sort by percentage done (highest first) work_packages.sort(key=lambda w: w.get("percentageDone", 0), reverse=True) - + # Format response text = f"📊 **Nearly Complete Work Packages**: {total} task(s) ≥{min_percentage}% done\n\n" text += format_work_package_list(work_packages) - + # Add completion percentages in summary if work_packages: text += "\n**Completion Summary**:\n" @@ -1482,13 +1631,11 @@ async def list_work_packages_nearly_complete( text += f" - #{wp.get('id')}: {percentage}% - {subject}\n" if len(work_packages) > 10: text += f" ... and {len(work_packages) - 10} more\n" - + if total > page_size: text += f"\n📄 Showing first {page_size} of {total} total\n" - + return text - + except Exception as e: return format_error(f"Failed to list nearly complete work packages: {str(e)}") - - diff --git a/src/utils/inputs.py b/src/utils/inputs.py new file mode 100644 index 0000000..2152779 --- /dev/null +++ b/src/utils/inputs.py @@ -0,0 +1,56 @@ +"""Tolerant handling of wrapped Pydantic tool inputs. + +Several tools take a single parameter -- a Pydantic model named ``input``. +FastMCP validates that argument against the model *before* the tool body runs. + +MCP clients differ in how they serialize structured arguments. Spec-compliant +clients send ``input`` as a JSON object and these tools work unchanged. Other +clients serialize object-typed arguments as a JSON *string* when the parameter's +schema is not an explicit ``type: object``; FastMCP then receives a ``str`` and +rejects it with a Pydantic ``model_type`` error before the body runs. + +``CoercibleModel`` makes any input model accept that JSON-string encoding in +addition to a dict or model instance, so the server works with both kinds of +client. ``as_model`` is the equivalent helper for non-decorated call paths. +""" + +from __future__ import annotations + +import json +from typing import Type, TypeVar, Union + +from pydantic import BaseModel, model_validator + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class CoercibleModel(BaseModel): + """Input-model base that also accepts a JSON string. + + A ``model_validator(mode="before")`` runs prior to field validation. When the + incoming value is a ``str`` (a client that serialized the object as JSON) it + is parsed into a dict first. Dicts and model instances pass through unchanged, + so spec-compliant clients are unaffected. Invalid JSON raises and surfaces as + a normal validation error. + """ + + @model_validator(mode="before") + @classmethod + def _accept_json_string(cls, data): + if isinstance(data, str): + return json.loads(data) + return data + + +def as_model(model_cls: Type[ModelT], value: Union[ModelT, dict, str]) -> ModelT: + """Coerce a model instance, dict, or JSON string into ``model_cls``.""" + if isinstance(value, model_cls): + return value + if isinstance(value, str): + return model_cls.model_validate_json(value) + if isinstance(value, dict): + return model_cls.model_validate(value) + raise TypeError( + f"{model_cls.__name__} input must be a model, dict, or JSON string, " + f"got {type(value).__name__}" + ) diff --git a/test_tools.py b/test_tools.py index 9af9d49..562eab2 100644 --- a/test_tools.py +++ b/test_tools.py @@ -77,34 +77,22 @@ async def test_all_tools(): print(f"FAIL FAILED: {e}") # Test 5: Create Work Package (CRITICAL) - DRY RUN - print("\n[5] Test: create_work_package validation (CRITICAL)") + print("\n[5] Test: create_work_package (CRITICAL)") try: - from src.tools.work_packages import CreateWorkPackageInput - # Validate input model only (don't actually create) - test_input = CreateWorkPackageInput( - project_id=1, - subject="Test Work Package", - type_id=1, - description="This is a test" - ) - print(f"OK Input validation PASSED") - print(f" Model: {test_input.model_dump()}") + # create_work_package now uses flat parameters (no wrapped input model) + from src.tools.work_packages import create_work_package + assert create_work_package is not None + print("OK create_work_package importable (flat params)") except Exception as e: print(f"FAIL FAILED: {e}") # Test 6: Update Work Package (CRITICAL) - DRY RUN - print("\n[6] Test: update_work_package validation (CRITICAL)") + print("\n[6] Test: update_work_package (CRITICAL)") try: - from src.tools.work_packages import UpdateWorkPackageInput - # Validate input model only (don't actually update) - test_input = UpdateWorkPackageInput( - work_package_id=123, - status_id=5, - assignee_id=7, - percentage_done=50 - ) - print(f"OK Input validation PASSED") - print(f" Model: {test_input.model_dump()}") + # update_work_package now uses flat parameters (no wrapped input model) + from src.tools.work_packages import update_work_package + assert update_work_package is not None + print("OK update_work_package importable (flat params)") except Exception as e: print(f"FAIL FAILED: {e}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a35fa3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Shared test setup. + +Importing the tool modules pulls in ``src.server``, which requires OpenProject +credentials to construct its client. The tests never hit the network, so we set +dummy credentials before anything imports the server. +""" + +import os + +os.environ.setdefault("OPENPROJECT_URL", "https://openproject.test") +os.environ.setdefault("OPENPROJECT_API_KEY", "dummy-key-for-tests") diff --git a/tests/test_inputs.py b/tests/test_inputs.py new file mode 100644 index 0000000..0575cbc --- /dev/null +++ b/tests/test_inputs.py @@ -0,0 +1,117 @@ +"""Tests for tolerant input handling (CoercibleModel + as_model). + +These verify the server accepts both the object and the JSON-string encoding of +wrapped tool inputs, so it works regardless of how an MCP client serializes +structured arguments. +""" + +import importlib +import inspect +import pkgutil +from typing import Optional + +import pytest +from pydantic import BaseModel + +from src.utils.inputs import CoercibleModel, as_model + + +class Demo(CoercibleModel): + project_id: int + subject: str + type_id: int + description: Optional[str] = None + + +# --- CoercibleModel: accepts object and JSON-string encodings --- + + +def test_coercible_accepts_dict(): + m = Demo.model_validate({"project_id": 1, "subject": "x", "type_id": 3}) + assert m.project_id == 1 and m.type_id == 3 + + +def test_coercible_accepts_json_string(): + # This is the encoding that previously failed with a `model_type` error. + m = Demo.model_validate('{"project_id": 1, "subject": "x", "type_id": 3}') + assert m.project_id == 1 and m.subject == "x" + + +def test_coercible_as_nested_field_accepts_json_string(): + # Mirrors how FastMCP validates a wrapped `input` parameter. + class Call(BaseModel): + input: Demo + + c = Call.model_validate( + {"input": '{"project_id": 2, "subject": "y", "type_id": 4}'} + ) + assert isinstance(c.input, Demo) and c.input.project_id == 2 + + +def test_coercible_invalid_json_raises(): + with pytest.raises(Exception): + Demo.model_validate("{not valid json") + + +# --- as_model helper --- + + +@pytest.mark.parametrize( + "value", + [ + {"project_id": 1, "subject": "x", "type_id": 3}, + '{"project_id": 1, "subject": "x", "type_id": 3}', + Demo(project_id=1, subject="x", type_id=3), + ], +) +def test_as_model_accepts_all_encodings(value): + m = as_model(Demo, value) + assert m.project_id == 1 and m.type_id == 3 + + +def test_as_model_rejects_bad_type(): + with pytest.raises(TypeError): + as_model(Demo, 123) + + +def test_as_model_rejects_invalid_payload(): + with pytest.raises(Exception): + as_model(Demo, '{"subject": "missing required fields"}') + + +# --- Tripwire: every wrapped *Input model must be coercible --- + + +def _iter_input_models(): + import src.tools as tools_pkg + + for mod in pkgutil.iter_modules(tools_pkg.__path__): + module = importlib.import_module(f"src.tools.{mod.name}") + for name, obj in inspect.getmembers(module, inspect.isclass): + if ( + name.endswith("Input") + and issubclass(obj, BaseModel) + and obj.__module__ == module.__name__ + ): + yield f"{mod.name}.{name}", obj + + +def test_every_wrapped_input_model_is_coercible(): + offenders = [ + qualname + for qualname, model in _iter_input_models() + if not issubclass(model, CoercibleModel) + ] + assert not offenders, ( + "These *Input models must subclass CoercibleModel so the server tolerates " + f"JSON-string arguments: {offenders}" + ) + + +def test_flattened_work_package_tools_have_no_wrapped_input(): + # The two hot tools were flattened to explicit params; guard against a + # regression back to a single wrapped `input` model. + import src.tools.work_packages as wp + + assert not hasattr(wp, "CreateWorkPackageInput") + assert not hasattr(wp, "UpdateWorkPackageInput") diff --git a/tests/test_integration_smoke.py b/tests/test_integration_smoke.py new file mode 100644 index 0000000..5e378e0 --- /dev/null +++ b/tests/test_integration_smoke.py @@ -0,0 +1,84 @@ +"""Live integration smoke test for the flattened work-package tools. + +Skipped by default. It exercises the real flattened payload-building end to end +(create -> read-back -> update(status) -> delete) against a live OpenProject +instance, so the change is covered beyond signatures/imports. It cleans up the +work package it creates. + +There is intentionally no standalone ``get_work_package`` tool in this server, so +the read-back uses ``search_work_packages`` (which matches by subject or ID). The +tool imports are at module top so a missing/renamed tool fails collection even +when this test is skipped. + +Enable by pointing at a disposable test project with real credentials: + + OPENPROJECT_SMOKE=1 \\ + OPENPROJECT_URL=https://your.instance \\ + OPENPROJECT_API_KEY=... \\ + OPENPROJECT_TEST_PROJECT_ID=458 \\ + OPENPROJECT_TEST_TYPE_ID=1 \\ + OPENPROJECT_TEST_STATUS_ID=... \\ # optional; exercises the status update + uv run pytest tests/test_integration_smoke.py -v +""" + +import os +import re + +import pytest + +from src.tools.work_packages import ( + create_work_package, + update_work_package, + search_work_packages, + delete_work_package, +) + +SMOKE = os.getenv("OPENPROJECT_SMOKE") == "1" +PROJECT_ID = os.getenv("OPENPROJECT_TEST_PROJECT_ID") +TYPE_ID = os.getenv("OPENPROJECT_TEST_TYPE_ID") +STATUS_ID = os.getenv("OPENPROJECT_TEST_STATUS_ID") + +pytestmark = pytest.mark.skipif( + not (SMOKE and PROJECT_ID and TYPE_ID), + reason=( + "live smoke test disabled; set OPENPROJECT_SMOKE=1 + " + "OPENPROJECT_TEST_PROJECT_ID + OPENPROJECT_TEST_TYPE_ID (and real creds)" + ), +) + + +def _wp_id(text: str) -> int: + match = re.search(r"#(\d+)", text) + assert match, f"could not parse work package id from: {text!r}" + return int(match.group(1)) + + +async def test_work_package_crud_roundtrip(): + # @mcp.tool wraps the coroutine in a FunctionTool; the callable is at `.fn`. + create = create_work_package.fn + update = update_work_package.fn + search = search_work_packages.fn + delete = delete_work_package.fn + + created = await create( + project_id=int(PROJECT_ID), + subject="[smoke] flattened create/update payload", + type_id=int(TYPE_ID), + description="Temporary work package from the integration smoke test.", + ) + assert "created successfully" in created.lower(), created + wp_id = _wp_id(created) + + try: + # No standalone get_work_package tool exists; read back via search by id. + found = await search(query=str(wp_id), project_id=int(PROJECT_ID)) + assert str(wp_id) in found, found + + kwargs = {"work_package_id": wp_id, "subject": "[smoke] updated subject"} + if STATUS_ID: + kwargs["status_id"] = int(STATUS_ID) + updated = await update(**kwargs) + assert "updated successfully" in updated.lower(), updated + finally: + deleted = await delete(wp_id) + assert "deleted successfully" in deleted.lower(), deleted diff --git a/tests/test_update_readonly.py b/tests/test_update_readonly.py new file mode 100644 index 0000000..592f560 --- /dev/null +++ b/tests/test_update_readonly.py @@ -0,0 +1,95 @@ +"""Unit coverage for update_work_package's read-only percentage_done handling. + +On some OpenProject instances percentage_done is read-only and the API rejects it +with HTTP 422 PropertyIsReadOnly. The tool should drop that field and retry so the +rest of the update still applies, without swallowing unrelated errors. These tests +mock the client, so they are network-free. +""" + +from src.tools import work_packages as wp + + +class _FakeClient: + def __init__(self): + self.calls = [] + + async def update_work_package(self, wp_id, data): + self.calls.append(dict(data)) + if "percentage_done" in data: + raise Exception( + "API Error 422: PropertyIsReadOnly - " + "Percentage done was attempted to be written but is not writable." + ) + return {"id": wp_id, "subject": "S", "_embedded": {}} + + +async def test_percentage_readonly_retries_without_field(monkeypatch): + fake = _FakeClient() + monkeypatch.setattr(wp, "get_client", lambda: fake) + + result = await wp.update_work_package.fn( + work_package_id=1, status_id=2, percentage_done=50 + ) + + assert "updated successfully" in result.lower() + assert "percentage_done is read-only" in result + # First attempt included the field; retry dropped it but kept status_id. + assert len(fake.calls) == 2 + assert "percentage_done" in fake.calls[0] + assert "percentage_done" not in fake.calls[1] + assert fake.calls[1]["status_id"] == 2 + + +async def test_percentage_readonly_only_field_returns_clear_message(monkeypatch): + fake = _FakeClient() + monkeypatch.setattr(wp, "get_client", lambda: fake) + + result = await wp.update_work_package.fn(work_package_id=1, percentage_done=50) + + assert "read-only" in result.lower() + assert "no other fields" in result.lower() + assert len(fake.calls) == 1 # no pointless retry with an empty payload + + +async def test_non_readonly_error_is_not_swallowed(monkeypatch): + class _BoomClient: + async def update_work_package(self, wp_id, data): + raise Exception("API Error 404: not found") + + monkeypatch.setattr(wp, "get_client", lambda: _BoomClient()) + + result = await wp.update_work_package.fn( + work_package_id=1, status_id=2, percentage_done=50 + ) + + assert "failed to update" in result.lower() + + +async def test_percentage_readonly_detected_via_identifier_when_localized(monkeypatch): + # Even with a translated/reworded human message, detection must still fire off + # the stable v3 errorIdentifier + attribute name in the 422 body. + class _LocalizedClient: + def __init__(self): + self.calls = [] + + async def update_work_package(self, wp_id, data): + self.calls.append(dict(data)) + if "percentage_done" in data: + raise Exception( + 'API Error 422: {"_type":"Error","errorIdentifier":' + '"urn:openproject-org:api:v3:errors:PropertyIsReadOnly",' + '"message":"Fertigstellungsgrad ist schreibgeschuetzt.",' + '"_embedded":{"details":{"attribute":"percentageDone"}}}' + ) + return {"id": wp_id, "subject": "S", "_embedded": {}} + + fake = _LocalizedClient() + monkeypatch.setattr(wp, "get_client", lambda: fake) + + result = await wp.update_work_package.fn( + work_package_id=1, status_id=2, percentage_done=50 + ) + + assert "updated successfully" in result.lower() + assert len(fake.calls) == 2 + assert "percentage_done" not in fake.calls[1] diff --git a/uv.lock b/uv.lock index 6a79137..8777d00 100644 --- a/uv.lock +++ b/uv.lock @@ -164,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beartype" version = "0.22.7" @@ -998,6 +1007,7 @@ dev = [ { name = "black" }, { name = "flake8" }, { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -1005,11 +1015,12 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.8.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, { name = "certifi", specifier = ">=2022.0.0" }, - { name = "fastmcp", specifier = ">=2.0.0" }, + { name = "fastmcp", specifier = ">=2.0.0,<3" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "starlette", specifier = ">=0.27.0" }, { name = "uvicorn", specifier = ">=0.24.0" }, @@ -1392,6 +1403,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1"