@hauptsache.net/clickup-mcp 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,24 @@ Once connected, your AI assistant can:
69
69
 
70
70
  The AI will retrieve the information directly from ClickUp, including all text content, comments, and images, providing you with comprehensive assistance on your tasks.
71
71
 
72
+ ## Configuration
73
+
74
+ This MCP server can be configured using environment variables:
75
+
76
+ - `CLICKUP_API_KEY`: (Required) Your ClickUp API key.
77
+ - `CLICKUP_TEAM_ID`: (Required) Your ClickUp Team ID (formerly Workspace ID).
78
+ - `MAX_IMAGES`: (Optional) The maximum number of images to return for a task in `getTaskById`. Defaults to 4.
79
+ - `CLICKUP_PRIMARY_LANGUAGE`: (Optional) A hint for the primary language used in your ClickUp tasks (e.g., "de" for German, "en" for English). This helps the `searchTask` tool provide more tailored guidance in its description for multilingual searches.
80
+ - `LANG`: (Optional) If `CLICKUP_PRIMARY_LANGUAGE` is not set, the MCP will check this standard environment variable (e.g., "en_US.UTF-8", "de_DE") as a fallback to infer the primary language.
81
+
82
+ ### Language-Aware Search Guidance
83
+
84
+ The `searchTask` tool's description will dynamically adjust based on the detected primary language:
85
+ - If `CLICKUP_PRIMARY_LANGUAGE` or `LANG` suggests a known primary language (e.g., German), the tool's description will specifically recommend providing search terms in both English and that detected language (e.g., German) for optimal results.
86
+ - If no primary language is detected, a more general recommendation for multilingual workspaces will be provided.
87
+
88
+ This feature aims to improve search effectiveness when the language of user queries (often English) differs from the language of the tasks in ClickUp, without making the MCP itself perform translations. The responsibility for providing bilingual search terms still lies with the agent calling the MCP, but the MCP offers more specific advice if it has a language hint.
89
+
72
90
  ## License
73
91
 
74
92
  ISC
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import 'dotenv/config';
3
3
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,eAAe,CAAC"}
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ require("dotenv/config"); // Load .env file
4
5
  const index_1 = require("./index");
5
6
  async function main() {
6
7
  const args = process.argv.slice(2);
@@ -12,13 +13,17 @@ async function main() {
12
13
  if (tools) {
13
14
  for (const [name, tool] of Object.entries(tools)) {
14
15
  console.error(` - ${name}: ${tool.description}`);
15
- console.error(" Parameters:");
16
- // Get parameter information from the inputSchema
17
- const shape = tool.inputSchema._def.shape();
18
- for (const [paramName, schema] of Object.entries(shape)) {
19
- // @ts-ignore - Accessing schema description
20
- const description = schema.description || "No description";
21
- console.error(` - ${paramName}: ${description}`);
16
+ if (tool.inputSchema && tool.inputSchema._def && typeof tool.inputSchema._def.shape === 'function') {
17
+ console.error(" Parameters:");
18
+ const shape = tool.inputSchema._def.shape();
19
+ for (const [paramName, schema] of Object.entries(shape)) {
20
+ // @ts-ignore - Accessing schema description
21
+ const description = schema.description || "No description";
22
+ console.error(` - ${paramName}: ${description}`);
23
+ }
24
+ }
25
+ else {
26
+ console.error(" Parameters: None");
22
27
  }
23
28
  console.error("");
24
29
  }
@@ -57,13 +62,20 @@ async function main() {
57
62
  process.exit(1);
58
63
  }
59
64
  const tool = tools[toolName];
60
- // Validate parameters using the tool's schema
61
- try {
62
- tool.inputSchema.parse(params);
65
+ // Validate parameters using the tool's schema, if it exists
66
+ if (tool.inputSchema) {
67
+ try {
68
+ tool.inputSchema.parse(params);
69
+ }
70
+ catch (error) {
71
+ const validationError = error;
72
+ console.error("Parameter validation error:", validationError.message);
73
+ process.exit(1);
74
+ }
63
75
  }
64
- catch (error) {
65
- const validationError = error;
66
- console.error("Parameter validation error:", validationError.message);
76
+ else if (Object.keys(params).length > 0) {
77
+ // If there's no schema, but parameters were provided, it's an error
78
+ console.error(`Error: Tool '${toolName}' does not accept any parameters, but parameters were provided.`);
67
79
  process.exit(1);
68
80
  }
69
81
  // Mock environment variables for testing if they're not set
@@ -1 +1 @@
1
- {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGjE;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,CAAC,EAAE,GAAG,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,eAAe,EAAE,GAC3B,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAiDpC;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,iBAAiB,EAAE,GAC/B,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAsEpC"}
1
+ {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGjE;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,CAAC,EAAE,GAAG,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,eAAe,EAAE,GAC3B,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAqDpC;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,iBAAiB,EAAE,GAC/B,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAsEpC"}
@@ -35,9 +35,13 @@ async function processClickUpText(textItems) {
35
35
  }
36
36
  }
37
37
  // Handle text items
38
- else if (item.text !== undefined) {
38
+ else if (typeof item.text === "string") {
39
39
  currentTextBlock += item.text;
40
40
  }
41
+ // Handle other types of items like bookmarks or whatever clickup can think of
42
+ else {
43
+ currentTextBlock += JSON.stringify(item);
44
+ }
41
45
  }
42
46
  // Add any remaining text
43
47
  if (currentTextBlock.trim()) {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiBpE,eAAO,MAAM,MAAM,WAGjB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAyCpE,eAAO,MAAM,MAAM,WAGjB,CAAC"}
package/dist/index.js CHANGED
@@ -1,15 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
4
7
  exports.server = void 0;
5
8
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
6
9
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
7
10
  const zod_1 = require("zod");
8
11
  const clickup_text_1 = require("./clickup-text");
12
+ const fuse_js_1 = __importDefault(require("fuse.js"));
13
+ // --- End Simplified Event Wrapper ---
14
+ const rawPrimaryLang = process.env.CLICKUP_PRIMARY_LANGUAGE || process.env.LANG;
15
+ let detectedLanguageHint = undefined;
16
+ if (rawPrimaryLang) {
17
+ // Extract the primary language part (e.g., 'en' from 'en_US.UTF-8' or 'en-GB')
18
+ // and convert to lowercase.
19
+ const langPart = rawPrimaryLang.match(/^[a-zA-Z]{2,3}/);
20
+ if (langPart) {
21
+ detectedLanguageHint = langPart[0].toLowerCase();
22
+ }
23
+ }
9
24
  const CONFIG = {
10
25
  apiKey: process.env.CLICKUP_API_KEY,
11
26
  teamId: process.env.CLICKUP_TEAM_ID,
12
27
  maxImages: process.env.MAX_IMAGES ? parseInt(process.env.MAX_IMAGES) : 4,
28
+ primaryLanguageHint: detectedLanguageHint, // Store the cleaned code directly
13
29
  };
14
30
  if (!CONFIG.apiKey || !CONFIG.teamId) {
15
31
  throw new Error("Missing Clickup API key or team ID");
@@ -37,21 +53,124 @@ exports.server.tool("getTaskById", "Get a Clickup task with images and comments
37
53
  })
38
54
  .describe(`The 7-9 character ID of the task to get without a prefix like "#", "CU-" or "https://app.clickup.com/t/"`),
39
55
  }, async ({ id }) => {
40
- const [content, comments] = await Promise.all([
41
- loadTaskContent(id),
42
- loadTaskComments(id),
56
+ // 1. Load base task content, comment events, and status change events in parallel
57
+ const [taskDetailContentBlocks, commentEvents, statusChangeEvents] = await Promise.all([
58
+ loadTaskContent(id), // Returns Promise<ContentBlock[]>
59
+ loadTaskComments(id), // Returns Promise<DatedContentEvent[]>
60
+ loadTimeInStatusHistory(id), // Returns Promise<DatedContentEvent[]>
43
61
  ]);
44
- // Combine all content and limit the number of images
45
- const allContent = [...content, ...comments];
46
- const limitedContent = limitImages(allContent, CONFIG.maxImages);
62
+ // 2. Combine comment and status change events
63
+ const allDatedEvents = [...commentEvents, ...statusChangeEvents];
64
+ // 3. Sort all dated events chronologically
65
+ allDatedEvents.sort((a, b) => {
66
+ const dateA = a.date ? parseInt(a.date) : 0;
67
+ const dateB = b.date ? parseInt(b.date) : 0;
68
+ return dateA - dateB;
69
+ });
70
+ // 4. Flatten sorted events into a single ContentBlock stream
71
+ let processedEventBlocks = [];
72
+ for (const event of allDatedEvents) {
73
+ processedEventBlocks.push(...event.contentBlocks);
74
+ }
75
+ // 5. Combine task details with processed event blocks
76
+ const allContentBlocks = [...taskDetailContentBlocks, ...processedEventBlocks];
77
+ // 6. Limit images
78
+ const limitedContent = limitImages(allContentBlocks, CONFIG.maxImages);
47
79
  return {
48
80
  content: limitedContent,
49
81
  };
50
82
  });
83
+ async function loadTaskContent(taskId) {
84
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}?include_markdown_description=true&include_subtasks=true`, { headers: { Authorization: CONFIG.apiKey } });
85
+ const task = await response.json();
86
+ const content = await (0, clickup_text_1.processClickUpMarkdown)(task.markdown_description || "", task.attachments);
87
+ // Create the task metadata block using the helper function
88
+ const taskMetadata = await generateTaskMetadata(task);
89
+ return [taskMetadata, ...content];
90
+ }
91
+ async function loadTaskComments(id) {
92
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched
93
+ { headers: { Authorization: CONFIG.apiKey } });
94
+ if (!response.ok) {
95
+ console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`);
96
+ return [];
97
+ }
98
+ const commentsData = await response.json();
99
+ if (!commentsData.comments || !Array.isArray(commentsData.comments)) {
100
+ console.error(`Unexpected comment data structure for task ${id}`);
101
+ return [];
102
+ }
103
+ const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => {
104
+ const headerBlock = {
105
+ type: "text",
106
+ text: `Comment by ${comment.user.username} on ${new Date(+comment.date)}:`,
107
+ };
108
+ const commentBodyBlocks = await (0, clickup_text_1.processClickUpText)(comment.comment);
109
+ return {
110
+ date: comment.date, // String timestamp from ClickUp for sorting
111
+ contentBlocks: [headerBlock, ...commentBodyBlocks],
112
+ };
113
+ }));
114
+ return commentEvents;
115
+ }
116
+ async function loadTimeInStatusHistory(taskId) {
117
+ const url = `https://api.clickup.com/api/v2/task/${taskId}/time_in_status`;
118
+ try {
119
+ const response = await fetch(url, { headers: { Authorization: CONFIG.apiKey } });
120
+ if (!response.ok) {
121
+ console.error(`Error fetching time in status for task ${taskId}: ${response.status} ${response.statusText}`);
122
+ return [];
123
+ }
124
+ // Using 'any' for less strict typing as per user preference, but keeping structure for clarity
125
+ const data = await response.json();
126
+ const events = [];
127
+ const processStatusEntry = (entry) => {
128
+ if (!entry || !entry.total_time || !entry.total_time.since || !entry.status)
129
+ return null;
130
+ return {
131
+ date: entry.total_time.since,
132
+ contentBlocks: [{
133
+ type: "text",
134
+ text: `Status set to '${entry.status}' on ${new Date(+entry.total_time.since)}`,
135
+ }],
136
+ };
137
+ };
138
+ if (data.status_history && Array.isArray(data.status_history)) {
139
+ data.status_history.forEach((historyEntry) => {
140
+ const event = processStatusEntry(historyEntry);
141
+ if (event)
142
+ events.push(event);
143
+ });
144
+ }
145
+ if (data.current_status) {
146
+ const event = processStatusEntry(data.current_status);
147
+ // Ensure current_status is only added if it's distinct or more recent than the last history item.
148
+ // The deduplication logic below handles if it's the same as the last history entry.
149
+ if (event)
150
+ events.push(event);
151
+ }
152
+ // Deduplicate events based on date and status name to avoid adding current_status if it's identical to the last history entry
153
+ const uniqueEvents = Array.from(new Map(events.map(event => [`${event.date}-${event.contentBlocks[0]?.text}`, event] // Keying by date and text content of first block
154
+ )).values());
155
+ return uniqueEvents;
156
+ }
157
+ catch (error) {
158
+ console.error(`Exception fetching time in status for task ${taskId}:`, error);
159
+ return [];
160
+ }
161
+ }
51
162
  /**
52
163
  * Helper function to generate consistent task metadata
53
164
  */
54
- function generateTaskMetadata(task) {
165
+ async function generateTaskMetadata(task) {
166
+ let spaceName = task.space?.name || 'Unknown Space';
167
+ let spaceIdForDisplay = task.space?.id || 'N/A';
168
+ if (spaceName === 'Unknown Space' && task.space?.id) {
169
+ const spaceDetails = await getSpaceDetails(task.space.id);
170
+ if (spaceDetails && spaceDetails.name) {
171
+ spaceName = spaceDetails.name;
172
+ }
173
+ }
55
174
  const metadataLines = [
56
175
  `task_id: ${task.id}`,
57
176
  `name: ${task.name}`,
@@ -59,7 +178,9 @@ function generateTaskMetadata(task) {
59
178
  `date_created: ${new Date(+task.date_created)}`,
60
179
  `date_updated: ${new Date(+task.date_updated)}`,
61
180
  `creator: ${task.creator.username}`,
181
+ `assignee: ${task.assignees.map((a) => a.username).join(', ')}`,
62
182
  `list: ${task.list.name} (${task.list.id})`,
183
+ `space: ${spaceName} (${spaceIdForDisplay})`,
63
184
  ];
64
185
  // Add parent task information if it exists
65
186
  if (typeof task.parent === "string") {
@@ -74,42 +195,35 @@ function generateTaskMetadata(task) {
74
195
  text: metadataLines.join("\n"),
75
196
  };
76
197
  }
77
- async function loadTaskContent(id) {
78
- const response = await fetch(`https://api.clickup.com/api/v2/task/${id}?include_markdown_description=true&include_subtasks=true`, { headers: { Authorization: CONFIG.apiKey } });
79
- const task = await response.json();
80
- const content = await (0, clickup_text_1.processClickUpMarkdown)(task.markdown_description || "", task.attachments);
81
- // Create the task metadata block using the helper function
82
- const taskMetadata = generateTaskMetadata(task);
83
- return [taskMetadata, ...content];
84
- }
85
- async function loadTaskComments(id) {
86
- const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment`, { headers: { Authorization: CONFIG.apiKey } });
87
- const comments = await response.json();
88
- return Promise.all(comments.comments
89
- // Sort comments by date, newest first to prioritize recent images
90
- .sort((a, b) => +b.date - +a.date)
91
- .map(async (comment) => {
92
- // Create a header for the comment
93
- const commentHeader = {
94
- type: "text",
95
- text: [
96
- `comment_id: ${comment.id}`,
97
- `date: ${new Date(+comment.date)}`,
98
- `user: ${comment.user.username}`,
99
- ].join("\n"),
100
- };
101
- // Process comment items if they exist
102
- if (comment.comment && Array.isArray(comment.comment)) {
103
- const commentContentBlocks = await (0, clickup_text_1.processClickUpText)(comment.comment);
104
- return [commentHeader, ...commentContentBlocks];
105
- }
106
- else {
107
- return [
108
- { type: "text", text: commentHeader.text },
109
- { type: "text", text: comment.comment_text },
110
- ];
111
- }
112
- }));
198
+ const spaceCache = new Map(); // Global cache for space details promises
199
+ /**
200
+ * Function to get space details, using a cache to avoid redundant fetches
201
+ */
202
+ async function getSpaceDetails(spaceId) {
203
+ if (!spaceId) {
204
+ return null;
205
+ }
206
+ if (!spaceCache.has(spaceId)) {
207
+ const fetchPromise = fetch(`https://api.clickup.com/api/v2/space/${spaceId}`, {
208
+ headers: { Authorization: CONFIG.apiKey },
209
+ })
210
+ .then(res => {
211
+ if (!res.ok) {
212
+ // Don't cache failed requests, or handle errors more gracefully
213
+ console.error(`Error fetching space ${spaceId}: ${res.status}`);
214
+ spaceCache.delete(spaceId); // Allow retry on next call
215
+ return null;
216
+ }
217
+ return res.json();
218
+ })
219
+ .catch(error => {
220
+ console.error(`Network error fetching space ${spaceId}:`, error);
221
+ spaceCache.delete(spaceId); // Allow retry on next call
222
+ return null;
223
+ });
224
+ spaceCache.set(spaceId, fetchPromise);
225
+ }
226
+ return spaceCache.get(spaceId);
113
227
  }
114
228
  /**
115
229
  * Limits the number of images in the content array, replacing excess images with text placeholders
@@ -146,65 +260,122 @@ function limitImages(content, maxImages) {
146
260
  return block;
147
261
  });
148
262
  }
149
- let cachedTasks = [];
150
- let lastTaskCacheUpdate = 0;
151
- exports.server.tool("searchTask", [
152
- "Searches tasks by name (case insensitive) with support for multiple search terms (OR logic).",
153
- "You'll get a rough overview of the tasks that match the search terms.",
263
+ let taskSearchIndex = null;
264
+ let lastIndexUpdateTime = 0;
265
+ const INDEX_REFRESH_INTERVAL = 60000; // 60 seconds
266
+ const MAX_SEARCH_RESULTS = 50;
267
+ // Dynamically construct the searchTask description
268
+ const searchTaskDescriptionBase = [
269
+ "Searches tasks by name, content, assignees, and ID (case insensitive) with fuzzy matching and support for multiple search terms (OR logic).",
270
+ // Placeholder for language-specific guidance
271
+ "You'll get a rough overview of the tasks that match the search terms, sorted by relevance.",
154
272
  "Always use getTaskById to get more specific information if a task is relevant.",
155
- ].join("\n"), {
273
+ ];
274
+ if (CONFIG.primaryLanguageHint && CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
275
+ searchTaskDescriptionBase.splice(1, 0, `For optimal results, as your ClickUp tasks may be primarily in '${CONFIG.primaryLanguageHint}', consider providing search terms in English and '${CONFIG.primaryLanguageHint}'.`);
276
+ }
277
+ exports.server.tool("searchTask", searchTaskDescriptionBase.join("\n"), {
156
278
  terms: zod_1.z
157
279
  .string()
158
280
  .min(3)
159
- .describe("Search terms separated by '|' for OR logic (e.g., 'term1|term2|term3')"),
281
+ .describe("Search terms separated by '|' for OR logic (e.g., 'term1|term2|term3') or a direct task ID"),
160
282
  }, async ({ terms }) => {
161
- const timeSinceLastUpdate = Date.now() - lastTaskCacheUpdate;
162
- if (timeSinceLastUpdate > 10000) {
163
- const taskLists = await Promise.all([...Array(30)].map((_, i) => {
164
- return fetch(`https://api.clickup.com/api/v2/team/${CONFIG.teamId}/task?order_by=updated&page=${i}`, { headers: { Authorization: CONFIG.apiKey } }).then((res) => res.json());
165
- }));
166
- cachedTasks = taskLists.flatMap((taskList) => taskList.tasks);
167
- lastTaskCacheUpdate = Date.now();
168
- }
169
- const searchTerms = terms
170
- .split("|")
171
- .map((term) => term.trim().toLowerCase());
172
- // Check if any search term looks like a task ID
173
- const potentialTaskIds = searchTerms.filter(isTaskId);
174
- // Fetch tasks from cache that match search terms
175
- const tasksFromCache = cachedTasks.filter((task) => {
176
- const taskNameLower = task.name.toLowerCase();
177
- const taskId = task.id.toLowerCase();
178
- return searchTerms.some((term) => taskNameLower.includes(term) || taskId.includes(term));
179
- });
180
- // Fetch tasks by ID directly if they look like task IDs and they're not already in the cache
181
- const tasksToFetch = potentialTaskIds.filter(id => {
182
- // Check if this ID is already in the tasksFromCache
183
- return !tasksFromCache.some(task => task.id.toLowerCase() === id.toLowerCase());
184
- });
185
- const taskPromises = tasksToFetch.map(async (id) => {
186
- try {
187
- // Fetch task directly from API
188
- const response = await fetch(`https://api.clickup.com/api/v2/task/${id}`, { headers: { Authorization: CONFIG.apiKey } });
189
- if (!response.ok)
190
- return null;
191
- const task = await response.json();
192
- return task;
283
+ const now = Date.now();
284
+ if (!taskSearchIndex || (now - lastIndexUpdateTime > INDEX_REFRESH_INTERVAL)) {
285
+ console.error('Refreshing ClickUp task index...');
286
+ const taskListsPromises = [...Array(30)].map((_, i) => {
287
+ return fetch(`https://api.clickup.com/api/v2/team/${CONFIG.teamId}/task?order_by=updated&page=${i}&subtasks=true`, { headers: { Authorization: CONFIG.apiKey } }).then((res) => res.json()).catch(e => {
288
+ console.error(`Error fetching page ${i} for index:`, e);
289
+ return { tasks: [] };
290
+ });
291
+ });
292
+ const taskLists = await Promise.all(taskListsPromises);
293
+ const allFetchedTasks = taskLists.flatMap(taskList => taskList.tasks);
294
+ if (allFetchedTasks.length > 0) {
295
+ taskSearchIndex = new fuse_js_1.default(allFetchedTasks, {
296
+ keys: [
297
+ { name: 'name', weight: 0.7 },
298
+ { name: 'id', weight: 0.6 },
299
+ { name: 'text_content', weight: 0.5 }, // Task description/content
300
+ { name: 'tags.name', weight: 0.4 }, // Task Tags
301
+ { name: 'assignees.username', weight: 0.4 }, // Task Assignees
302
+ { name: 'list.name', weight: 0.3 }, // Name of the List the task is in
303
+ { name: 'folder.name', weight: 0.2 }, // Name of the Folder the task is in
304
+ { name: 'space.name', weight: 0.1 } // Name of the Space the task is in
305
+ ],
306
+ includeScore: true,
307
+ threshold: 0.4,
308
+ minMatchCharLength: 2,
309
+ });
310
+ lastIndexUpdateTime = now;
311
+ console.error(`Task index refreshed with ${allFetchedTasks.length} tasks.`);
193
312
  }
194
- catch (error) {
195
- console.error(`Error fetching task ${id}:`, error);
196
- return null;
313
+ else {
314
+ console.error('No tasks fetched to build search index.');
315
+ // Potentially keep the old index if fetching failed, or clear it
316
+ // For now, if fetching fails to get any tasks, the index won't update.
197
317
  }
318
+ }
319
+ const searchTermsArray = terms
320
+ .split("|")
321
+ .map((term) => term.trim())
322
+ .filter(term => term.length > 0);
323
+ if (searchTermsArray.length === 0) {
324
+ return { content: [{ type: "text", text: "No search terms provided." }] };
325
+ }
326
+ const uniqueResults = new Map();
327
+ if (taskSearchIndex) {
328
+ searchTermsArray.forEach(term => {
329
+ const results = taskSearchIndex.search(term.toLowerCase());
330
+ results.forEach(result => {
331
+ if (result.item && typeof result.item.id === 'string') {
332
+ const currentScore = result.score ?? 1; // Default to 1 if undefined
333
+ const existing = uniqueResults.get(result.item.id);
334
+ if (!existing || currentScore < existing.score) {
335
+ uniqueResults.set(result.item.id, { item: result.item, score: currentScore });
336
+ }
337
+ }
338
+ });
339
+ });
340
+ }
341
+ // Task ID Fallback Logic
342
+ const potentialTaskIds = searchTermsArray.filter(isTaskId);
343
+ const foundTaskIdsByFuse = new Set(Array.from(uniqueResults.keys()).map(id => id.toLowerCase())); // Store lowercase for comparison
344
+ // Filter task IDs that were not found by Fuse, comparing case-insensitively
345
+ const taskIdsToFetchDirectly = potentialTaskIds.filter(id => {
346
+ const lowerId = id.toLowerCase();
347
+ return !foundTaskIdsByFuse.has(lowerId);
198
348
  });
199
- // Wait for all task fetches to complete
200
- const directlyFetchedTasks = await Promise.all(taskPromises);
201
- // Combine tasks from cache and directly fetched tasks, removing nulls
202
- const allTasks = [
203
- ...tasksFromCache,
204
- ...directlyFetchedTasks.filter(Boolean)
205
- ];
206
- const tasks = allTasks;
207
- if (tasks.length === 0) {
349
+ if (taskIdsToFetchDirectly.length > 0) {
350
+ console.error(`Attempting direct fetch for task IDs: ${taskIdsToFetchDirectly.join(', ')}`);
351
+ const directFetchPromises = taskIdsToFetchDirectly.map(async (id) => {
352
+ try {
353
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${id}`, { headers: { Authorization: CONFIG.apiKey } });
354
+ if (response.ok) {
355
+ const task = await response.json();
356
+ if (task && typeof task.id === 'string') {
357
+ // Add/update with a perfect score if fetched directly, unless a better Fuse score already exists
358
+ const existing = uniqueResults.get(task.id);
359
+ if (!existing || 0 < existing.score) { // 0 is a perfect score
360
+ uniqueResults.set(task.id, { item: task, score: 0 });
361
+ }
362
+ }
363
+ return task;
364
+ }
365
+ return null;
366
+ }
367
+ catch (error) {
368
+ console.error(`Error directly fetching task ${id}:`, error);
369
+ return null;
370
+ }
371
+ });
372
+ await Promise.all(directFetchPromises);
373
+ }
374
+ const sortedResults = Array.from(uniqueResults.values())
375
+ .sort((a, b) => a.score - b.score) // Sort by score, ascending (lower is better)
376
+ .map(entry => entry.item)
377
+ .slice(0, MAX_SEARCH_RESULTS); // Limit the number of results
378
+ if (sortedResults.length === 0) {
208
379
  return {
209
380
  content: [
210
381
  {
@@ -215,10 +386,10 @@ exports.server.tool("searchTask", [
215
386
  };
216
387
  }
217
388
  return {
218
- content: tasks.map((task) => generateTaskMetadata(task)),
389
+ content: await Promise.all(sortedResults.map((task) => generateTaskMetadata(task))),
219
390
  };
220
391
  });
221
- exports.server.tool("listTodo", "Lists all open tasks for the current user.", {}, async () => {
392
+ exports.server.tool("listTodo", "Lists all open tasks for the current user.", async () => {
222
393
  // fetch current user ID
223
394
  const userResp = await fetch("https://api.clickup.com/api/v2/user", {
224
395
  headers: { Authorization: CONFIG.apiKey },
@@ -239,7 +410,7 @@ exports.server.tool("listTodo", "Lists all open tasks for the current user.", {}
239
410
  };
240
411
  }
241
412
  return {
242
- content: openTasks.map((task) => generateTaskMetadata(task)),
413
+ content: await Promise.all(openTasks.map((task) => generateTaskMetadata(task))),
243
414
  };
244
415
  });
245
416
  // Only connect to the transport if this file is being run directly (not imported)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hauptsache.net/clickup-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "A minimal implementation of a Model Context Protocol (MCP) server for ClickUp integration",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -29,10 +29,12 @@
29
29
  "license": "ISC",
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.9.0",
32
+ "fuse.js": "^7.1.0",
32
33
  "zod": "^3.24.2"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^22.14.1",
37
+ "dotenv": "^16.5.0",
36
38
  "nodemon": "^3.1.9",
37
39
  "prettier": "^3.5.3",
38
40
  "typescript": "^5.8.3"