@opsee/mcp-server 0.6.1 → 0.6.3
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 +34 -4
- package/gen/api/v1/acceptance_criterion_pb.d.ts +195 -0
- package/gen/api/v1/acceptance_criterion_pb.js +73 -0
- package/gen/api/v1/doc_template_pb.d.ts +244 -0
- package/gen/api/v1/doc_template_pb.js +77 -0
- package/gen/api/v1/models_pb.d.ts +61 -0
- package/gen/api/v1/models_pb.js +8 -1
- package/gen/api/v1/task_dependency_pb.d.ts +67 -1
- package/gen/api/v1/task_dependency_pb.js +17 -3
- package/gen/api/v1/task_pb.d.ts +49 -1
- package/gen/api/v1/task_pb.js +1 -1
- package/gen/api/v1/task_template_pb.d.ts +349 -0
- package/gen/api/v1/task_template_pb.js +77 -0
- package/package.json +1 -1
- package/src/__tests__/tools.test.ts +409 -3
- package/src/client/api.ts +6 -0
- package/src/server.ts +6 -0
- package/src/tools/acceptance-criteria.ts +127 -0
- package/src/tools/comments.ts +40 -1
- package/src/tools/cycles.ts +189 -1
- package/src/tools/docs.ts +1 -1
- package/src/tools/labels.ts +3 -3
- package/src/tools/milestones.ts +3 -3
- package/src/tools/notifications.ts +171 -0
- package/src/tools/task-dependencies.ts +43 -1
- package/src/tools/tasks.ts +316 -11
- package/src/tools/work-logs.ts +123 -0
- package/src/utils/format.ts +3 -0
package/src/tools/tasks.ts
CHANGED
|
@@ -4,7 +4,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import type { ApiClients } from "../client/api.js";
|
|
5
5
|
import { defaultPagination } from "../client/api.js";
|
|
6
6
|
import { authManager } from "../auth/manager.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
formatTaskList,
|
|
9
|
+
formatTask,
|
|
10
|
+
formatTaskDependencyList,
|
|
11
|
+
formatCommentList,
|
|
12
|
+
formatTaskLabelList,
|
|
13
|
+
formatError,
|
|
14
|
+
} from "../utils/format.js";
|
|
8
15
|
import {
|
|
9
16
|
FilterOptionsSchema,
|
|
10
17
|
FilterSchema,
|
|
@@ -16,18 +23,33 @@ export function registerTaskTools(
|
|
|
16
23
|
): void {
|
|
17
24
|
server.tool(
|
|
18
25
|
"opsee_list_tasks",
|
|
19
|
-
"List tasks in an Opsee project. Supports filtering by status, assignee, board column, cycle, and
|
|
26
|
+
"List tasks in an Opsee project. Supports filtering by status, assignee, board column, cycle, labels, priority, type, milestone, parent, due date, and update/create recency. labelIds uses AND semantics; multi-id filters (priorityIds/taskTypeIds/milestoneIds) use OR semantics.",
|
|
20
27
|
{
|
|
21
28
|
projectId: z.number().describe("The project ID"),
|
|
22
29
|
columnId: z.number().optional().describe("Filter by board column ID (status)"),
|
|
23
30
|
assigneeId: z.number().optional().describe("Filter by assigned user ID"),
|
|
24
31
|
cycleId: z.number().optional().describe("Filter by cycle/sprint ID"),
|
|
25
32
|
labelIds: z.array(z.number().int().min(1)).optional().describe("Filter by label IDs (AND semantics — task must have all listed labels)"),
|
|
33
|
+
priorityIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more priority IDs (OR semantics)"),
|
|
34
|
+
taskTypeIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more task type IDs (OR semantics)"),
|
|
35
|
+
milestoneIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more milestone IDs (OR semantics)"),
|
|
36
|
+
parentTaskId: z.number().int().min(1).optional().describe("Filter to subtasks of this task ID"),
|
|
37
|
+
hasParent: z.boolean().optional().describe("true = only subtasks, false = only top-level tasks"),
|
|
38
|
+
unassigned: z.boolean().optional().describe("true = only unassigned tasks (triage queue)"),
|
|
39
|
+
updatedSince: z.string().optional().describe("Only tasks updated at or after this timestamp (RFC3339 or YYYY-MM-DD)"),
|
|
40
|
+
createdSince: z.string().optional().describe("Only tasks created at or after this timestamp (RFC3339 or YYYY-MM-DD)"),
|
|
41
|
+
dueBefore: z.string().optional().describe("Only tasks due at or before this date (RFC3339 or YYYY-MM-DD)"),
|
|
42
|
+
dueAfter: z.string().optional().describe("Only tasks due at or after this date (RFC3339 or YYYY-MM-DD)"),
|
|
26
43
|
page: z.number().optional().describe("Page number (default: 1)"),
|
|
27
44
|
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
28
45
|
},
|
|
29
46
|
{ readOnlyHint: true, destructiveHint: false },
|
|
30
|
-
async ({
|
|
47
|
+
async ({
|
|
48
|
+
projectId, columnId, assigneeId, cycleId, labelIds,
|
|
49
|
+
priorityIds, taskTypeIds, milestoneIds, parentTaskId, hasParent,
|
|
50
|
+
unassigned, updatedSince, createdSince, dueBefore, dueAfter,
|
|
51
|
+
page, pageSize,
|
|
52
|
+
}) => {
|
|
31
53
|
try {
|
|
32
54
|
const clients = getClients();
|
|
33
55
|
|
|
@@ -35,6 +57,16 @@ export function registerTaskTools(
|
|
|
35
57
|
if (columnId) filters.push({ key: "BoardColumnId", value: String(columnId) });
|
|
36
58
|
if (assigneeId) filters.push({ key: "AssignedUserId", value: String(assigneeId) });
|
|
37
59
|
if (cycleId) filters.push({ key: "CycleId", value: String(cycleId) });
|
|
60
|
+
if (priorityIds?.length) filters.push({ key: "TaskPriorityId", value: priorityIds.join(",") });
|
|
61
|
+
if (taskTypeIds?.length) filters.push({ key: "TaskTypeId", value: taskTypeIds.join(",") });
|
|
62
|
+
if (milestoneIds?.length) filters.push({ key: "MilestoneId", value: milestoneIds.join(",") });
|
|
63
|
+
if (parentTaskId) filters.push({ key: "ParentTaskId", value: String(parentTaskId) });
|
|
64
|
+
if (hasParent !== undefined) filters.push({ key: "HasParent", value: hasParent ? "true" : "false" });
|
|
65
|
+
if (unassigned) filters.push({ key: "Unassigned", value: "true" });
|
|
66
|
+
if (updatedSince) filters.push({ key: "UpdatedSince", value: updatedSince });
|
|
67
|
+
if (createdSince) filters.push({ key: "CreatedSince", value: createdSince });
|
|
68
|
+
if (dueBefore) filters.push({ key: "DueBefore", value: dueBefore });
|
|
69
|
+
if (dueAfter) filters.push({ key: "DueAfter", value: dueAfter });
|
|
38
70
|
|
|
39
71
|
const filterOptions = filters.length > 0
|
|
40
72
|
? create(FilterOptionsSchema, {
|
|
@@ -73,13 +105,75 @@ export function registerTaskTools(
|
|
|
73
105
|
},
|
|
74
106
|
);
|
|
75
107
|
|
|
108
|
+
server.tool(
|
|
109
|
+
"opsee_get_task_with_context",
|
|
110
|
+
"One-shot composer that returns a task plus all its related context: labels, comments, dependencies (both directions), parent task, and subtasks. Use this when picking up a ticket — saves ~5 round-trips compared to calling each tool separately.",
|
|
111
|
+
{ taskId: z.number().describe("The task ID") },
|
|
112
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
113
|
+
async ({ taskId }) => {
|
|
114
|
+
try {
|
|
115
|
+
const clients = getClients();
|
|
116
|
+
|
|
117
|
+
const taskRes = await clients.tasks.getTask({ id: taskId });
|
|
118
|
+
const task = taskRes.task;
|
|
119
|
+
if (!task) {
|
|
120
|
+
return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [labelsRes, depsRes, commentsRes, subtasksRes, parentRes] = await Promise.all([
|
|
124
|
+
clients.taskLabels.getTaskLabels({
|
|
125
|
+
taskId,
|
|
126
|
+
pagination: defaultPagination(1, 50),
|
|
127
|
+
}),
|
|
128
|
+
clients.taskDependencies.getTaskDependencies({
|
|
129
|
+
taskId,
|
|
130
|
+
pagination: defaultPagination(1, 50),
|
|
131
|
+
}),
|
|
132
|
+
clients.comments.getComments({
|
|
133
|
+
taskId,
|
|
134
|
+
pagination: defaultPagination(1, 50),
|
|
135
|
+
}),
|
|
136
|
+
clients.tasks.getTasks({
|
|
137
|
+
projectId: task.project?.id ?? 0,
|
|
138
|
+
pagination: defaultPagination(1, 50),
|
|
139
|
+
filterOptions: create(FilterOptionsSchema, {
|
|
140
|
+
filters: [
|
|
141
|
+
create(FilterSchema, { key: "ParentTaskId", value: String(task.id) }),
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
}),
|
|
145
|
+
task.parentTaskId
|
|
146
|
+
? clients.tasks.getTask({ id: task.parentTaskId })
|
|
147
|
+
: Promise.resolve(null),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const sections: string[] = [];
|
|
151
|
+
sections.push(formatTask(task));
|
|
152
|
+
|
|
153
|
+
const parentTask = parentRes?.task;
|
|
154
|
+
if (parentTask) {
|
|
155
|
+
sections.push(`\n--- Parent Task ---\n[${parentTask.identifier}] ${parentTask.title} (ID: ${parentTask.id})`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sections.push(`\n--- Labels ---\n${formatTaskLabelList(labelsRes.taskLabels)}`);
|
|
159
|
+
sections.push(`\n--- Dependencies ---\n${formatTaskDependencyList(depsRes.taskDependencies)}`);
|
|
160
|
+
sections.push(`\n--- Subtasks ---\n${formatTaskList(subtasksRes.tasks)}`);
|
|
161
|
+
sections.push(`\n--- Comments ---\n${formatCommentList(commentsRes.comments)}`);
|
|
162
|
+
|
|
163
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
76
170
|
server.tool(
|
|
77
171
|
"opsee_create_task",
|
|
78
172
|
"Create a new task in an Opsee project. Use opsee_list_task_types, opsee_list_task_priorities, and opsee_list_board_columns first to get valid values for optional fields.",
|
|
79
173
|
{
|
|
80
174
|
projectId: z.number().describe("The project ID"),
|
|
81
175
|
title: z.string().describe("Task title"),
|
|
82
|
-
description: z.string().optional().describe("Task description.
|
|
176
|
+
description: z.string().optional().describe("Task description. Markdown is supported — headings (##), bullets (-), code fences, inline **bold** / *italic* / `code` / links all render as proper formatted blocks in the UI. For ACCEPTANCE CRITERIA, write them inline in this description as a `## Acceptance criteria` heading followed by markdown checkbox lines: `- [ ] criterion text` and `- [x] done item`. The UI renders those as clickable checkboxes (BlockNote checkListItem) and users can toggle them. Do NOT use the structured opsee_add_acceptance_criterion tool for typical AC — it writes to a separate DB table that does not surface in the task detail UI; it's reserved for automation paths (PR-to-criterion mapping etc.). Written as-is to the backend; the frontend BlockEditor parses markdown on read and saves the parsed BlockNote JSON on the next edit, so the round-trip preserves structure."),
|
|
83
177
|
taskTypeId: z.number().optional().describe("Task type ID (from opsee_list_task_types)"),
|
|
84
178
|
priorityId: z.number().optional().describe("Priority ID (from opsee_list_task_priorities)"),
|
|
85
179
|
boardColumnId: z.number().optional().describe("Board column ID (from opsee_list_board_columns)"),
|
|
@@ -87,9 +181,10 @@ export function registerTaskTools(
|
|
|
87
181
|
cycleId: z.number().optional().describe("Cycle/sprint ID"),
|
|
88
182
|
storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
|
|
89
183
|
estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
|
|
184
|
+
labelIds: z.array(z.number().int().min(1)).optional().describe("Label IDs to attach to the new task. Saves a follow-up opsee_attach_label_to_task call per label."),
|
|
90
185
|
},
|
|
91
186
|
{ readOnlyHint: false, destructiveHint: false },
|
|
92
|
-
async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours }) => {
|
|
187
|
+
async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours, labelIds }) => {
|
|
93
188
|
try {
|
|
94
189
|
const clients = getClients();
|
|
95
190
|
|
|
@@ -158,10 +253,11 @@ export function registerTaskTools(
|
|
|
158
253
|
storyPoints,
|
|
159
254
|
estimatedHours,
|
|
160
255
|
repositoryIds: [],
|
|
256
|
+
labelIds: labelIds ?? [],
|
|
161
257
|
});
|
|
162
258
|
|
|
163
259
|
if (!res.task)
|
|
164
|
-
return { content: [{ type: "text", text: "Failed to create task." }] };
|
|
260
|
+
return { content: [{ type: "text", text: "Failed to create task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
165
261
|
|
|
166
262
|
return {
|
|
167
263
|
content: [
|
|
@@ -180,7 +276,7 @@ export function registerTaskTools(
|
|
|
180
276
|
{
|
|
181
277
|
taskId: z.number().describe("The task ID to update"),
|
|
182
278
|
title: z.string().optional().describe("New title"),
|
|
183
|
-
description: z.string().optional().describe("New description.
|
|
279
|
+
description: z.string().optional().describe("New description. Markdown is supported — headings (##), bullets (-), code fences, inline **bold** / *italic* / `code` / links all render as proper formatted blocks in the UI. For ACCEPTANCE CRITERIA, write them inline in this description as a `## Acceptance criteria` heading followed by markdown checkbox lines: `- [ ] criterion text` and `- [x] done item`. The UI renders those as clickable checkboxes (BlockNote checkListItem) and users can toggle them. Do NOT use the structured opsee_add_acceptance_criterion tool for typical AC — it writes to a separate DB table that does not surface in the task detail UI; it's reserved for automation paths (PR-to-criterion mapping etc.). Written as-is to the backend; the frontend BlockEditor parses markdown on read and saves the parsed BlockNote JSON on the next edit, so the round-trip preserves structure."),
|
|
184
280
|
taskTypeId: z.number().optional().describe("New task type ID"),
|
|
185
281
|
priorityId: z.number().optional().describe("New priority ID"),
|
|
186
282
|
boardColumnId: z.number().optional().describe("New board column ID (status)"),
|
|
@@ -188,9 +284,10 @@ export function registerTaskTools(
|
|
|
188
284
|
cycleId: z.number().optional().describe("New cycle/sprint ID"),
|
|
189
285
|
storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
|
|
190
286
|
estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
|
|
287
|
+
labelIds: z.array(z.number().int().min(0)).optional().describe("Replace this task's labels with this set. Omit to leave labels unchanged. Empty array clears all labels."),
|
|
191
288
|
},
|
|
192
289
|
{ readOnlyHint: false, destructiveHint: false },
|
|
193
|
-
async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours }) => {
|
|
290
|
+
async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours, labelIds }) => {
|
|
194
291
|
try {
|
|
195
292
|
const clients = getClients();
|
|
196
293
|
|
|
@@ -222,10 +319,11 @@ export function registerTaskTools(
|
|
|
222
319
|
assignedUserId: assigneeId ?? task.assignedUser?.id,
|
|
223
320
|
cycleId: cycleId ?? task.cycle?.id,
|
|
224
321
|
repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
|
|
322
|
+
labelIds,
|
|
225
323
|
});
|
|
226
324
|
|
|
227
325
|
if (!res.task)
|
|
228
|
-
return { content: [{ type: "text", text: "Failed to update task." }] };
|
|
326
|
+
return { content: [{ type: "text", text: "Failed to update task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
229
327
|
|
|
230
328
|
return {
|
|
231
329
|
content: [
|
|
@@ -240,7 +338,7 @@ export function registerTaskTools(
|
|
|
240
338
|
|
|
241
339
|
server.tool(
|
|
242
340
|
"opsee_bulk_update_tasks",
|
|
243
|
-
"Atomicity: backend wraps all updates in one DB transaction. Per-task lookup failures (task not visible to your company, or unknown ID) are silently skipped — compare updated_count against taskIds.length to detect partial application. Omitted fields are not touched.",
|
|
341
|
+
"Atomicity: backend wraps all updates in one DB transaction. Per-task lookup failures (task not visible to your company, or unknown ID) are silently skipped — compare updated_count against taskIds.length to detect partial application. Omitted fields are not touched. labelIds with a non-empty list replaces each task's labels; empty/omitted leaves them alone.",
|
|
244
342
|
{
|
|
245
343
|
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
246
344
|
taskIds: z.array(z.number()).min(1).describe("Task IDs to update (at least one)"),
|
|
@@ -249,9 +347,13 @@ export function registerTaskTools(
|
|
|
249
347
|
taskTypeId: z.number().optional().describe("New task type ID"),
|
|
250
348
|
assignedUserId: z.number().optional().describe("New assigned user ID"),
|
|
251
349
|
cycleId: z.number().optional().describe("New cycle/sprint ID"),
|
|
350
|
+
storyPoints: z.number().int().min(1).optional().describe("Story points to set on every task"),
|
|
351
|
+
estimatedHours: z.number().min(0).optional().describe("Estimated hours to set on every task"),
|
|
352
|
+
parentTaskId: z.number().int().min(0).optional().describe("Parent task ID for every task (pass 0 to clear)"),
|
|
353
|
+
labelIds: z.array(z.number().int().min(1)).optional().describe("Replace label attachments on every task with this set. Empty / omitted leaves labels untouched."),
|
|
252
354
|
},
|
|
253
355
|
{ readOnlyHint: false, destructiveHint: false },
|
|
254
|
-
async ({ projectId, taskIds, boardColumnId, taskPriorityId, taskTypeId, assignedUserId, cycleId }) => {
|
|
356
|
+
async ({ projectId, taskIds, boardColumnId, taskPriorityId, taskTypeId, assignedUserId, cycleId, storyPoints, estimatedHours, parentTaskId, labelIds }) => {
|
|
255
357
|
try {
|
|
256
358
|
const clients = getClients();
|
|
257
359
|
const res = await clients.tasks.bulkEditTasks({
|
|
@@ -262,7 +364,11 @@ export function registerTaskTools(
|
|
|
262
364
|
taskTypeId,
|
|
263
365
|
assignedUserId,
|
|
264
366
|
cycleId,
|
|
367
|
+
storyPoints,
|
|
368
|
+
estimatedHours,
|
|
369
|
+
parentTaskId,
|
|
265
370
|
repositoryIds: [],
|
|
371
|
+
labelIds: labelIds ?? [],
|
|
266
372
|
});
|
|
267
373
|
|
|
268
374
|
const requested = taskIds.length;
|
|
@@ -277,4 +383,203 @@ export function registerTaskTools(
|
|
|
277
383
|
}
|
|
278
384
|
},
|
|
279
385
|
);
|
|
386
|
+
|
|
387
|
+
server.tool(
|
|
388
|
+
"opsee_bulk_move_to_cycle",
|
|
389
|
+
"Convenience wrapper: move many tasks to the same cycle in one call. Equivalent to opsee_bulk_update_tasks with only cycleId set.",
|
|
390
|
+
{
|
|
391
|
+
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
392
|
+
taskIds: z.array(z.number()).min(1).describe("Task IDs to move"),
|
|
393
|
+
cycleId: z.number().describe("Target cycle/sprint ID"),
|
|
394
|
+
},
|
|
395
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
396
|
+
async ({ projectId, taskIds, cycleId }) => {
|
|
397
|
+
try {
|
|
398
|
+
const clients = getClients();
|
|
399
|
+
const res = await clients.tasks.bulkEditTasks({
|
|
400
|
+
projectId,
|
|
401
|
+
taskIds,
|
|
402
|
+
cycleId,
|
|
403
|
+
repositoryIds: [],
|
|
404
|
+
labelIds: [],
|
|
405
|
+
});
|
|
406
|
+
const text = `Moved ${res.updatedCount} of ${taskIds.length} task(s) to cycle ${cycleId}.`;
|
|
407
|
+
return { content: [{ type: "text", text }] };
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
server.tool(
|
|
415
|
+
"opsee_bulk_move_to_column",
|
|
416
|
+
"Convenience wrapper: move many tasks to the same board column (status) in one call.",
|
|
417
|
+
{
|
|
418
|
+
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
419
|
+
taskIds: z.array(z.number()).min(1).describe("Task IDs to move"),
|
|
420
|
+
boardColumnId: z.number().describe("Target board column ID (status)"),
|
|
421
|
+
},
|
|
422
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
423
|
+
async ({ projectId, taskIds, boardColumnId }) => {
|
|
424
|
+
try {
|
|
425
|
+
const clients = getClients();
|
|
426
|
+
const res = await clients.tasks.bulkEditTasks({
|
|
427
|
+
projectId,
|
|
428
|
+
taskIds,
|
|
429
|
+
boardColumnId,
|
|
430
|
+
repositoryIds: [],
|
|
431
|
+
labelIds: [],
|
|
432
|
+
});
|
|
433
|
+
const text = `Moved ${res.updatedCount} of ${taskIds.length} task(s) to column ${boardColumnId}.`;
|
|
434
|
+
return { content: [{ type: "text", text }] };
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
server.tool(
|
|
442
|
+
"opsee_bulk_assign",
|
|
443
|
+
"Convenience wrapper: assign many tasks to the same user in one call.",
|
|
444
|
+
{
|
|
445
|
+
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
446
|
+
taskIds: z.array(z.number()).min(1).describe("Task IDs to reassign"),
|
|
447
|
+
assigneeId: z.number().describe("Target user ID"),
|
|
448
|
+
},
|
|
449
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
450
|
+
async ({ projectId, taskIds, assigneeId }) => {
|
|
451
|
+
try {
|
|
452
|
+
const clients = getClients();
|
|
453
|
+
const res = await clients.tasks.bulkEditTasks({
|
|
454
|
+
projectId,
|
|
455
|
+
taskIds,
|
|
456
|
+
assignedUserId: assigneeId,
|
|
457
|
+
repositoryIds: [],
|
|
458
|
+
labelIds: [],
|
|
459
|
+
});
|
|
460
|
+
const text = `Assigned ${res.updatedCount} of ${taskIds.length} task(s) to user ${assigneeId}.`;
|
|
461
|
+
return { content: [{ type: "text", text }] };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
server.tool(
|
|
469
|
+
"opsee_bulk_set_priority",
|
|
470
|
+
"Convenience wrapper: set the same priority on many tasks in one call.",
|
|
471
|
+
{
|
|
472
|
+
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
473
|
+
taskIds: z.array(z.number()).min(1).describe("Task IDs to reprioritize"),
|
|
474
|
+
priorityId: z.number().describe("Target priority ID (from opsee_list_task_priorities)"),
|
|
475
|
+
},
|
|
476
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
477
|
+
async ({ projectId, taskIds, priorityId }) => {
|
|
478
|
+
try {
|
|
479
|
+
const clients = getClients();
|
|
480
|
+
const res = await clients.tasks.bulkEditTasks({
|
|
481
|
+
projectId,
|
|
482
|
+
taskIds,
|
|
483
|
+
taskPriorityId: priorityId,
|
|
484
|
+
repositoryIds: [],
|
|
485
|
+
labelIds: [],
|
|
486
|
+
});
|
|
487
|
+
const text = `Set priority ${priorityId} on ${res.updatedCount} of ${taskIds.length} task(s).`;
|
|
488
|
+
return { content: [{ type: "text", text }] };
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
server.tool(
|
|
496
|
+
"opsee_replace_task_labels",
|
|
497
|
+
"Replace the entire set of labels on a single task. Use opsee_update_task with labelIds for the same effect, but this tool is more discoverable for the common 'set my labels to [...]' flow.",
|
|
498
|
+
{
|
|
499
|
+
taskId: z.number().describe("The task ID"),
|
|
500
|
+
labelIds: z.array(z.number().int().min(1)).describe("Label IDs to set (empty array clears all labels)"),
|
|
501
|
+
},
|
|
502
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
503
|
+
async ({ taskId, labelIds }) => {
|
|
504
|
+
try {
|
|
505
|
+
const clients = getClients();
|
|
506
|
+
const getRes = await clients.tasks.getTask({ id: taskId });
|
|
507
|
+
const task = getRes.task;
|
|
508
|
+
if (!task) {
|
|
509
|
+
return { content: [{ type: "text", text: "Task not found." }] };
|
|
510
|
+
}
|
|
511
|
+
const res = await clients.tasks.editTask({
|
|
512
|
+
id: task.id,
|
|
513
|
+
identifier: task.identifier,
|
|
514
|
+
title: task.title,
|
|
515
|
+
description: task.description,
|
|
516
|
+
displayOrder: task.displayOrder,
|
|
517
|
+
storyPoints: task.storyPoints,
|
|
518
|
+
estimatedHours: task.estimatedHours,
|
|
519
|
+
actualHours: task.actualHours,
|
|
520
|
+
dueDate: task.dueDate,
|
|
521
|
+
aiModeEnabled: task.aiModeEnabled,
|
|
522
|
+
metadataJson: task.metadataJson,
|
|
523
|
+
boardId: task.board?.id ?? 0,
|
|
524
|
+
boardColumnId: task.boardColumn?.id ?? 0,
|
|
525
|
+
projectId: task.project?.id ?? 0,
|
|
526
|
+
taskTypeId: task.taskType?.id ?? 0,
|
|
527
|
+
taskPriorityId: task.taskPriority?.id ?? 0,
|
|
528
|
+
reporterUserId: task.reporterUser?.id ?? 0,
|
|
529
|
+
assignedUserId: task.assignedUser?.id,
|
|
530
|
+
cycleId: task.cycle?.id,
|
|
531
|
+
repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
|
|
532
|
+
labelIds,
|
|
533
|
+
});
|
|
534
|
+
if (!res.task) {
|
|
535
|
+
return { content: [{ type: "text", text: "Failed to update task labels. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
536
|
+
}
|
|
537
|
+
const verb = labelIds.length === 0 ? "Cleared all labels on" : `Set ${labelIds.length} label(s) on`;
|
|
538
|
+
return { content: [{ type: "text", text: `${verb} ${res.task.identifier}.` }] };
|
|
539
|
+
} catch (error) {
|
|
540
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
server.tool(
|
|
546
|
+
"opsee_delete_task",
|
|
547
|
+
"Delete a task by ID. This is permanent.",
|
|
548
|
+
{ taskId: z.number().describe("The task ID to delete") },
|
|
549
|
+
{ readOnlyHint: false, destructiveHint: true },
|
|
550
|
+
async ({ taskId }) => {
|
|
551
|
+
try {
|
|
552
|
+
const clients = getClients();
|
|
553
|
+
await clients.tasks.deleteTask({ id: taskId });
|
|
554
|
+
return { content: [{ type: "text", text: "Task deleted." }] };
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
server.tool(
|
|
562
|
+
"opsee_bulk_delete_tasks",
|
|
563
|
+
"Delete multiple tasks in one call. Tasks not visible to your company or not found are silently skipped — compare deletedCount against taskIds.length to detect partial application. This is permanent.",
|
|
564
|
+
{
|
|
565
|
+
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
566
|
+
taskIds: z.array(z.number()).min(1).describe("Task IDs to delete (at least one)"),
|
|
567
|
+
},
|
|
568
|
+
{ readOnlyHint: false, destructiveHint: true },
|
|
569
|
+
async ({ projectId, taskIds }) => {
|
|
570
|
+
try {
|
|
571
|
+
const clients = getClients();
|
|
572
|
+
const res = await clients.tasks.bulkDeleteTasks({ projectId, taskIds });
|
|
573
|
+
const requested = taskIds.length;
|
|
574
|
+
const deleted = res.deletedCount;
|
|
575
|
+
let text = `Deleted ${deleted} of ${requested} task(s).`;
|
|
576
|
+
if (deleted < requested) {
|
|
577
|
+
text += " (Tasks not visible to your company or not found were skipped.)";
|
|
578
|
+
}
|
|
579
|
+
return { content: [{ type: "text", text }] };
|
|
580
|
+
} catch (error) {
|
|
581
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
);
|
|
280
585
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { ApiClients } from "../client/api.js";
|
|
4
|
+
import { formatError } from "../utils/format.js";
|
|
5
|
+
|
|
6
|
+
// Work log RPCs live on TaskService (AddWorkLog / DeleteWorkLog). There is
|
|
7
|
+
// no list endpoint — work logs are preloaded onto the Task by GetTask, so
|
|
8
|
+
// we surface them by hitting GetTask and projecting the embedded work_logs.
|
|
9
|
+
|
|
10
|
+
export function registerWorkLogTools(
|
|
11
|
+
server: McpServer,
|
|
12
|
+
getClients: () => ApiClients,
|
|
13
|
+
): void {
|
|
14
|
+
server.tool(
|
|
15
|
+
"opsee_list_work_logs",
|
|
16
|
+
"List all work-log entries for a task, oldest first. Includes the user who logged each entry, hours, and optional description.",
|
|
17
|
+
{ taskId: z.number().describe("The task ID") },
|
|
18
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
19
|
+
async ({ taskId }) => {
|
|
20
|
+
try {
|
|
21
|
+
const clients = getClients();
|
|
22
|
+
const res = await clients.tasks.getTask({ id: taskId });
|
|
23
|
+
const task = res.task;
|
|
24
|
+
if (!task) {
|
|
25
|
+
return { content: [{ type: "text", text: "Task not found." }] };
|
|
26
|
+
}
|
|
27
|
+
const logs = task.workLogs ?? [];
|
|
28
|
+
if (logs.length === 0) {
|
|
29
|
+
return { content: [{ type: "text", text: `No work logs on ${task.identifier}.` }] };
|
|
30
|
+
}
|
|
31
|
+
const lines = logs.map((wl: { id: number; userId: number; user?: { fullName?: string }; hours: number; description?: string; createdAt?: { seconds: bigint | number } }) => {
|
|
32
|
+
const who = wl.user?.fullName || `User #${wl.userId}`;
|
|
33
|
+
const when = wl.createdAt
|
|
34
|
+
? new Date((typeof wl.createdAt.seconds === "bigint" ? Number(wl.createdAt.seconds) : wl.createdAt.seconds) * 1000).toISOString().split("T")[0]
|
|
35
|
+
: "—";
|
|
36
|
+
const desc = wl.description ? ` — ${wl.description}` : "";
|
|
37
|
+
return `${who} @ ${when}: ${wl.hours}h${desc} (ID: ${wl.id})`;
|
|
38
|
+
});
|
|
39
|
+
const total = logs.reduce((sum: number, wl: { hours: number }) => sum + wl.hours, 0);
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "text", text: `Work logs for ${task.identifier} (total ${total}h):\n` + lines.join("\n") },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
server.tool(
|
|
52
|
+
"opsee_log_work",
|
|
53
|
+
"Log time against a task. Posts as the authenticated user. Hours must be > 0.",
|
|
54
|
+
{
|
|
55
|
+
taskId: z.number().describe("The task ID"),
|
|
56
|
+
hours: z.number().min(0.1).describe("Hours worked (>= 0.1)"),
|
|
57
|
+
description: z.string().optional().describe("Optional note describing what was done"),
|
|
58
|
+
},
|
|
59
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
60
|
+
async ({ taskId, hours, description }) => {
|
|
61
|
+
try {
|
|
62
|
+
const clients = getClients();
|
|
63
|
+
const res = await clients.tasks.addWorkLog({ taskId, hours, description });
|
|
64
|
+
if (!res.workLog) {
|
|
65
|
+
return { content: [{ type: "text", text: "Failed to log work. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{ type: "text", text: `Logged ${res.workLog.hours}h on task ${taskId} (work_log ID ${res.workLog.id}).` },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
"opsee_delete_work_log",
|
|
80
|
+
"Delete a work-log entry by ID. This is permanent.",
|
|
81
|
+
{
|
|
82
|
+
taskId: z.number().describe("The task ID the work log belongs to"),
|
|
83
|
+
workLogId: z.number().describe("The work log ID to delete"),
|
|
84
|
+
},
|
|
85
|
+
{ readOnlyHint: false, destructiveHint: true },
|
|
86
|
+
async ({ taskId, workLogId }) => {
|
|
87
|
+
try {
|
|
88
|
+
const clients = getClients();
|
|
89
|
+
await clients.tasks.deleteWorkLog({ taskId, id: workLogId });
|
|
90
|
+
return { content: [{ type: "text", text: "Work log deleted." }] };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"opsee_get_task_actual_hours",
|
|
99
|
+
"Sum of all work_log hours on a task. Use this to compare against opsee_get_task's estimatedHours when assessing whether a task is over budget.",
|
|
100
|
+
{ taskId: z.number().describe("The task ID") },
|
|
101
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
102
|
+
async ({ taskId }) => {
|
|
103
|
+
try {
|
|
104
|
+
const clients = getClients();
|
|
105
|
+
const res = await clients.tasks.getTask({ id: taskId });
|
|
106
|
+
const task = res.task;
|
|
107
|
+
if (!task) {
|
|
108
|
+
return { content: [{ type: "text", text: "Task not found." }] };
|
|
109
|
+
}
|
|
110
|
+
const total = (task.workLogs ?? []).reduce((sum: number, wl: { hours: number }) => sum + wl.hours, 0);
|
|
111
|
+
const estimated = task.estimatedHours ?? 0;
|
|
112
|
+
const delta = estimated > 0 ? ` (estimated ${estimated}h, ${total > estimated ? "over by " + (total - estimated) : "under by " + (estimated - total)} h)` : "";
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{ type: "text", text: `${task.identifier}: ${total}h logged${delta}.` },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -33,6 +33,9 @@ export function formatTask(t: Task): string {
|
|
|
33
33
|
const cycle = t.cycle?.name || "None";
|
|
34
34
|
lines.push(` Assignee: ${assignee} | Cycle: ${cycle}`);
|
|
35
35
|
|
|
36
|
+
if (t.storyPoints) lines.push(` Story Points: ${t.storyPoints}`);
|
|
37
|
+
if (t.estimatedHours) lines.push(` Estimated Hours: ${t.estimatedHours}`);
|
|
38
|
+
|
|
36
39
|
if (t.description) {
|
|
37
40
|
const desc =
|
|
38
41
|
t.description.length > 200
|