@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 +18 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -13
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +268 -97
- package/package.json +3 -1
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
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
console.error(
|
|
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,
|
|
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"}
|
package/dist/clickup-text.js
CHANGED
|
@@ -35,9 +35,13 @@ async function processClickUpText(textItems) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
// Handle text items
|
|
38
|
-
else if (item.text
|
|
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()) {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
150
|
-
let
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
]
|
|
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.
|
|
171
|
-
.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
console.error(
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
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.",
|
|
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
|
+
"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"
|