diff --git a/src/clickup-client/docs.ts b/src/clickup-client/docs.ts index 5f68cdc..58c5620 100644 --- a/src/clickup-client/docs.ts +++ b/src/clickup-client/docs.ts @@ -109,51 +109,56 @@ export class DocsClient { } /** - * Search for docs in a workspace + * Search for docs in a workspace by name (case-insensitive substring match). + * + * Implementation note: ClickUp's v2 search endpoint + * (`/api/v2/team/{ws}/docs/search`) returns 404 — it does not exist. The + * v3 docs API has no dedicated search endpoint either. We therefore page + * through `GET /api/v3/workspaces/{ws}/docs` and filter client-side. For + * very large workspaces this is suboptimal, but it is the only reliable + * way to honor the tool contract. + * + * The query syntax `space:` is preserved from the legacy v2 + * implementation: instead of matching by name, return all docs whose + * `parent.id === spaceId` (parent_type 4 = space, but we don't filter on + * type to also surface docs nested under folders/lists of that space — + * see deepFilter below). + * * @param workspaceId The ID of the workspace to search in * @param params The search parameters * @returns A list of docs matching the search query */ async searchDocs(workspaceId: string, params: SearchDocsParams): Promise<{ docs: Doc[], next_cursor: string }> { - // Get the API token directly from the environment variable - const apiToken = process.env.CLICKUP_API_TOKEN; - - try { - // According to the ClickUp API documentation, the endpoint is: - // GET /api/v2/team/{team_id}/docs/search - // where team_id is the workspace ID - const url = `https://api.clickup.com/api/v2/team/${workspaceId}/docs/search`; - - // Use the exact same headers that worked in the successful request - const headers = { - 'Authorization': apiToken, - 'Accept': 'application/json' - }; - - // According to the ClickUp API documentation, this should be a GET request - // with the parameters as query parameters - const queryParams: any = { - doc_name: params.query, - cursor: params.cursor - }; - - // If the query is a space ID, use it as a space_id parameter - if (params.query.startsWith('space:')) { - const spaceId = params.query.substring(6); - queryParams.space_id = spaceId; - delete queryParams.doc_name; + const isSpaceFilter = params.query.startsWith('space:'); + const spaceId = isSpaceFilter ? params.query.substring(6) : null; + const needle = isSpaceFilter ? '' : params.query.toLowerCase(); + + const matches: Doc[] = []; + let cursor: string | undefined = params.cursor; + // Hard cap to avoid runaway loops; ClickUp's docs are paginated 50/page. + const MAX_PAGES = 40; + let pages = 0; + let nextCursor = ''; + + while (pages < MAX_PAGES) { + const page = await this.getDocsFromWorkspace(workspaceId, { cursor, limit: 50 }); + for (const doc of page.docs ?? []) { + if (isSpaceFilter) { + if (doc.parent?.id === spaceId) matches.push(doc); + } else if (doc.name && doc.name.toLowerCase().includes(needle)) { + matches.push(doc); + } } - - const response = await axios.get(url, { - headers, - params: queryParams - }); - - return response.data; - } catch (error) { - console.error('Error searching docs:', error); - throw error; + pages += 1; + if (!page.next_cursor) { + nextCursor = ''; + break; + } + cursor = page.next_cursor; + nextCursor = page.next_cursor; } + + return { docs: matches, next_cursor: nextCursor }; } /** diff --git a/src/clickup-client/index.ts b/src/clickup-client/index.ts index e105f6c..eaddfef 100644 --- a/src/clickup-client/index.ts +++ b/src/clickup-client/index.ts @@ -61,8 +61,8 @@ export class ClickUpClient { return response.data; } - async delete(endpoint: string): Promise { - const response = await this.axiosInstance.delete(endpoint); + async delete(endpoint: string, params?: any): Promise { + const response = await this.axiosInstance.delete(endpoint, { params }); return response.data; } } diff --git a/src/clickup-client/lists.ts b/src/clickup-client/lists.ts index 46537b5..295b722 100644 --- a/src/clickup-client/lists.ts +++ b/src/clickup-client/lists.ts @@ -17,6 +17,7 @@ export interface CreateListParams { export interface UpdateListParams { name?: string; + content?: string; // ...other parameters for updating a list... } diff --git a/src/clickup-client/tasks.ts b/src/clickup-client/tasks.ts index 9c4df14..e14d10c 100644 --- a/src/clickup-client/tasks.ts +++ b/src/clickup-client/tasks.ts @@ -52,6 +52,7 @@ export interface Task { export interface CreateTaskParams { name: string; description?: string; + markdown_description?: string; assignees?: number[]; tags?: string[]; status?: string; @@ -64,6 +65,7 @@ export interface CreateTaskParams { notify_all?: boolean; parent?: string; links_to?: string; + custom_item_id?: number; check_required_custom_fields?: boolean; custom_fields?: Array<{ id: string; @@ -74,6 +76,7 @@ export interface CreateTaskParams { export interface UpdateTaskParams { name?: string; description?: string; + markdown_description?: string; assignees?: number[]; status?: string; priority?: number; @@ -84,6 +87,8 @@ export interface UpdateTaskParams { start_date_time?: boolean; notify_all?: boolean; parent?: string; + custom_item_id?: number; + archived?: boolean; custom_fields?: Array<{ id: string; value: any; @@ -169,6 +174,34 @@ export class TasksClient { return this.client.delete(`/task/${taskId}`); } + /** + * Add a dependency between two tasks. + * Pass exactly one of depends_on or dependency_of in the body. + * @param taskId The ID of the task with the dependency relationship + * @param body Object with either { depends_on } or { dependency_of } + * @returns Success message + */ + async addDependency( + taskId: string, + body: { depends_on?: string; dependency_of?: string } + ): Promise<{ success: boolean }> { + return this.client.post(`/task/${taskId}/dependency`, body); + } + + /** + * Remove a dependency between two tasks. + * Pass exactly one of depends_on or dependency_of as a query parameter. + * @param taskId The ID of the task with the dependency relationship + * @param params Object with either { depends_on } or { dependency_of } + * @returns Success message + */ + async removeDependency( + taskId: string, + params: { depends_on?: string; dependency_of?: string } + ): Promise<{ success: boolean }> { + return this.client.delete(`/task/${taskId}/dependency`, params); + } + /** * Get subtasks of a specific task * @param taskId The ID of the task to get subtasks for diff --git a/src/tools/doc-tools.ts b/src/tools/doc-tools.ts index c9360a7..d2f930d 100644 --- a/src/tools/doc-tools.ts +++ b/src/tools/doc-tools.ts @@ -49,11 +49,11 @@ export function setupDocTools(server: McpServer): void { // Register search_docs tool server.tool( 'search_docs', - 'Search for docs in a ClickUp workspace using a query string. Returns matching docs with their metadata.', + 'Search for docs in a ClickUp workspace by name (case-insensitive substring match). The query string `space:` returns all docs whose parent is the given space. Implementation: pages through the v3 docs endpoint and filters client-side — the v2 search endpoint returns 404 and v3 has no dedicated search endpoint.', { workspace_id: z.string().describe('The ID of the workspace to search in'), - query: z.string().describe('The search query'), - cursor: z.string().optional().describe('Cursor for pagination') + query: z.string().describe('Substring to match in doc names, or `space:` to filter by parent space'), + cursor: z.string().optional().describe('Cursor for pagination (passed through to the underlying v3 docs listing)') }, async ({ workspace_id, query, cursor }) => { try { diff --git a/src/tools/task-tools.ts b/src/tools/task-tools.ts index c08c849..2965002 100644 --- a/src/tools/task-tools.ts +++ b/src/tools/task-tools.ts @@ -108,11 +108,12 @@ export function setupTaskTools(server: McpServer): void { server.tool( 'create_task', - 'Create a new task in a ClickUp list with specified properties like name, description, assignees, status, and dates.', + 'Create a new task in a ClickUp list with specified properties like name, description, assignees, status, and dates. Set custom_item_id=1 to mark the task as a milestone (diamond on Gantt). Use markdown_description for rich-formatted descriptions.', { list_id: z.string().describe('The ID of the list to create the task in'), name: z.string().describe('The name of the task'), - description: z.string().optional().describe('The description of the task'), + description: z.string().optional().describe('The description of the task (plain text)'), + markdown_description: z.string().optional().describe('The description of the task with markdown formatting (rendered in the ClickUp UI). When provided, takes precedence over description.'), assignees: z.array(z.number()).optional().describe('The IDs of the users to assign to the task'), tags: z.array(z.string()).optional().describe('The tags to add to the task'), status: z.string().optional().describe('The status of the task'), @@ -123,7 +124,8 @@ export function setupTaskTools(server: McpServer): void { start_date: z.number().optional().describe('The start date of the task (Unix timestamp)'), start_date_time: z.boolean().optional().describe('Whether the start date includes a time'), notify_all: z.boolean().optional().describe('Whether to notify all assignees'), - parent: z.string().optional().describe('The ID of the parent task') + parent: z.string().optional().describe('The ID of the parent task'), + custom_item_id: z.number().optional().describe('Task type ID. Set to 1 to mark the task as a milestone (diamond icon, Gantt support). Other values map to other custom task types if configured on the workspace.') }, async ({ list_id, ...taskParams }) => { try { @@ -143,11 +145,12 @@ export function setupTaskTools(server: McpServer): void { server.tool( 'update_task', - 'Update an existing ClickUp task\'s properties including name, description, assignees, status, and dates.', + 'Update an existing ClickUp task\'s properties including name, description, assignees, status, and dates. Set custom_item_id=1 to mark/unmark the task as a milestone. Use markdown_description for rich-formatted descriptions.', { task_id: z.string().describe('The ID of the task to update'), name: z.string().optional().describe('The new name of the task'), - description: z.string().optional().describe('The new description of the task'), + description: z.string().optional().describe('The new description of the task (plain text)'), + markdown_description: z.string().optional().describe('The new description of the task with markdown formatting (rendered in the ClickUp UI). When provided, takes precedence over description.'), assignees: z.array(z.number()).optional().describe('The IDs of the users to assign to the task'), status: z.string().optional().describe('The new status of the task'), priority: z.number().optional().describe('The new priority of the task (1-4)'), @@ -156,7 +159,9 @@ export function setupTaskTools(server: McpServer): void { time_estimate: z.number().optional().describe('The new time estimate for the task (in milliseconds)'), start_date: z.number().optional().describe('The new start date of the task (Unix timestamp)'), start_date_time: z.boolean().optional().describe('Whether the start date includes a time'), - notify_all: z.boolean().optional().describe('Whether to notify all assignees') + notify_all: z.boolean().optional().describe('Whether to notify all assignees'), + custom_item_id: z.number().optional().describe('Task type ID. Set to 1 to mark the task as a milestone (diamond icon, Gantt support). Set to 0 to unmark.'), + archived: z.boolean().optional().describe('Set to true to archive the task, false to unarchive.') }, async ({ task_id, ...taskParams }) => { try { @@ -174,6 +179,85 @@ export function setupTaskTools(server: McpServer): void { } ); + server.tool( + 'delete_task', + 'Permanently delete a ClickUp task. This is destructive — the task and its history are removed. Use update_task with archived=true if you want to keep the task hidden but recoverable.', + { + task_id: z.string().describe('The ID of the task to delete') + }, + async ({ task_id }) => { + try { + const result = await tasksClient.deleteTask(task_id); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] + }; + } catch (error: any) { + console.error('Error deleting task:', error); + return { + content: [{ type: 'text', text: `Error deleting task: ${error.message}` }], + isError: true + }; + } + } + ); + + server.tool( + 'add_task_dependency', + 'Add a dependency between two ClickUp tasks. The task identified by task_id will be marked as "waiting on" depends_on (or as "blocking" of dependency_of). Useful for milestone chains (e.g. release V1.1 waits on V1.0). Pass exactly one of depends_on or dependency_of.', + { + task_id: z.string().describe('The ID of the task that has the dependency relationship'), + depends_on: z.string().optional().describe('The ID of the task that task_id is waiting on (task_id will not be actionable until depends_on is closed)'), + dependency_of: z.string().optional().describe('The ID of the task that depends on task_id (the inverse direction — task_id is blocking dependency_of)') + }, + async ({ task_id, depends_on, dependency_of }) => { + try { + if ((depends_on && dependency_of) || (!depends_on && !dependency_of)) { + throw new Error('Provide exactly one of depends_on or dependency_of.'); + } + const body: { depends_on?: string; dependency_of?: string } = {}; + if (depends_on) body.depends_on = depends_on; + if (dependency_of) body.dependency_of = dependency_of; + const result = await tasksClient.addDependency(task_id, body); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] + }; + } catch (error: any) { + console.error('Error adding task dependency:', error); + return { + content: [{ type: 'text', text: `Error adding task dependency: ${error.message}` }], + isError: true + }; + } + } + ); + + server.tool( + 'remove_task_dependency', + 'Remove an existing dependency between two ClickUp tasks. Pass exactly one of depends_on or dependency_of (the same direction used when creating the dependency).', + { + task_id: z.string().describe('The ID of the task that has the dependency relationship'), + depends_on: z.string().optional().describe('The ID of the upstream task (task_id was waiting on it)'), + dependency_of: z.string().optional().describe('The ID of the downstream task (it was waiting on task_id)') + }, + async ({ task_id, depends_on, dependency_of }) => { + try { + if ((depends_on && dependency_of) || (!depends_on && !dependency_of)) { + throw new Error('Provide exactly one of depends_on or dependency_of.'); + } + const result = await tasksClient.removeDependency(task_id, { depends_on, dependency_of }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] + }; + } catch (error: any) { + console.error('Error removing task dependency:', error); + return { + content: [{ type: 'text', text: `Error removing task dependency: ${error.message}` }], + isError: true + }; + } + } + ); + // List and Folder tools server.tool( 'get_lists', @@ -375,14 +459,18 @@ export function setupTaskTools(server: McpServer): void { server.tool( 'update_list', - 'Update an existing ClickUp list\'s name.', + 'Update an existing ClickUp list\'s name and/or description (content). The description is plain text — markdown is not rendered in list descriptions.', { list_id: z.string().describe('The ID of the list to update'), - name: z.string().describe('The new name of the list') + name: z.string().optional().describe('The new name of the list'), + content: z.string().optional().describe('The new description of the list (plain text, markdown not rendered)') }, - async ({ list_id, name }) => { + async ({ list_id, name, content }) => { try { - const result = await listsClient.updateList(list_id, { name }); + const params: { name?: string; content?: string } = {}; + if (name !== undefined) params.name = name; + if (content !== undefined) params.content = content; + const result = await listsClient.updateList(list_id, params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };