Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 43 additions & 38 deletions src/clickup-client/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<spaceId>` 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 };
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/clickup-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export class ClickUpClient {
return response.data;
}

async delete<T = any>(endpoint: string): Promise<T> {
const response = await this.axiosInstance.delete(endpoint);
async delete<T = any>(endpoint: string, params?: any): Promise<T> {
const response = await this.axiosInstance.delete(endpoint, { params });
return response.data;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/clickup-client/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CreateListParams {

export interface UpdateListParams {
name?: string;
content?: string;
// ...other parameters for updating a list...
}

Expand Down
33 changes: 33 additions & 0 deletions src/clickup-client/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface Task {
export interface CreateTaskParams {
name: string;
description?: string;
markdown_description?: string;
assignees?: number[];
tags?: string[];
status?: string;
Expand All @@ -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;
Expand All @@ -74,6 +76,7 @@ export interface CreateTaskParams {
export interface UpdateTaskParams {
name?: string;
description?: string;
markdown_description?: string;
assignees?: number[];
status?: string;
priority?: number;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/tools/doc-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<spaceId>` 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:<spaceId>` 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 {
Expand Down
108 changes: 98 additions & 10 deletions src/tools/task-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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 {
Expand All @@ -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)'),
Expand All @@ -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 {
Expand All @@ -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',
Expand Down Expand Up @@ -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) }]
};
Expand Down