From 0be6544d86a27af98bc9bd79f2d38c0eaef25073 Mon Sep 17 00:00:00 2001 From: adampiispanen Date: Wed, 8 Oct 2025 12:55:09 -0400 Subject: [PATCH] Add ClickUp MCP server with project management, tasks, time tracking, and goals --- servers/clickup/Dockerfile | 16 + servers/clickup/README.md | 798 +++++++++++++++++++++++++++++++ servers/clickup/requirements.txt | 4 + servers/clickup/server.json | 87 ++++ servers/clickup/server.py | 545 +++++++++++++++++++++ servers/clickup/test.json | 50 ++ 6 files changed, 1500 insertions(+) create mode 100644 servers/clickup/Dockerfile create mode 100644 servers/clickup/README.md create mode 100644 servers/clickup/requirements.txt create mode 100644 servers/clickup/server.json create mode 100644 servers/clickup/server.py create mode 100644 servers/clickup/test.json diff --git a/servers/clickup/Dockerfile b/servers/clickup/Dockerfile new file mode 100644 index 0000000..478aaf2 --- /dev/null +++ b/servers/clickup/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy server code +COPY server.py . + +# Expose port +EXPOSE 8000 + +# Run the FastMCP server +CMD ["python", "-m", "fastmcp", "run", "server.py", "--transport", "streamable-http", "--port", "8000"] diff --git a/servers/clickup/README.md b/servers/clickup/README.md new file mode 100644 index 0000000..65555fb --- /dev/null +++ b/servers/clickup/README.md @@ -0,0 +1,798 @@ +# ClickUp MCP Server + +MCP server for ClickUp API. Manage projects, tasks, time tracking, goals, and team collaboration with a flexible hierarchical project management system. + +## Features + +- **Hierarchical Organization**: Workspace → Space → Folder → List → Task +- **Task Management**: Create, update, and track tasks with custom fields +- **Time Tracking**: Log time entries and estimates +- **Goals**: Set and track goals with progress metrics +- **Custom Fields**: Flexible metadata for tasks +- **Collaboration**: Comments, assignments, and notifications +- **Search**: Find tasks across your workspace +- **Statuses**: Customizable per-list status workflows + +## Setup + +### Prerequisites + +- ClickUp account (free or paid) +- API token + +### Environment Variables + +- `CLICKUP_API_TOKEN` (required): Your ClickUp API token + +**How to get credentials:** +1. Go to [app.clickup.com](https://app.clickup.com/) +2. Log in to your account +3. Click your avatar → Settings +4. Go to "Apps" in the sidebar +5. Click "Generate" under "API Token" +6. Copy the token (starts with `pk_`) +7. Store as `CLICKUP_API_TOKEN` + +Direct link: https://app.clickup.com/settings/apps + +**Important**: Keep your API token secure. It has full access to your ClickUp account. + +## Rate Limits + +- **Standard**: 100 requests per minute per token +- Rate limit headers included in responses +- HTTP 429 response when limit exceeded +- Consider caching for frequently accessed data + +## Hierarchy Overview + +ClickUp uses a hierarchical structure: + +``` +Workspace (Team) + └─ Space + ├─ Folder (optional) + │ └─ List + │ └─ Task + │ └─ Subtask + └─ List (folderless) + └─ Task +``` + +## Available Tools + +### Workspace & Space Management + +#### `list_workspaces` +List all accessible workspaces (teams). + +**Example:** +```python +workspaces = await list_workspaces() + +# Returns: +# { +# "teams": [ +# { +# "id": "123", +# "name": "My Workspace", +# "color": "#FF0000", +# "avatar": "https://...", +# "members": [...] +# } +# ] +# } +``` + +#### `list_spaces` +List spaces in a workspace. + +**Parameters:** +- `workspace_id` (string, required): Workspace ID +- `archived` (bool, optional): Include archived (default: false) + +**Example:** +```python +spaces = await list_spaces(workspace_id="123") + +# Returns: +# { +# "spaces": [ +# { +# "id": "456", +# "name": "Marketing", +# "private": false, +# "statuses": [...], +# "multiple_assignees": true, +# "features": { +# "due_dates": {"enabled": true}, +# "time_tracking": {"enabled": true} +# } +# } +# ] +# } +``` + +#### `get_space` +Get space details. + +**Parameters:** +- `space_id` (string, required): Space ID + +**Example:** +```python +space = await get_space(space_id="456") +``` + +### Folder & List Management + +#### `list_folders` +List folders in a space. + +**Parameters:** +- `space_id` (string, required): Space ID +- `archived` (bool, optional): Include archived (default: false) + +**Example:** +```python +folders = await list_folders(space_id="456") + +# Returns: +# { +# "folders": [ +# { +# "id": "789", +# "name": "Q4 Campaigns", +# "orderindex": 0, +# "task_count": 15, +# "lists": [...] +# } +# ] +# } +``` + +#### `list_lists` +List lists in a folder or space. + +**Parameters:** +- `folder_id` (string, optional): Folder ID +- `space_id` (string, optional): Space ID (for folderless lists) +- `archived` (bool, optional): Include archived (default: false) + +**Example:** +```python +# Lists in a folder +lists = await list_lists(folder_id="789") + +# Folderless lists in a space +lists = await list_lists(space_id="456") + +# Returns: +# { +# "lists": [ +# { +# "id": "abc123", +# "name": "Sprint Backlog", +# "orderindex": 0, +# "status": { +# "status": "active", +# "color": "#87909e" +# }, +# "task_count": 42 +# } +# ] +# } +``` + +### Task Management + +#### `list_tasks` +List tasks with filters. + +**Parameters:** +- `list_id` (string, required): List ID +- `archived` (bool, optional): Include archived (default: false) +- `page` (int, optional): Page number (default: 0) +- `order_by` (string, optional): Order by (created, updated, due_date) +- `reverse` (bool, optional): Reverse order (default: true) +- `subtasks` (bool, optional): Include subtasks (default: true) +- `statuses` (list, optional): Filter by status names +- `include_closed` (bool, optional): Include closed tasks (default: false) +- `assignees` (list, optional): Filter by assignee user IDs +- `tags` (list, optional): Filter by tag names +- `due_date_gt` (int, optional): Due date greater than (Unix ms) +- `due_date_lt` (int, optional): Due date less than (Unix ms) + +**Example:** +```python +# All tasks in a list +tasks = await list_tasks(list_id="abc123") + +# Open tasks assigned to specific user +tasks = await list_tasks( + list_id="abc123", + assignees=["12345"], + include_closed=False +) + +# Tasks due this week +import time +now = int(time.time() * 1000) +week_later = now + (7 * 24 * 60 * 60 * 1000) + +tasks = await list_tasks( + list_id="abc123", + due_date_gt=now, + due_date_lt=week_later +) + +# Tasks with specific status +tasks = await list_tasks( + list_id="abc123", + statuses=["in progress", "review"] +) +``` + +#### `get_task` +Get task details with custom fields. + +**Parameters:** +- `task_id` (string, required): Task ID + +**Example:** +```python +task = await get_task(task_id="xyz789") + +# Returns: +# { +# "id": "xyz789", +# "name": "Implement user authentication", +# "description": "Add OAuth 2.0 support", +# "status": { +# "status": "in progress", +# "color": "#d3d3d3" +# }, +# "orderindex": "1.00", +# "date_created": "1633024800000", +# "date_updated": "1633111200000", +# "date_closed": null, +# "creator": {"id": 123, "username": "user"}, +# "assignees": [{"id": 456, "username": "dev"}], +# "tags": [{"name": "backend", "tag_fg": "#000", "tag_bg": "#FFF"}], +# "parent": null, +# "priority": 2, +# "due_date": "1633197600000", +# "start_date": "1633024800000", +# "time_estimate": 7200000, +# "time_spent": 3600000, +# "custom_fields": [...], +# "list": {"id": "abc123", "name": "Sprint"}, +# "folder": {"id": "789", "name": "Engineering"}, +# "space": {"id": "456"}, +# "url": "https://app.clickup.com/t/xyz789" +# } +``` + +#### `create_task` +Create a new task. + +**Parameters:** +- `list_id` (string, required): List ID +- `name` (string, required): Task name +- `description` (string, optional): Task description +- `assignees` (list, optional): Assignee user IDs +- `tags` (list, optional): Tag names +- `status` (string, optional): Status name +- `priority` (int, optional): Priority (1=urgent, 2=high, 3=normal, 4=low) +- `due_date` (int, optional): Due date (Unix timestamp ms) +- `due_date_time` (bool, optional): Include time (default: false) +- `time_estimate` (int, optional): Time estimate in milliseconds +- `start_date` (int, optional): Start date (Unix timestamp ms) +- `start_date_time` (bool, optional): Include time (default: false) +- `notify_all` (bool, optional): Notify assignees (default: true) +- `parent` (string, optional): Parent task ID (for subtasks) +- `custom_fields` (list, optional): Custom field objects + +**Priority Levels:** +- **1**: Urgent (red flag) +- **2**: High (yellow flag) +- **3**: Normal (blue flag, default) +- **4**: Low (gray flag) + +**Example:** +```python +# Simple task +task = await create_task( + list_id="abc123", + name="Write API documentation" +) + +# Full task with all options +import time +tomorrow = int((time.time() + 86400) * 1000) + +task = await create_task( + list_id="abc123", + name="Deploy to production", + description="Deploy version 2.0.0 with new features", + assignees=[12345, 67890], + tags=["deployment", "urgent"], + status="todo", + priority=1, + due_date=tomorrow, + due_date_time=True, + time_estimate=3600000, # 1 hour in ms + notify_all=True +) + +# Subtask +subtask = await create_task( + list_id="abc123", + name="Run database migrations", + parent="xyz789", # Parent task ID + assignees=[12345], + priority=2 +) + +# Task with custom fields +task = await create_task( + list_id="abc123", + name="Bug fix: Login issue", + custom_fields=[ + {"id": "field_123", "value": "Bug"}, + {"id": "field_456", "value": "High"} + ] +) +``` + +#### `update_task` +Update task details. + +**Parameters:** +- `task_id` (string, required): Task ID +- `name` (string, optional): Updated name +- `description` (string, optional): Updated description +- `status` (string, optional): Updated status +- `priority` (int, optional): Updated priority (1-4) +- `due_date` (int, optional): Updated due date (Unix ms) +- `time_estimate` (int, optional): Updated time estimate (ms) +- `assignees` (dict, optional): Assignees {"add": [ids], "rem": [ids]} + +**Example:** +```python +# Update task status +task = await update_task( + task_id="xyz789", + status="in progress" +) + +# Add assignees +task = await update_task( + task_id="xyz789", + assignees={"add": [12345, 67890]} +) + +# Remove assignees +task = await update_task( + task_id="xyz789", + assignees={"rem": [12345]} +) + +# Update priority and due date +import time +next_week = int((time.time() + 7 * 86400) * 1000) + +task = await update_task( + task_id="xyz789", + priority=1, + due_date=next_week +) +``` + +#### `delete_task` +Delete a task. + +**Parameters:** +- `task_id` (string, required): Task ID + +**Example:** +```python +result = await delete_task(task_id="xyz789") +``` + +### Comments + +#### `add_task_comment` +Add comment to a task. + +**Parameters:** +- `task_id` (string, required): Task ID +- `comment_text` (string, required): Comment text +- `assignee` (int, optional): Assign comment to user ID +- `notify_all` (bool, optional): Notify all assignees (default: true) + +**Example:** +```python +# Simple comment +comment = await add_task_comment( + task_id="xyz789", + comment_text="Great progress on this task!" +) + +# Comment with assignment +comment = await add_task_comment( + task_id="xyz789", + comment_text="Can you review this?", + assignee=12345, + notify_all=True +) +``` + +#### `list_task_comments` +Get task comments. + +**Parameters:** +- `task_id` (string, required): Task ID + +**Example:** +```python +comments = await list_task_comments(task_id="xyz789") + +# Returns: +# { +# "comments": [ +# { +# "id": "123", +# "comment_text": "Great work!", +# "user": {"id": 456, "username": "user"}, +# "date": "1633024800000" +# } +# ] +# } +``` + +### Time Tracking + +#### `create_time_entry` +Track time on a task. + +**Parameters:** +- `task_id` (string, required): Task ID +- `duration` (int, required): Duration in milliseconds +- `start` (int, optional): Start time (Unix ms, defaults to now) +- `description` (string, optional): Time entry description + +**Example:** +```python +# Log 2 hours of work +two_hours_ms = 2 * 60 * 60 * 1000 + +time_entry = await create_time_entry( + task_id="xyz789", + duration=two_hours_ms, + description="Implemented OAuth integration" +) + +# Log time with specific start time +import time +start_time = int((time.time() - 7200) * 1000) # 2 hours ago + +time_entry = await create_time_entry( + task_id="xyz789", + duration=two_hours_ms, + start=start_time +) +``` + +#### `list_time_entries` +Get time tracking entries. + +**Parameters:** +- `workspace_id` (string, required): Workspace ID +- `start_date` (int, optional): Filter by start date (Unix ms) +- `end_date` (int, optional): Filter by end date (Unix ms) +- `assignee` (int, optional): Filter by assignee user ID + +**Example:** +```python +# All time entries +entries = await list_time_entries(workspace_id="123") + +# Time entries for this week +import time +week_ago = int((time.time() - 7 * 86400) * 1000) +now = int(time.time() * 1000) + +entries = await list_time_entries( + workspace_id="123", + start_date=week_ago, + end_date=now +) + +# Time entries for specific user +entries = await list_time_entries( + workspace_id="123", + assignee=12345 +) +``` + +### Goals + +#### `list_goals` +List goals in a workspace. + +**Parameters:** +- `workspace_id` (string, required): Workspace ID + +**Example:** +```python +goals = await list_goals(workspace_id="123") + +# Returns: +# { +# "goals": [ +# { +# "id": "goal_123", +# "name": "Q4 Revenue Target", +# "due_date": "1640995200000", +# "description": "Reach $1M ARR", +# "percent_completed": 75, +# "color": "#32a852" +# } +# ] +# } +``` + +#### `get_goal` +Get goal details and progress. + +**Parameters:** +- `goal_id` (string, required): Goal ID + +**Example:** +```python +goal = await get_goal(goal_id="goal_123") + +# Returns: +# { +# "goal": { +# "id": "goal_123", +# "name": "Q4 Revenue Target", +# "description": "Reach $1M ARR", +# "due_date": "1640995200000", +# "color": "#32a852", +# "percent_completed": 75, +# "key_results": [ +# { +# "id": "kr_456", +# "name": "Close 10 enterprise deals", +# "type": "number", +# "current": 7, +# "target": 10, +# "percent_completed": 70 +# } +# ] +# } +# } +``` + +### Custom Fields + +#### `list_custom_fields` +Get custom fields for a list. + +**Parameters:** +- `list_id` (string, required): List ID + +**Example:** +```python +fields = await list_custom_fields(list_id="abc123") + +# Returns: +# { +# "fields": [ +# { +# "id": "field_123", +# "name": "Priority", +# "type": "drop_down", +# "type_config": { +# "options": [ +# {"id": "opt_1", "name": "High", "color": "#FF0000"}, +# {"id": "opt_2", "name": "Low", "color": "#00FF00"} +# ] +# } +# }, +# { +# "id": "field_456", +# "name": "Story Points", +# "type": "number" +# } +# ] +# } +``` + +**Custom Field Types:** +- `text`: Text input +- `number`: Numeric input +- `drop_down`: Dropdown selection +- `date`: Date picker +- `checkbox`: Boolean checkbox +- `url`: URL input +- `email`: Email input +- `phone`: Phone number +- `currency`: Currency value + +### Search + +#### `search_tasks` +Search tasks across workspace. + +**Parameters:** +- `workspace_id` (string, required): Workspace ID +- `query` (string, required): Search query text +- `start_date` (int, optional): Filter by start date (Unix ms) +- `end_date` (int, optional): Filter by end date (Unix ms) +- `assignees` (list, optional): Filter by assignee user IDs +- `statuses` (list, optional): Filter by status names +- `tags` (list, optional): Filter by tag names + +**Example:** +```python +# Search by keyword +tasks = await search_tasks( + workspace_id="123", + query="authentication" +) + +# Advanced search with filters +tasks = await search_tasks( + workspace_id="123", + query="bug", + assignees=[12345], + statuses=["in progress"], + tags=["urgent"] +) +``` + +## Common Workflows + +### Project Setup +```python +# 1. Get workspace +workspaces = await list_workspaces() +workspace_id = workspaces["teams"][0]["id"] + +# 2. Create or get space +spaces = await list_spaces(workspace_id=workspace_id) +space_id = spaces["spaces"][0]["id"] + +# 3. Get lists +lists = await list_lists(space_id=space_id) +list_id = lists["lists"][0]["id"] + +# 4. Create tasks +await create_task( + list_id=list_id, + name="Set up development environment", + priority=2 +) + +await create_task( + list_id=list_id, + name="Write technical documentation", + priority=3 +) +``` + +### Sprint Planning +```python +# Get all tasks +tasks = await list_tasks(list_id="abc123") + +# Sort by priority +sorted_tasks = sorted( + tasks["tasks"], + key=lambda t: t.get("priority", 3) +) + +# Assign to team members +team_members = [12345, 67890, 11111] + +for i, task in enumerate(sorted_tasks[:10]): + assignee = team_members[i % len(team_members)] + await update_task( + task_id=task["id"], + assignees={"add": [assignee]}, + status="todo" + ) +``` + +### Time Tracking Report +```python +import time + +# Get this week's time entries +week_ago = int((time.time() - 7 * 86400) * 1000) +now = int(time.time() * 1000) + +entries = await list_time_entries( + workspace_id="123", + start_date=week_ago, + end_date=now +) + +# Calculate total hours +total_ms = sum(entry["duration"] for entry in entries["data"]) +total_hours = total_ms / (1000 * 60 * 60) + +print(f"Total hours this week: {total_hours:.2f}") +``` + +### Goal Tracking +```python +# List all goals +goals = await list_goals(workspace_id="123") + +# Check progress on each goal +for goal in goals["goals"]: + goal_detail = await get_goal(goal_id=goal["id"]) + + print(f"Goal: {goal_detail['goal']['name']}") + print(f"Progress: {goal_detail['goal']['percent_completed']}%") + + for kr in goal_detail['goal']['key_results']: + print(f" - {kr['name']}: {kr['current']}/{kr['target']}") +``` + +## Best Practices + +1. **Use hierarchy effectively**: Organize with Spaces → Folders → Lists +2. **Custom statuses**: Set up workflows per list +3. **Custom fields**: Add metadata for filtering and reporting +4. **Time tracking**: Log time regularly for accurate estimates +5. **Tags**: Use tags for cross-list categorization +6. **Goals**: Set measurable goals with key results +7. **Priorities**: Use priority levels consistently +8. **Assignees**: Assign tasks for accountability +9. **Comments**: Communicate within tasks +10. **Search**: Use search for cross-workspace queries + +## Rate Limit Handling + +```python +import asyncio + +async def make_request_with_retry(): + try: + return await list_tasks(list_id="abc123") + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + # Wait and retry + await asyncio.sleep(60) + return await list_tasks(list_id="abc123") + raise +``` + +## Error Handling + +Common errors: + +- **401 Unauthorized**: Invalid API token +- **403 Forbidden**: Insufficient permissions +- **404 Not Found**: Resource not found +- **429 Too Many Requests**: Rate limit exceeded (100 req/min) +- **500 Internal Server Error**: ClickUp service issue + +## API Documentation + +- [ClickUp API Documentation](https://clickup.com/api/) +- [Authentication](https://clickup.com/api/developer-portal/authentication/) +- [Tasks API](https://clickup.com/api/clickupreference/operation/GetTasks/) +- [Time Tracking](https://clickup.com/api/clickupreference/operation/Gettimeentrieswithinadaterange/) + +## Support + +- [Help Center](https://help.clickup.com/) +- [API Support](https://clickup.com/contact) +- [Community](https://clickup.com/community) +- [Status Page](https://status.clickup.com/) diff --git a/servers/clickup/requirements.txt b/servers/clickup/requirements.txt new file mode 100644 index 0000000..42f0167 --- /dev/null +++ b/servers/clickup/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=0.2.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 +uvicorn>=0.30.0 diff --git a/servers/clickup/server.json b/servers/clickup/server.json new file mode 100644 index 0000000..66e26fe --- /dev/null +++ b/servers/clickup/server.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://registry.nimbletools.ai/schemas/2025-09-22/nimbletools-server.schema.json", + "name": "ai.nimbletools/clickup", + "version": "1.0.0", + "description": "ClickUp API: project management, tasks, time tracking, goals, and team collaboration", + "status": "active", + "repository": { + "url": "https://github.com/NimbleBrainInc/mcp-clickup", + "source": "github", + "branch": "main" + }, + "websiteUrl": "https://clickup.com/", + "packages": [ + { + "registryType": "oci", + "registryBaseUrl": "https://docker.io", + "identifier": "nimbletools/mcp-clickup", + "version": "1.0.0", + "transport": { + "type": "streamable-http", + "url": "https://mcp.nimbletools.ai/mcp" + }, + "environmentVariables": [ + { + "name": "CLICKUP_API_TOKEN", + "description": "ClickUp API token (get from https://app.clickup.com/settings/apps)", + "isRequired": true, + "isSecret": true, + "example": "pk_..." + } + ] + } + ], + "_meta": { + "ai.nimbletools.mcp/v1": { + "container": { + "healthCheck": { + "path": "/health", + "port": 8000 + } + }, + "capabilities": { + "tools": true, + "resources": false, + "prompts": false + }, + "resources": { + "limits": { + "memory": "256Mi", + "cpu": "250m" + }, + "requests": { + "memory": "128Mi", + "cpu": "100m" + } + }, + "deployment": { + "protocol": "http", + "port": 8000, + "mcpPath": "/mcp" + }, + "display": { + "name": "ClickUp", + "category": "business-finance", + "tags": [ + "clickup", + "project-management", + "tasks", + "time-tracking", + "goals", + "collaboration", + "productivity", + "agile", + "scrum", + "requires-api-key" + ], + "branding": { + "logoUrl": "https://static.nimbletools.ai/logos/clickup.png", + "iconUrl": "https://static.nimbletools.ai/icons/clickup.png" + }, + "documentation": { + "readmeUrl": "https://raw.githubusercontent.com/NimbleBrainInc/mcp-clickup/main/README.md" + } + } + } + } +} diff --git a/servers/clickup/server.py b/servers/clickup/server.py new file mode 100644 index 0000000..08086b5 --- /dev/null +++ b/servers/clickup/server.py @@ -0,0 +1,545 @@ +import os +from typing import Optional, List, Dict, Any +import httpx +from fastmcp import FastMCP + +mcp = FastMCP("ClickUp") + +API_TOKEN = os.getenv("CLICKUP_API_TOKEN") +BASE_URL = "https://api.clickup.com/api/v2" + + +def get_headers() -> dict: + """Get headers with API token authentication.""" + return { + "Authorization": API_TOKEN, + "Content-Type": "application/json" + } + + +@mcp.tool() +async def list_workspaces() -> dict: + """List all accessible workspaces (teams).""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/team", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_spaces( + workspace_id: str, + archived: bool = False +) -> dict: + """List spaces in a workspace. + + Args: + workspace_id: Workspace (team) ID + archived: Include archived spaces (default: false) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/team/{workspace_id}/space", + headers=get_headers(), + params={"archived": str(archived).lower()} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_space(space_id: str) -> dict: + """Get space details. + + Args: + space_id: Space ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/space/{space_id}", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_folders( + space_id: str, + archived: bool = False +) -> dict: + """List folders in a space. + + Args: + space_id: Space ID + archived: Include archived folders (default: false) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/space/{space_id}/folder", + headers=get_headers(), + params={"archived": str(archived).lower()} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_lists( + folder_id: Optional[str] = None, + space_id: Optional[str] = None, + archived: bool = False +) -> dict: + """List lists in a folder or space. + + Args: + folder_id: Folder ID (provide either folder_id or space_id) + space_id: Space ID (for folderless lists) + archived: Include archived lists (default: false) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + if folder_id: + url = f"{BASE_URL}/folder/{folder_id}/list" + elif space_id: + url = f"{BASE_URL}/space/{space_id}/list" + else: + raise ValueError("Either folder_id or space_id must be provided") + + response = await client.get( + url, + headers=get_headers(), + params={"archived": str(archived).lower()} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_tasks( + list_id: str, + archived: bool = False, + page: int = 0, + order_by: str = "created", + reverse: bool = True, + subtasks: bool = True, + statuses: Optional[List[str]] = None, + include_closed: bool = False, + assignees: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + due_date_gt: Optional[int] = None, + due_date_lt: Optional[int] = None +) -> dict: + """List tasks with filters. + + Args: + list_id: List ID + archived: Include archived tasks (default: false) + page: Page number (default: 0) + order_by: Order by field (created, updated, due_date) + reverse: Reverse order (default: true) + subtasks: Include subtasks (default: true) + statuses: Filter by status names + include_closed: Include closed tasks (default: false) + assignees: Filter by assignee user IDs + tags: Filter by tag names + due_date_gt: Due date greater than (Unix timestamp ms) + due_date_lt: Due date less than (Unix timestamp ms) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + params = { + "archived": str(archived).lower(), + "page": page, + "order_by": order_by, + "reverse": str(reverse).lower(), + "subtasks": str(subtasks).lower(), + "include_closed": str(include_closed).lower() + } + + if statuses: + params["statuses[]"] = statuses + if assignees: + params["assignees[]"] = assignees + if tags: + params["tags[]"] = tags + if due_date_gt: + params["due_date_gt"] = due_date_gt + if due_date_lt: + params["due_date_lt"] = due_date_lt + + response = await client.get( + f"{BASE_URL}/list/{list_id}/task", + headers=get_headers(), + params=params + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_task(task_id: str) -> dict: + """Get task details with custom fields. + + Args: + task_id: Task ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/task/{task_id}", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def create_task( + list_id: str, + name: str, + description: Optional[str] = None, + assignees: Optional[List[int]] = None, + tags: Optional[List[str]] = None, + status: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[int] = None, + due_date_time: bool = False, + time_estimate: Optional[int] = None, + start_date: Optional[int] = None, + start_date_time: bool = False, + notify_all: bool = True, + parent: Optional[str] = None, + custom_fields: Optional[List[Dict[str, Any]]] = None +) -> dict: + """Create a new task. + + Args: + list_id: List ID + name: Task name (required) + description: Task description + assignees: List of assignee user IDs + tags: List of tag names + status: Status name + priority: Priority (1=urgent, 2=high, 3=normal, 4=low) + due_date: Due date (Unix timestamp ms) + due_date_time: Include time in due date (default: false) + time_estimate: Time estimate in milliseconds + start_date: Start date (Unix timestamp ms) + start_date_time: Include time in start date (default: false) + notify_all: Notify all assignees (default: true) + parent: Parent task ID (for subtasks) + custom_fields: List of custom field objects + """ + async with httpx.AsyncClient(timeout=30.0) as client: + payload = { + "name": name, + "notify_all": notify_all + } + + if description: + payload["description"] = description + if assignees: + payload["assignees"] = assignees + if tags: + payload["tags"] = tags + if status: + payload["status"] = status + if priority: + payload["priority"] = priority + if due_date: + payload["due_date"] = due_date + payload["due_date_time"] = due_date_time + if time_estimate: + payload["time_estimate"] = time_estimate + if start_date: + payload["start_date"] = start_date + payload["start_date_time"] = start_date_time + if parent: + payload["parent"] = parent + if custom_fields: + payload["custom_fields"] = custom_fields + + response = await client.post( + f"{BASE_URL}/list/{list_id}/task", + headers=get_headers(), + json=payload + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def update_task( + task_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + status: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[int] = None, + time_estimate: Optional[int] = None, + assignees: Optional[Dict[str, List[int]]] = None +) -> dict: + """Update task details. + + Args: + task_id: Task ID + name: Updated task name + description: Updated description + status: Updated status + priority: Updated priority (1-4) + due_date: Updated due date (Unix timestamp ms) + time_estimate: Updated time estimate (ms) + assignees: Assignees object {"add": [user_ids], "rem": [user_ids]} + """ + async with httpx.AsyncClient(timeout=30.0) as client: + payload = {} + + if name: + payload["name"] = name + if description: + payload["description"] = description + if status: + payload["status"] = status + if priority: + payload["priority"] = priority + if due_date: + payload["due_date"] = due_date + if time_estimate: + payload["time_estimate"] = time_estimate + if assignees: + payload["assignees"] = assignees + + response = await client.put( + f"{BASE_URL}/task/{task_id}", + headers=get_headers(), + json=payload + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def delete_task(task_id: str) -> dict: + """Delete a task. + + Args: + task_id: Task ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.delete( + f"{BASE_URL}/task/{task_id}", + headers=get_headers() + ) + response.raise_for_status() + return {"success": True, "task_id": task_id} + + +@mcp.tool() +async def add_task_comment( + task_id: str, + comment_text: str, + assignee: Optional[int] = None, + notify_all: bool = True +) -> dict: + """Add comment to a task. + + Args: + task_id: Task ID + comment_text: Comment text + assignee: Assign comment to user ID + notify_all: Notify all task assignees (default: true) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + payload = { + "comment_text": comment_text, + "notify_all": notify_all + } + + if assignee: + payload["assignee"] = assignee + + response = await client.post( + f"{BASE_URL}/task/{task_id}/comment", + headers=get_headers(), + json=payload + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_task_comments(task_id: str) -> dict: + """Get task comments. + + Args: + task_id: Task ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/task/{task_id}/comment", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def create_time_entry( + task_id: str, + duration: int, + start: Optional[int] = None, + description: Optional[str] = None +) -> dict: + """Track time on a task. + + Args: + task_id: Task ID + duration: Duration in milliseconds + start: Start time (Unix timestamp ms, defaults to now) + description: Time entry description + """ + async with httpx.AsyncClient(timeout=30.0) as client: + payload = {"duration": duration} + + if start: + payload["start"] = start + if description: + payload["description"] = description + + response = await client.post( + f"{BASE_URL}/task/{task_id}/time", + headers=get_headers(), + json=payload + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_time_entries( + workspace_id: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + assignee: Optional[int] = None +) -> dict: + """Get time tracking entries. + + Args: + workspace_id: Workspace (team) ID + start_date: Filter by start date (Unix timestamp ms) + end_date: Filter by end date (Unix timestamp ms) + assignee: Filter by assignee user ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + params = {} + if start_date: + params["start_date"] = start_date + if end_date: + params["end_date"] = end_date + if assignee: + params["assignee"] = assignee + + response = await client.get( + f"{BASE_URL}/team/{workspace_id}/time_entries", + headers=get_headers(), + params=params + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_goals(workspace_id: str) -> dict: + """List goals in a workspace. + + Args: + workspace_id: Workspace (team) ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/team/{workspace_id}/goal", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_goal(goal_id: str) -> dict: + """Get goal details and progress. + + Args: + goal_id: Goal ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/goal/{goal_id}", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_custom_fields(list_id: str) -> dict: + """Get custom fields for a list. + + Args: + list_id: List ID + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BASE_URL}/list/{list_id}/field", + headers=get_headers() + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def search_tasks( + workspace_id: str, + query: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + assignees: Optional[List[int]] = None, + statuses: Optional[List[str]] = None, + tags: Optional[List[str]] = None +) -> dict: + """Search tasks across workspace. + + Args: + workspace_id: Workspace (team) ID + query: Search query text + start_date: Filter by start date (Unix timestamp ms) + end_date: Filter by end date (Unix timestamp ms) + assignees: Filter by assignee user IDs + statuses: Filter by status names + tags: Filter by tag names + """ + async with httpx.AsyncClient(timeout=30.0) as client: + params = {"query": query} + + if start_date: + params["start_date"] = start_date + if end_date: + params["end_date"] = end_date + if assignees: + params["assignees[]"] = assignees + if statuses: + params["statuses[]"] = statuses + if tags: + params["tags[]"] = tags + + response = await client.get( + f"{BASE_URL}/team/{workspace_id}/task", + headers=get_headers(), + params=params + ) + response.raise_for_status() + return response.json() + + +if __name__ == "__main__": + mcp.run() diff --git a/servers/clickup/test.json b/servers/clickup/test.json new file mode 100644 index 0000000..0652340 --- /dev/null +++ b/servers/clickup/test.json @@ -0,0 +1,50 @@ +{ + "tests": [ + { + "name": "List Workspaces", + "tool": "list_workspaces", + "params": {}, + "expectedFields": [ + "teams" + ], + "assertions": [ + { + "type": "exists", + "path": "teams" + } + ] + }, + { + "name": "List Spaces", + "tool": "list_spaces", + "params": { + "workspace_id": "test_workspace_id" + }, + "expectedFields": [ + "spaces" + ], + "assertions": [ + { + "type": "exists", + "path": "spaces" + } + ] + }, + { + "name": "List Tasks", + "tool": "list_tasks", + "params": { + "list_id": "test_list_id" + }, + "expectedFields": [ + "tasks" + ], + "assertions": [ + { + "type": "exists", + "path": "tasks" + } + ] + } + ] +}