UNPKG

@s.pawel940/clickup-mcp-server

Version:

ClickUp MCP Server (Read-Only Fork) - Integrate ClickUp tasks with AI through Model Context Protocol

706 lines (705 loc) 30.4 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express from 'express'; import { z } from 'zod'; import configuration from './config.js'; import { createDocumentPageTool, createDocumentTool, getDocumentPagesTool, getDocumentTool, handleCreateDocument, handleCreateDocumentPage, handleGetDocument, handleGetDocumentPages, handleListDocumentPages, handleListDocuments, handleUpdateDocumentPage, listDocumentPagesTool, listDocumentsTool, updateDocumentPageTool, } from './tools/documents.js'; import { createFolderTool, deleteFolderTool, getFolderTool, handleCreateFolder, handleDeleteFolder, handleGetFolder, handleUpdateFolder, updateFolderTool, } from './tools/folder.js'; import { addTimeEntryTool, attachTaskFileTool, createBulkTasksTool, createTaskCommentTool, createTaskTool, deleteBulkTasksTool, deleteTaskTool, deleteTimeEntryTool, duplicateTaskTool, getCurrentTimeEntryTool, getTaskCommentsTool, getTaskTimeEntriesTool, getTaskTool, getWorkspaceTasksTool, handleAddTimeEntry, handleAttachTaskFile, handleCreateBulkTasks, handleCreateTask, handleCreateTaskComment, handleDeleteBulkTasks, handleDeleteTask, handleDeleteTimeEntry, handleDuplicateTask, handleGetCurrentTimeEntry, handleGetTask, handleGetTaskComments, handleGetTaskTimeEntries, handleGetWorkspaceTasks, handleMoveBulkTasks, handleMoveTask, handleStartTimeTracking, handleStopTimeTracking, handleUpdateBulkTasks, handleUpdateTask, moveBulkTasksTool, moveTaskTool, startTimeTrackingTool, stopTimeTrackingTool, updateBulkTasksTool, updateTaskTool, } from './tools/index.js'; import { createListInFolderTool, createListTool, deleteListTool, getListTool, handleCreateList, handleCreateListInFolder, handleDeleteList, handleGetList, handleUpdateList, updateListTool, } from './tools/list.js'; import { addTagToTaskTool, getSpaceTagsTool, handleAddTagToTask, handleGetSpaceTags, handleRemoveTagFromTask, removeTagFromTaskTool, } from './tools/tag.js'; import { handleGetWorkspaceHierarchy } from './tools/workspace.js'; const server = new McpServer({ name: 'clickup-mcp-server', version: '0.7.2', }); const app = express(); app.use(express.json()); server.tool('get_workspace_hierarchy', 'Get Workspace Hierarchy', {}, async () => { const hierarchy = (await handleGetWorkspaceHierarchy()); return hierarchy; }); server.tool(getTaskTool.name, getTaskTool.description, { taskId: z .string() .optional() .describe("ID of task to retrieve (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234'). The system automatically detects the ID format."), taskName: z .string() .optional() .describe('Name of task to retrieve. Can be used alone for a global search, or with listName for faster lookup.'), listName: z .string() .optional() .describe('Name of list containing the task. Optional but recommended when using taskName.'), customTaskId: z .string() .optional() .describe("Custom task ID (e.g., 'DEV-1234'). Only use this if you want to explicitly force custom ID lookup. In most cases, you can just use taskId which auto-detects ID format."), subtasks: z .boolean() .optional() .describe('Whether to include subtasks in the response. Set to true to retrieve full details of all subtasks.'), }, async (params) => { return (await handleGetTask(params)); }); server.tool(createTaskTool.name, createTaskTool.description, { name: z .string() .describe('REQUIRED: Name of the task. Put a relevant emoji followed by a blank space before the name.'), listId: z .string() .optional() .describe('REQUIRED (unless listName provided): ID of the list to create the task in. If you have this ID from a previous response, use it directly rather than looking up by name.'), listName: z .string() .optional() .describe('REQUIRED (unless listName provided): Name of the list to create the task in - will automatically find the list by name.'), description: z .string() .optional() .describe('Optional plain text description for the task'), markdown_description: z .string() .optional() .describe('Optional markdown formatted description for the task. If provided, this takes precedence over description'), status: z .string() .optional() .describe('Optional: Override the default ClickUp status. In most cases, you should omit this to use ClickUp defaults'), priority: z .number() .optional() .describe('Optional priority of the task (1-4), where 1 is urgent/highest priority and 4 is lowest priority. Only set this when explicitly requested.'), dueDate: z .string() .optional() .describe("Optional due date. Supports Unix timestamps (ms) or natural language like '1 hour from now', 'tomorrow', 'next week', etc."), startDate: z .string() .optional() .describe("Optional start date. Supports Unix timestamps (ms) or natural language like 'now', 'start of today', etc."), parent: z .string() .optional() .describe('Optional ID of the parent task. When specified, this task will be created as a subtask of the specified parent task.'), tags: z .array(z.string()) .optional() .describe('Optional array of tag names to assign to the task. The tags must already exist in the space.'), custom_fields: z .array(z.object({ id: z.string().describe('ID of the custom field'), value: z .any() .describe('Value for the custom field. Type depends on the field type.'), })) .optional() .describe("Optional array of custom field values to set on the task. Each object must have an 'id' and 'value' property."), check_required_custom_fields: z .boolean() .optional() .describe('Optional flag to check if all required custom fields are set before saving the task.'), }, async (params) => { return (await handleCreateTask(params)); }); server.tool(updateTaskTool.name, updateTaskTool.description, { taskId: z .string() .optional() .describe("ID of task to update (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of task to update. The tool will search for tasks with this name across all lists unless listName is specified.'), listName: z .string() .optional() .describe('Optional: Name of list containing the task. Providing this narrows the search to a specific list, improving performance and reducing ambiguity.'), name: z .string() .optional() .describe('New name for the task. Include emoji prefix if appropriate.'), description: z .string() .optional() .describe('New plain text description. Will be ignored if markdown_description is provided.'), markdown_description: z .string() .optional() .describe('New markdown description. Takes precedence over plain text description.'), status: z .string() .optional() .describe("New status. Must be valid for the task's current list."), priority: z .number() .optional() .describe('New priority: 1 (urgent) to 4 (low). Set null to clear priority.'), dueDate: z .string() .optional() .describe("New due date. Supports both Unix timestamps (in milliseconds) and natural language expressions like '1 hour from now', 'tomorrow', 'next week', or '3 days from now'."), startDate: z .string() .optional() .describe('New start date. Supports both Unix timestamps (in milliseconds) and natural language expressions.'), time_estimate: z .string() .optional() .describe("Time estimate for the task. For best compatibility with the ClickUp API, use a numeric value in minutes (e.g., '150' for 2h 30m)"), custom_fields: z .array(z.object({ id: z.string().describe('ID of the custom field'), value: z .any() .describe('Value for the custom field. Type depends on the field type.'), })) .optional() .describe("Optional array of custom field values to set on the task. Each object must have an 'id' and 'value' property."), }, async (params) => { return (await handleUpdateTask(params)); }); server.tool(moveTaskTool.name, moveTaskTool.description, { taskId: z .string() .optional() .describe("ID of the task to move (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of the task to move. When using this, you MUST also provide sourceListName.'), sourceListName: z .string() .optional() .describe('REQUIRED with taskName: Current list containing the task.'), listId: z .string() .optional() .describe('ID of destination list (preferred). Use this instead of listName if you have it.'), listName: z .string() .optional() .describe("Name of destination list. Only use if you don't have listId."), }, async (params) => { return (await handleMoveTask(params)); }); server.tool(duplicateTaskTool.name, duplicateTaskTool.description, { taskId: z .string() .optional() .describe("ID of task to duplicate (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of task to duplicate. When using this, you MUST provide sourceListName.'), sourceListName: z .string() .optional() .describe('REQUIRED with taskName: List containing the original task.'), listId: z .string() .optional() .describe('ID of list for the duplicate (optional). Defaults to same list as original.'), listName: z .string() .optional() .describe("Name of list for the duplicate. Only use if you don't have listId."), }, async (params) => { return (await handleDuplicateTask(params)); }); server.tool(deleteTaskTool.name, deleteTaskTool.description, { taskId: z .string() .optional() .describe("ID of task to delete (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of task to delete. The tool will search for tasks with this name across all lists unless listName is specified.'), listName: z .string() .optional() .describe('Optional: Name of list containing the task. Providing this narrows the search to a specific list, improving performance and reducing ambiguity.'), }, async (params) => { return (await handleDeleteTask(params)); }); server.tool(getTaskCommentsTool.name, getTaskCommentsTool.description, { taskId: z .string() .optional() .describe("ID of task to retrieve comments for (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of task to retrieve comments for. Warning: Task names may not be unique.'), listName: z .string() .optional() .describe('Name of list containing the task. Helps find the right task when using taskName.'), start: z .number() .optional() .describe('Timestamp (in milliseconds) to start retrieving comments from. Used for pagination.'), startId: z .string() .optional() .describe('Comment ID to start from. Used together with start for pagination.'), }, async (params) => { return (await handleGetTaskComments(params)); }); server.tool(createTaskCommentTool.name, createTaskCommentTool.description, { taskId: z .string() .optional() .describe("ID of task to comment on (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."), taskName: z .string() .optional() .describe('Name of task to comment on. When using this parameter, you MUST also provide listName.'), listName: z .string() .optional() .describe('Name of list containing the task. REQUIRED when using taskName.'), commentText: z .string() .describe('REQUIRED: Text content of the comment to create.'), notifyAll: z .boolean() .optional() .describe('Whether to notify all assignees. Default is false.'), assignee: z .number() .optional() .describe('Optional user ID to assign the comment to.'), }, async (params) => { return (await handleCreateTaskComment(params)); }); server.tool(attachTaskFileTool.name, attachTaskFileTool.description, { taskId: z.string().describe('ID of the task to attach the file to.'), filePath: z.string().describe('Path to the file to attach.'), fileName: z .string() .optional() .describe('Name of the file. Defaults to the original file name.'), }, async (params) => { // Note: handleAttachTaskFile in server.ts might need adjustment // if it expects a different structure for file path/content in SSE context. // For now, assuming it can handle a file path. return (await handleAttachTaskFile(params)); }); server.tool(createBulkTasksTool.name, createBulkTasksTool.description, { listId: z.string().describe('ID of the list to create the tasks in.'), tasks: z .array(z.object({ name: z.string().describe('Name of the task.'), description: z .string() .optional() .describe('Description of the task.'), assignees: z .array(z.string()) .optional() .describe('Array of user IDs to assign to the task.'), tags: z .array(z.string()) .optional() .describe('Array of tag names to add to the task.'), status: z.string().optional().describe('Status to set for the task.'), priority: z.number().optional().describe('Priority of the task.'), dueDate: z .string() .optional() .describe('Due date of the task (YYYY-MM-DD).'), startDate: z .string() .optional() .describe('Start date of the task (YYYY-MM-DD).'), customFields: z .array(z.object({ id: z.string(), value: z.any() })) .optional() .describe('Array of custom fields to set for the task.'), parentTask: z .string() .optional() .describe('ID of the parent task, if creating a subtask.'), })) .describe('Array of task objects to create.'), }, async (params) => { return (await handleCreateBulkTasks(params)); }); server.tool(updateBulkTasksTool.name, updateBulkTasksTool.description, { tasks: z .array(z.object({ id: z.string().describe('ID of the task to update.'), name: z.string().optional().describe('New name for the task.'), description: z .string() .optional() .describe('New description for the task.'), assignees: z .array(z.string()) .optional() .describe('New array of user IDs to assign to the task.'), tags: z .array(z.string()) .optional() .describe('New array of tag names to add to the task.'), status: z .string() .optional() .describe('New status to set for the task.'), priority: z.number().optional().describe('New priority of the task.'), dueDate: z .string() .optional() .describe('New due date of the task (YYYY-MM-DD).'), startDate: z .string() .optional() .describe('New start date of the task (YYYY-MM-DD).'), customFields: z .array(z.object({ id: z.string(), value: z.any() })) .optional() .describe('New array of custom fields to set for the task.'), parentTask: z .string() .optional() .describe('New ID of the parent task.'), })) .describe('Array of task objects to update.'), }, async (params) => { return (await handleUpdateBulkTasks(params)); }); server.tool(moveBulkTasksTool.name, moveBulkTasksTool.description, { taskIds: z.array(z.string()).describe('Array of task IDs to move.'), newListId: z.string().describe('ID of the list to move the tasks to.'), newStatus: z .string() .optional() .describe('New status for the tasks in the new list.'), }, async (params) => { return (await handleMoveBulkTasks(params)); }); server.tool(deleteBulkTasksTool.name, deleteBulkTasksTool.description, { taskIds: z.array(z.string()).describe('Array of task IDs to delete.'), }, async (params) => { return (await handleDeleteBulkTasks(params)); }); server.tool(getWorkspaceTasksTool.name, getWorkspaceTasksTool.description, { workspaceId: z .string() .optional() .describe('ID of the workspace to get tasks from. Defaults to the current workspace.'), listIds: z .array(z.string()) .optional() .describe('Array of list IDs to filter tasks by.'), statuses: z .array(z.string()) .optional() .describe('Array of statuses to filter tasks by.'), assignees: z .array(z.string()) .optional() .describe('Array of user IDs to filter tasks by.'), tags: z .array(z.string()) .optional() .describe('Array of tag names to filter tasks by.'), dueDateGreaterThan: z .string() .optional() .describe('Filter tasks with due date greater than (YYYY-MM-DD).'), dueDateLessThan: z .string() .optional() .describe('Filter tasks with due date less than (YYYY-MM-DD).'), createdDateGreaterThan: z .string() .optional() .describe('Filter tasks with created date greater than (YYYY-MM-DD).'), createdDateLessThan: z .string() .optional() .describe('Filter tasks with created date less than (YYYY-MM-DD).'), updatedDateGreaterThan: z .string() .optional() .describe('Filter tasks with updated date greater than (YYYY-MM-DD).'), updatedDateLessThan: z .string() .optional() .describe('Filter tasks with updated date less than (YYYY-MM-DD).'), includeSubtasks: z .boolean() .optional() .describe('Include subtasks in the results.'), includeClosed: z .boolean() .optional() .describe('Include closed tasks in the results.'), sortBy: z .string() .optional() .describe("Field to sort tasks by (e.g., 'dueDate', 'createdDate', 'priority')."), sortOrder: z .enum(['asc', 'desc']) .optional() .describe("Sort order ('asc' or 'desc')."), page: z.number().optional().describe('Page number for pagination.'), }, async (params) => { return (await handleGetWorkspaceTasks(params)); }); server.tool(getTaskTimeEntriesTool.name, getTaskTimeEntriesTool.description, { taskId: z.string().describe('ID of the task to get time entries for.'), startDate: z .string() .optional() .describe('Filter time entries starting after this date (YYYY-MM-DD).'), endDate: z .string() .optional() .describe('Filter time entries ending before this date (YYYY-MM-DD).'), assignee: z.string().optional().describe('Filter time entries by user ID.'), }, async (params) => { return (await handleGetTaskTimeEntries(params)); }); server.tool(startTimeTrackingTool.name, startTimeTrackingTool.description, { taskId: z.string().describe('ID of the task to start time tracking for.'), assignee: z .string() .optional() .describe('User ID to start time tracking for. Defaults to the authenticated user.'), }, async (params) => { return (await handleStartTimeTracking(params)); }); server.tool(stopTimeTrackingTool.name, stopTimeTrackingTool.description, { assignee: z .string() .optional() .describe('User ID to stop time tracking for. Defaults to the authenticated user.'), }, async (params) => { return (await handleStopTimeTracking(params)); }); server.tool(addTimeEntryTool.name, addTimeEntryTool.description, { taskId: z.string().describe('ID of the task to add time entry to.'), duration: z .number() .describe('Duration of the time entry in milliseconds.'), description: z .string() .optional() .describe('Description of the time entry.'), start: z .string() .optional() .describe('Start time of the entry (Unix timestamp in milliseconds).'), end: z .string() .optional() .describe('End time of the entry (Unix timestamp in milliseconds).'), assignee: z .string() .optional() .describe('User ID for the time entry. Defaults to the authenticated user.'), billable: z .boolean() .optional() .describe('Whether the time entry is billable.'), }, async (params) => { return (await handleAddTimeEntry(params)); }); server.tool(deleteTimeEntryTool.name, deleteTimeEntryTool.description, { timeEntryId: z.string().describe('ID of the time entry to delete.'), }, async (params) => { return (await handleDeleteTimeEntry(params)); }); server.tool(getCurrentTimeEntryTool.name, getCurrentTimeEntryTool.description, { assignee: z .string() .optional() .describe('User ID to get current time entry for. Defaults to the authenticated user.'), }, async (params) => { return (await handleGetCurrentTimeEntry(params)); }); server.tool(createListTool.name, createListTool.description, { spaceId: z.string().describe('ID of the space to create the list in.'), listName: z.string().describe('Name of the list to create.'), status: z.string().optional().describe('Status for the list.'), priority: z.number().optional().describe('Priority for the list.'), dueDate: z .string() .optional() .describe('Due date for the list (YYYY-MM-DD).'), }, async (params) => { return (await handleCreateList(params)); }); server.tool(createListInFolderTool.name, createListInFolderTool.description, { folderId: z.string().describe('ID of the folder to create the list in.'), listName: z.string().describe('Name of the list to create.'), status: z.string().optional().describe('Status for the list.'), priority: z.number().optional().describe('Priority for the list.'), dueDate: z .string() .optional() .describe('Due date for the list (YYYY-MM-DD).'), }, async (params) => { return (await handleCreateListInFolder(params)); }); server.tool(getListTool.name, getListTool.description, { listId: z.string().describe('ID of the list to retrieve.'), }, async (params) => { return (await handleGetList(params)); }); server.tool(updateListTool.name, updateListTool.description, { listId: z.string().describe('ID of the list to update.'), listName: z.string().optional().describe('New name for the list.'), status: z.string().optional().describe('New status for the list.'), priority: z.number().optional().describe('New priority for the list.'), dueDate: z .string() .optional() .describe('New due date for the list (YYYY-MM-DD).'), unsetStatus: z .boolean() .optional() .describe('Set to true to remove the status from the list.'), }, async (params) => { return (await handleUpdateList(params)); }); server.tool(deleteListTool.name, deleteListTool.description, { listId: z.string().describe('ID of the list to delete.'), }, async (params) => { return (await handleDeleteList(params)); }); server.tool(createFolderTool.name, createFolderTool.description, { spaceId: z.string().describe('ID of the space to create the folder in.'), folderName: z.string().describe('Name of the folder to create.'), }, async (params) => { return (await handleCreateFolder(params)); }); server.tool(getFolderTool.name, getFolderTool.description, { folderId: z.string().describe('ID of the folder to retrieve.'), }, async (params) => { return (await handleGetFolder(params)); }); server.tool(updateFolderTool.name, updateFolderTool.description, { folderId: z.string().describe('ID of the folder to update.'), folderName: z.string().optional().describe('New name for the folder.'), }, async (params) => { return (await handleUpdateFolder(params)); }); server.tool(deleteFolderTool.name, deleteFolderTool.description, { folderId: z.string().describe('ID of the folder to delete.'), }, async (params) => { return (await handleDeleteFolder(params)); }); server.tool(getSpaceTagsTool.name, getSpaceTagsTool.description, { spaceId: z.string().describe('ID of the space to get tags from.'), }, async (params) => { return (await handleGetSpaceTags(params)); }); server.tool(addTagToTaskTool.name, addTagToTaskTool.description, { taskId: z.string().describe('ID of the task to add the tag to.'), tagName: z.string().describe('Name of the tag to add.'), }, async (params) => { return (await handleAddTagToTask(params)); }); server.tool(removeTagFromTaskTool.name, removeTagFromTaskTool.description, { taskId: z.string().describe('ID of the task to remove the tag from.'), tagName: z.string().describe('Name of the tag to remove.'), }, async (params) => { return (await handleRemoveTagFromTask(params)); }); server.tool(createDocumentTool.name, createDocumentTool.description, { spaceId: z.string().describe('ID of the space to create the document in.'), name: z.string().describe('Name of the document.'), content: z.string().optional().describe('Initial content of the document.'), parentType: z .enum(['list', 'folder', 'space']) .optional() .describe('Type of the parent entity.'), parentId: z.string().optional().describe('ID of the parent entity.'), }, async (params) => { return (await handleCreateDocument(params)); }); server.tool(getDocumentTool.name, getDocumentTool.description, { documentId: z.string().describe('ID of the document to retrieve.'), }, async (params) => { return (await handleGetDocument(params)); }); server.tool(listDocumentsTool.name, listDocumentsTool.description, { spaceId: z .string() .optional() .describe('ID of the space to list documents from. Defaults to all accessible documents.'), includeArchived: z .boolean() .optional() .describe('Include archived documents.'), }, async (params) => { return (await handleListDocuments(params)); }); server.tool(listDocumentPagesTool.name, listDocumentPagesTool.description, { documentId: z.string().describe('ID of the document to list pages from.'), }, async (params) => { return (await handleListDocumentPages(params)); }); server.tool(getDocumentPagesTool.name, getDocumentPagesTool.description, { documentId: z.string().describe('ID of the document to get pages from.'), pageIds: z .array(z.string()) .optional() .describe('Array of page IDs to retrieve. If not provided, all pages are retrieved.'), }, async (params) => { return (await handleGetDocumentPages(params)); }); server.tool(createDocumentPageTool.name, createDocumentPageTool.description, { documentId: z.string().describe('ID of the document to add the page to.'), title: z.string().describe('Title of the new page.'), content: z.string().optional().describe('Content of the new page.'), orderIndex: z .number() .optional() .describe('Order index of the page within the document.'), }, async (params) => { return (await handleCreateDocumentPage(params)); }); server.tool(updateDocumentPageTool.name, updateDocumentPageTool.description, { pageId: z.string().describe('ID of the page to update.'), title: z.string().optional().describe('New title for the page.'), content: z.string().optional().describe('New content for the page.'), orderIndex: z.number().optional().describe('New order index for the page.'), }, async (params) => { return (await handleUpdateDocumentPage(params)); }); export function startSSEServer() { const transports = { streamable: {}, sse: {}, }; app.get('/sse', async (req, res) => { const transport = new SSEServerTransport('/messages', res); transports.sse[transport.sessionId] = transport; console.log(`New SSE connection established with sessionId: ${transport.sessionId}`); res.on('close', () => { delete transports.sse[transport.sessionId]; }); await server.connect(transport); }); app.post('/messages', async (req, res) => { const sessionId = req.query.sessionId; const transport = transports.sse[sessionId]; if (transport) { await transport.handlePostMessage(req, res, req.body); } else { res.status(400).send('No transport found for sessionId'); } }); const PORT = Number(configuration.port ?? '3231'); app.listen(PORT, () => { console.log(`Connect to sse with http://localhost:${PORT}/sse`); }); }