clickup-mcp
ClickUp MCP server for Claude — exposes ClickUp tasks, spaces, folders, lists, and comments as MCP tools.
Tech stack: Python 3.12 + uv + FastMCP (Starlette/FastAPI)
MSPbots Connector
The MCP Gateway built for MSPs: visible, controlled, and your data stays where it belongs.
MSPbots Connector gives your AI clients one MCP URL to reach your entire MSP stack (RMM, PSA, documentation, security, finance), and gives you control over who can call which tool and a record of everything that was called.
Hosted service: connect.mspbots.ai · Free to use.
Why Connector

| Visible | Every tool call is logged: who, which vendor, when. Usage dashboard, CSV export, SIEM streaming. |
| Controlled | Role-based, per-tool allowlist. Deny by default. |
| Protected | Credentials encrypted per record. AI clients never see your vendor passwords or tokens. |
| MSP-native | Built for the MSP stack only: RMM, PSA, documentation, security, finance. |
Quick start
- Sign up at connect.mspbots.ai.
- Connect your vendors. Pick from 150+ connectors and authorize each one on the vendor's own page, or bring your own API keys. No extra vendor accounts needed.
- Set access. Create roles and decide which tools each role may call. Nothing is allowed until you allow it.
- Add your MCP URL to your AI client. Copy the URL from your Connector dashboard into Claude, ChatGPT, Cursor, VS Code, Gemini, Codex, or any MCP-capable client.
- Ask your AI to do real work, and watch every call appear in the audit log.
Features
Unified gateway
- One MCP URL for all your AI clients
- 150+ connectors across the MSP stack
- Custom MCP servers supported, free
- Authorization happens on each vendor's own page; the AI never sees passwords or tokens
- Bring your own credentials: use the vendor API keys you already have
- Multiple accounts per app; connect once, reuse across AI clients
- Tool search: finds the right action for the task
- One sign-in; vendor credentials are injected behind the scenes and OAuth tokens refresh automatically
Visibility
- Audit log for every tool call (who, which vendor, when)
- Usage dashboard: call volume, top vendors, error rate, per-user activity
- Export audit logs to CSV, or stream to Loki, Graylog, or LogScale (SIEM)
- Optional recording of tool arguments and result summaries (off by default, enabled per organization)
Control
- Per-role, per-tool allowlist, deny by default
- owner / admin / member roles
- SCIM provisioning with Entra ID, Okta, JumpCloud, and Google Workspace
Security
- Credentials encrypted one record at a time (AES-256-GCM, per-record key)
- AI clients authenticate with OAuth 2.1 + PKCE
- Vendor responses are scanned for prompt-injection patterns and returned as structured data. This is one layer of defense, not a guarantee against every attack.
- Static egress IP for customer firewall and API allowlisting
Pricing
Free.
Links: connect.mspbots.ai · mspbots.ai
Quick Start
# Install dependencies
cd D:\leo\mcp-server\clickup-mcp
uv sync
# Run in HTTP/gateway mode (the default) — each request supplies its own
# token via the X-Clickup-Token header, nothing to set locally
uv run clickup-mcp
# Or run in stdio mode for local single-user tools like Claude Desktop
$env:MCP_TRANSPORT="stdio"
$env:CLICKUP_API_TOKEN="pk_xxxxx"
uv run clickup-mcp
Configuration
Copy .env.example to .env and fill in your values:
| Variable | Default | Description |
|---|---|---|
MCP_TRANSPORT |
http |
http (gateway, default) or stdio (Claude Desktop / local dev only) |
CLICKUP_API_TOKEN |
— | ClickUp personal API token (pk_xxxxx). Only read when MCP_TRANSPORT=stdio — the HTTP/gateway path never reads it, by design (no header = 401, never a silent fallback to this value) |
MCP_HTTP_PORT |
8080 |
HTTP server port |
CLICKUP_BASE_URL |
https://api.clickup.com/api/v2 |
API base URL |
Note: there is no AUTH_MODE setting anymore. HTTP transport is alwaysgateway mode (per-request X-Clickup-Token header, enforced by middleware);stdio transport is always local-token mode. Credential source now followstransport directly, so there is no way to accidentally run an HTTP/gatewaydeployment that falls back to a shared env-var token.
Get your API token: ClickUp → Settings → Apps → API Token
Claude Desktop Setup
Add to claude_desktop_config.json:
{
"mcpServers": {
"clickup": {
"command": "uv",
"args": ["run", "--directory", "D:/leo/mcp-server/clickup-mcp", "clickup-mcp"],
"env": {
"CLICKUP_API_TOKEN": "pk_xxxxx"
}
}
}
}
Transport Modes
HTTP — gateway / multi-tenant (default)
uv run clickup-mcp
# Each request must include: X-Clickup-Token: pk_xxxxx
# A request with no header gets 401 — there is no env-var fallback.
stdio (Claude Desktop / CLI, local dev only)
$env:MCP_TRANSPORT="stdio"
$env:CLICKUP_API_TOKEN="pk_xxxxx"
uv run clickup-mcp
Available Tools (28)
| Tool | Description |
|---|---|
clickup_get_workspaces |
List all workspaces/teams |
clickup_list_members |
List workspace members, flattened to id/username/email/team_id/role — resolve a person's email to the user_id assignees filters expect |
clickup_list_spaces |
List spaces in a workspace, or across every workspace the token can see when team_id is omitted |
clickup_get_space |
Get space details |
clickup_get_space_folders |
List folders in a space |
clickup_get_space_lists |
List folderless lists in a space |
clickup_get_folder |
Get folder details |
clickup_get_folder_lists |
List lists in a folder |
clickup_create_folder |
Create a folder |
clickup_update_folder |
Update a folder |
clickup_delete_folder |
Delete a folder |
clickup_get_list |
Get list details |
clickup_create_list_in_folder |
Create list in a folder |
clickup_create_folderless_list |
Create list in a space |
clickup_update_list |
Update a list |
clickup_get_task |
Get task by native ID, or by custom ID with the workspace resolved automatically |
clickup_search_tasks |
Search tasks with filters; searches every workspace the token can see when team_id is omitted |
clickup_list_tasks_for_person |
List a person's tasks across ALL visible workspaces in one call, by email or user_id — no team_id, no manual pagination/dedup needed |
clickup_create_task |
Create a task |
clickup_update_task |
Update a task |
clickup_delete_task |
Delete a task |
clickup_move_task |
Move task to a different list |
clickup_get_task_comments |
Get task comments |
clickup_create_task_comment |
Add a comment to a task |
clickup_get_doc_page |
Get a single page from a Doc (v3); workspace_id optional |
clickup_attach_task_file |
Upload a file (e.g. an image) as an attachment on a task |
clickup_create_comment_with_image |
Upload a file and post it inline inside a new task comment, in one call |
clickup_list_rocks_for_org |
List all EOS Rocks (quarterly goals) org-wide in one call, normalized to a fixed status enum |
Workspace (team) IDs are resolved from the token
No tool requires a workspace/team ID. GET /team already tells the server everyworkspace the API token is authorized for, so the server resolves it rather thanasking the caller to supply an ID it has no way of knowing.
- Omit
team_idand the call covers every workspace the token can reach. Readsfan out and merge; each returned row carries its ownteam_id. - Pass a
team_idthe token cannot see and, when only one workspace exists, itis substituted and the response reportsteam_id_corrected. - When several workspaces exist and the given ID matches none, the error liststhe legal ones in
authorized_workspaces— so one retry is enough. Writes(clickup_attach_task_file) never guess between workspaces.
A workspace the token cannot reach is reported as invalid_argument, notunauthorized; only a genuinely bad token yields unauthorized.
Finding a person's ClickUp user ID
Use clickup_list_members. ClickUp's native GET /team response embeds afull member list per team (teams[].members[].user.{id,username,email}), butclickup_get_workspaces strips that out to keep its response small, so it isnot the place to look up people. clickup_list_members reads the sameunderlying endpoint and projects the member list to a flat, purpose-builtshape (id/username/email/team_id/role) so callers don't have to dig it out ofthe full workspace/team object themselves. clickup_list_tasks_for_personuses the same underlying lookup internally to resolve email -> user_id.
Known gap: ClickUp's team-member object has no reliable "is this memberdeactivated" field — clickup_list_members does not return an activefield, since nothing real would back it (the only status field presenton the raw object, invited_by.status, describes the inviter, not themember).
clickup_search_tasks already returns status.type
Like every other read tool here, clickup_search_tasks andclickup_get_task pass through ClickUp's raw task object unmodified —including the status object's type field (open / custom / closed /done), which is the only reliable way to tell whether a custom-namedstatus counts as done. No code change was needed for this; it was alreadythere. clickup_list_tasks_for_person surfaces it explicitly asstatus_type on each returned task for convenience.
How EOS Rocks are represented in this ClickUp workspace
Confirmed 2026-08-18 by inspecting a real rock task's fields directly (notguessed): Rocks are regular ClickUp tasks living in a list literally named"Rocks" (found under Space "Company" > Folder "EOS Traction"), eachcarrying dedicated custom fields: Quarter (dropdown, "Q1 2024".."Q4 2026"),Rocks Status (On Hold / Off Track / On Track / Completed / Blocked / AtRisk), Rock Type (Company / Individual / Departmental / Team Rock),Department, and progress via either Progress (manual) or Progress %(auto, checklist-rollup). This is neither the ClickUp Goals API nor aplain task list with no metadata — it's tasks-plus-custom-fields.
clickup_list_rocks_for_org discovers every list named "Rocks" (by name,not a hardcoded ID, in case spaces/folders get reorganized) across everyworkspace visible to the token, reads these fields, and normalizes them:
quarter: ClickUp's "Q3 2026" label is converted to2026-Q3(and back,for thequarterinput filter).status: ClickUp's 6 raw options are mapped down to the 5-value contract(on_track/off_track/done/missed/open) — see the_STATUS_MAPcomment inrocks.pyfor the exact mapping and whymissedis neveremitted (nothing in ClickUp's data distinguishes "ran out of time" fromgeneric "off track"; deriving it from an overdue due_date would be anunconfirmed business-logic assumption, so it isn't done here). The rawClickUp label (e.g."At Risk") is also returned asstatus_raw,alongside the normalizedstatus, so a UI can show ClickUp's own wordingwithout it looking out of sync with the mapped value.measurable: no dedicated field exists on this list. Falls back tothe task description;nullif that's empty too (never fabricated).weekly_status: no structured source was found anywhere (not acustom field, nothing comment-derived either) — always returned as[].If the org starts tracking this in ClickUp some other way, revisit.owner_email/owner_user_id(optional filter args): this tool isorg-wide by default (every rock returned), but a caller building asingle-person view can pass either to scope the results to one owner'srocks — the org-wide fetch itself still happens (this doesn't reduceupstream ClickUp API calls), it just narrows what's returned. Omit bothfor the original org-wide behavior.
Discovery performance: parallel, not sequential
clickup_list_rocks_for_org's list-discovery walk (every workspace, everyspace, each space's folderless lists + folder lists) runs concurrently viaasyncio.gather, not one request after another. A sequential version ofthis was measured to time out against MSPbots' own workspace (15+ spacesx 2 calls each, run one at a time, comfortably exceeded the caller's MCPtimeout) — parallelizing brought it down to ~7s. ClickUp's rate limit(developer.clickup.com/docs/rate-limits) is a per-minute budget with noseparate burst cap (100/min on the lowest plan tier), and this fires onthe order of 2 x (space count) requests once, so a few dozen concurrentcalls stays well inside it even combined with other concurrent usage ofthe same token — api_client.py's shared retry logic (see below) alsobacks off on a real 429 rather than assuming this burst is the onlytraffic on the token.
Rate-limit retries are centralized, not per-tool
Every tool's HTTP calls go through ClickUpClient's shared _requestmethod (api_client.py), which retries 429/500/502/503/504with backoff — added here once so every tool benefits, since a token'srate-limit budget is shared across whatever else is calling the ClickUpAPI with it, not dedicated to any single tool. On a 429, the delayprefers ClickUp's own X-RateLimit-Reset header (a Unix timestamp forwhen the per-minute window resets — the header ClickUp's rate-limit docsactually document) over a generic Retry-After or blind exponentialbackoff, so retries wait exactly as long as ClickUp says to, not a guess.
Attachments and images
ClickUp's REST API has no way to attach a file directly to a comment —only to a task (POST /task/{task_id}/attachment, whatclickup_attach_task_file wraps). There is also no delete/updateattachment endpoint; re-uploading adds a new attachment rather thanreplacing the old one, and removing one requires the ClickUp web/desktopapp. Confirmed by checking ClickUp's own official MCP server's tooldescriptions too — same split (a Create Task Comment tool with noattachment support, and a separate Attach File to Task tool).
To make an image show up inline inside a comment, the underlyingtrick is: upload the file to the task first, then reference thereturned URL from the file's response using Markdown image syntax inthe comment text — ClickUp's comment renderer inlines it as a realimage, not just a link. clickup_create_comment_with_image does bothsteps in one call:
clickup_create_comment_with_image(task_id, file_content_base64, filename)
# internally:
# 1. POST /task/{task_id}/attachment -> {"url": "...", ...}
# 2. POST /task/{task_id}/comment comment_text = ""
To do it manually instead (e.g. to add other text around the image),call the two tools yourself:
1. result = clickup_attach_task_file(task_id, file_content_base64, filename)
-> result["url"] is the uploaded file's URL
2. clickup_create_task_comment(
task_id,
comment_text=f""
)