@opsee/mcp-server 0.9.31 → 0.9.33

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.
@@ -0,0 +1,372 @@
1
+ import { z } from "zod";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import type { ApiClients } from "../client/api.js";
5
+ import { defaultPagination } from "../client/api.js";
6
+ import { authManager } from "../auth/manager.js";
7
+ import {
8
+ formatTaskList,
9
+ formatTask,
10
+ formatTaskDependencyList,
11
+ formatCommentList,
12
+ formatTaskLabelList,
13
+ formatError,
14
+ } from "../utils/format.js";
15
+ import {
16
+ CREATE_TASK_DESCRIPTION,
17
+ STORY_POINTS_ESTIMATE,
18
+ UPDATE_TASK_DESCRIPTION,
19
+ } from "./descriptions.js";
20
+ import {
21
+ FilterOptionsSchema,
22
+ FilterSchema,
23
+ } from "../../gen/api/v1/filter_pb.js";
24
+
25
+ // Single-task reads and the create/update writes behind them.
26
+ export function registerTaskReadWriteTools(
27
+ server: McpServer,
28
+ getClients: () => ApiClients,
29
+ ): void {
30
+ server.tool(
31
+ "opsee_list_tasks",
32
+ "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. Supports sorting via sortBy (created_at|updated_at|due_date|priority|title|id|display_order) and sortDir (asc|desc).",
33
+ {
34
+ projectId: z.number().int().min(1).describe("The project ID"),
35
+ columnId: z.number().int().min(1).optional().describe("Filter by board column ID (status)"),
36
+ assigneeId: z.number().int().min(1).optional().describe("Filter by assigned user ID"),
37
+ cycleId: z.number().int().min(1).optional().describe("Filter by cycle/sprint ID"),
38
+ labelIds: z.array(z.number().int().min(1)).optional().describe("Filter by label IDs (AND semantics — task must have all listed labels)"),
39
+ priorityIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more priority IDs (OR semantics)"),
40
+ taskTypeIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more task type IDs (OR semantics)"),
41
+ milestoneIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more milestone IDs (OR semantics)"),
42
+ parentTaskId: z.number().int().min(1).optional().describe("Filter to subtasks of this task ID"),
43
+ hasParent: z.boolean().optional().describe("true = only subtasks, false = only top-level tasks"),
44
+ unassigned: z.boolean().optional().describe("true = only unassigned tasks (triage queue)"),
45
+ updatedSince: z.string().optional().describe("Only tasks updated at or after this timestamp (RFC3339 or YYYY-MM-DD)"),
46
+ createdSince: z.string().optional().describe("Only tasks created at or after this timestamp (RFC3339 or YYYY-MM-DD)"),
47
+ dueBefore: z.string().optional().describe("Only tasks due at or before this date (RFC3339 or YYYY-MM-DD)"),
48
+ dueAfter: z.string().optional().describe("Only tasks due at or after this date (RFC3339 or YYYY-MM-DD)"),
49
+ sortBy: z.enum(["created_at", "updated_at", "due_date", "priority", "title", "id", "display_order"]).optional().describe("Sort column. NULLs sort last."),
50
+ sortDir: z.enum(["asc", "desc"]).optional().describe("Sort direction (default: desc)"),
51
+ page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
52
+ pageSize: z.number().int().min(1).optional().describe("Items per page (default: 50)"),
53
+ },
54
+ { readOnlyHint: true, destructiveHint: false },
55
+ async ({
56
+ projectId, columnId, assigneeId, cycleId, labelIds,
57
+ priorityIds, taskTypeIds, milestoneIds, parentTaskId, hasParent,
58
+ unassigned, updatedSince, createdSince, dueBefore, dueAfter,
59
+ sortBy, sortDir, page, pageSize,
60
+ }) => {
61
+ try {
62
+ const clients = getClients();
63
+
64
+ const filters: Array<{ key: string; value: string }> = [];
65
+ if (columnId) filters.push({ key: "BoardColumnId", value: String(columnId) });
66
+ if (assigneeId) filters.push({ key: "AssignedUserId", value: String(assigneeId) });
67
+ if (cycleId) filters.push({ key: "CycleId", value: String(cycleId) });
68
+ if (priorityIds?.length) filters.push({ key: "TaskPriorityId", value: priorityIds.join(",") });
69
+ if (taskTypeIds?.length) filters.push({ key: "TaskTypeId", value: taskTypeIds.join(",") });
70
+ if (milestoneIds?.length) filters.push({ key: "MilestoneId", value: milestoneIds.join(",") });
71
+ if (parentTaskId) filters.push({ key: "ParentTaskId", value: String(parentTaskId) });
72
+ if (hasParent !== undefined) filters.push({ key: "HasParent", value: hasParent ? "true" : "false" });
73
+ if (unassigned) filters.push({ key: "Unassigned", value: "true" });
74
+ if (updatedSince) filters.push({ key: "UpdatedSince", value: updatedSince });
75
+ if (createdSince) filters.push({ key: "CreatedSince", value: createdSince });
76
+ if (dueBefore) filters.push({ key: "DueBefore", value: dueBefore });
77
+ if (dueAfter) filters.push({ key: "DueAfter", value: dueAfter });
78
+
79
+ const filterOptions = filters.length > 0
80
+ ? create(FilterOptionsSchema, {
81
+ filters: filters.map((f) => create(FilterSchema, f)),
82
+ })
83
+ : undefined;
84
+
85
+ const res = await clients.tasks.getTasks({
86
+ projectId,
87
+ pagination: defaultPagination(page || 1, pageSize || 50),
88
+ filterOptions,
89
+ labelIds: labelIds ?? [],
90
+ sortBy,
91
+ sortDir,
92
+ });
93
+
94
+ const pagination = res.pagination;
95
+ const currentPage = page || 1;
96
+ const hasMore = (pagination?.nextPage ?? 0) > 0;
97
+
98
+ return {
99
+ content: [{
100
+ type: "text",
101
+ text: formatTaskList(res.tasks, {
102
+ totalCount: pagination?.total,
103
+ page: currentPage,
104
+ hasMore,
105
+ nextPage: hasMore ? currentPage + 1 : undefined,
106
+ }),
107
+ }],
108
+ };
109
+ } catch (error) {
110
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
111
+ }
112
+ },
113
+ );
114
+
115
+ server.tool(
116
+ "opsee_get_task",
117
+ "Get full details of a specific task by ID.",
118
+ { taskId: z.number().int().min(1).describe("The task ID") },
119
+ { readOnlyHint: true, destructiveHint: false },
120
+ async ({ taskId }) => {
121
+ try {
122
+ const clients = getClients();
123
+ const res = await clients.tasks.getTask({ id: taskId });
124
+ if (!res.task) return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
125
+ return { content: [{ type: "text", text: formatTask(res.task, { full: true }) }] };
126
+ } catch (error) {
127
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
128
+ }
129
+ },
130
+ );
131
+
132
+ server.tool(
133
+ "opsee_get_task_with_context",
134
+ "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.",
135
+ { taskId: z.number().int().min(1).describe("The task ID") },
136
+ { readOnlyHint: true, destructiveHint: false },
137
+ async ({ taskId }) => {
138
+ try {
139
+ const clients = getClients();
140
+
141
+ const taskRes = await clients.tasks.getTask({ id: taskId });
142
+ const task = taskRes.task;
143
+ if (!task) {
144
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
145
+ }
146
+
147
+ const [labelsRes, depsRes, commentsRes, subtasksRes, parentRes] = await Promise.all([
148
+ clients.taskLabels.getTaskLabels({
149
+ taskId,
150
+ pagination: defaultPagination(1, 50),
151
+ }),
152
+ clients.taskDependencies.getTaskDependencies({
153
+ taskId,
154
+ pagination: defaultPagination(1, 50),
155
+ }),
156
+ clients.comments.getComments({
157
+ taskId,
158
+ pagination: defaultPagination(1, 50),
159
+ }),
160
+ clients.tasks.getTasks({
161
+ projectId: task.project?.id ?? 0,
162
+ pagination: defaultPagination(1, 50),
163
+ filterOptions: create(FilterOptionsSchema, {
164
+ filters: [
165
+ create(FilterSchema, { key: "ParentTaskId", value: String(task.id) }),
166
+ ],
167
+ }),
168
+ }),
169
+ task.parentTaskId
170
+ ? clients.tasks.getTask({ id: task.parentTaskId })
171
+ : Promise.resolve(null),
172
+ ]);
173
+
174
+ const sections: string[] = [];
175
+ sections.push(formatTask(task, { full: true }));
176
+
177
+ const parentTask = parentRes?.task;
178
+ if (parentTask) {
179
+ sections.push(`\n--- Parent Task ---\n[${parentTask.identifier}] ${parentTask.title} (ID: ${parentTask.id})`);
180
+ }
181
+
182
+ sections.push(`\n--- Labels ---\n${formatTaskLabelList(labelsRes.taskLabels)}`);
183
+ sections.push(`\n--- Dependencies ---\n${formatTaskDependencyList(depsRes.taskDependencies)}`);
184
+ sections.push(`\n--- Subtasks ---\n${formatTaskList(subtasksRes.tasks)}`);
185
+ sections.push(`\n--- Comments ---\n${formatCommentList(commentsRes.comments)}`);
186
+
187
+ return { content: [{ type: "text", text: sections.join("\n") }] };
188
+ } catch (error) {
189
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
190
+ }
191
+ },
192
+ );
193
+
194
+ server.tool(
195
+ "opsee_create_task",
196
+ "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.",
197
+ {
198
+ projectId: z.number().int().min(1).describe("The project ID"),
199
+ title: z.string().describe("Task title"),
200
+ description: z.string().optional().describe(CREATE_TASK_DESCRIPTION),
201
+ taskTypeId: z.number().int().min(1).optional().describe("Task type ID (from opsee_list_task_types)"),
202
+ priorityId: z.number().int().min(1).optional().describe("Priority ID (from opsee_list_task_priorities)"),
203
+ boardColumnId: z.number().int().min(1).optional().describe("Board column ID (from opsee_list_board_columns)"),
204
+ assigneeId: z.number().int().min(1).optional().describe("Assigned user ID"),
205
+ cycleId: z.number().int().min(1).optional().describe("Cycle/sprint ID"),
206
+ milestoneId: z.number().int().min(1).optional().describe("Milestone ID — attaches this task to the milestone."),
207
+ parentTaskId: z.number().int().min(1).optional().describe("Parent task ID — makes this task a subtask of the given task."),
208
+ storyPoints: z.number().int().min(1).optional().describe(STORY_POINTS_ESTIMATE),
209
+ estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
210
+ 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."),
211
+ },
212
+ { readOnlyHint: false, destructiveHint: false },
213
+ async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, milestoneId, parentTaskId, storyPoints, estimatedHours, labelIds }) => {
214
+ try {
215
+ const clients = getClients();
216
+
217
+ // Auto-resolve defaults
218
+ let resolvedBoardId = 0;
219
+ let resolvedColumnId = boardColumnId || 0;
220
+ let resolvedTaskTypeId = taskTypeId || 0;
221
+ let resolvedPriorityId = priorityId || 0;
222
+
223
+ // Get default board
224
+ const boardsRes = await clients.boards.getBoards({ projectId });
225
+ if (boardsRes.boards?.length) {
226
+ resolvedBoardId = boardsRes.boards[0].id;
227
+
228
+ // Get first column if not specified
229
+ if (!boardColumnId) {
230
+ const colsRes = await clients.boardColumns.getBoardColumns({
231
+ boardId: resolvedBoardId,
232
+ });
233
+ if (colsRes.boardColumns?.length) {
234
+ resolvedColumnId = colsRes.boardColumns[0].id;
235
+ }
236
+ }
237
+ }
238
+
239
+ // Get first task type if not specified
240
+ if (!taskTypeId) {
241
+ const typesRes = await clients.taskTypes.getTaskTypes({ projectId });
242
+ if (typesRes.taskTypes?.length) {
243
+ resolvedTaskTypeId = typesRes.taskTypes[0].id;
244
+ }
245
+ }
246
+
247
+ // Get first priority if not specified
248
+ if (!priorityId) {
249
+ const priRes = await clients.taskPriorities.getTaskPriorities({ projectId });
250
+ if (priRes.taskPriorities?.length) {
251
+ resolvedPriorityId = priRes.taskPriorities[0].id;
252
+ }
253
+ }
254
+
255
+ // Get reporter (current user)
256
+ let reporterUserId = 0;
257
+ const userId = authManager.getUserId();
258
+ if (userId) {
259
+ reporterUserId = Number(userId);
260
+ }
261
+ if (!reporterUserId) {
262
+ const meRes = await clients.users.getMe({});
263
+ if (meRes.user) reporterUserId = meRes.user.id;
264
+ }
265
+
266
+ const res = await clients.tasks.addTask({
267
+ projectId,
268
+ title,
269
+ description,
270
+ boardId: resolvedBoardId,
271
+ boardColumnId: resolvedColumnId,
272
+ taskTypeId: resolvedTaskTypeId,
273
+ taskPriorityId: resolvedPriorityId,
274
+ reporterUserId,
275
+ assignedUserId: assigneeId,
276
+ parentTaskId,
277
+ displayOrder: "",
278
+ aiModeEnabled: false,
279
+ cycleId,
280
+ milestoneId,
281
+ storyPoints,
282
+ estimatedHours,
283
+ repositoryIds: [],
284
+ labelIds: labelIds ?? [],
285
+ });
286
+
287
+ if (!res.task)
288
+ 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 };
289
+
290
+ return {
291
+ content: [
292
+ { type: "text", text: `Task created:\n${formatTask(res.task, { full: true })}` },
293
+ ],
294
+ };
295
+ } catch (error) {
296
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
297
+ }
298
+ },
299
+ );
300
+
301
+ server.tool(
302
+ "opsee_update_task",
303
+ "Update a task's fields (patch semantics - only provided fields are changed). Fetches the current task first, merges your changes, and sends the full update.",
304
+ {
305
+ taskId: z.number().int().min(1).describe("The task ID to update"),
306
+ title: z.string().optional().describe("New title"),
307
+ description: z.string().optional().describe(UPDATE_TASK_DESCRIPTION),
308
+ taskTypeId: z.number().int().min(1).optional().describe("New task type ID"),
309
+ priorityId: z.number().int().min(1).optional().describe("New priority ID"),
310
+ boardColumnId: z.number().int().min(1).optional().describe("New board column ID (status)"),
311
+ assigneeId: z.number().int().min(1).optional().describe("New assigned user ID"),
312
+ cycleId: z.number().int().min(1).optional().describe("New cycle/sprint ID"),
313
+ milestoneId: z.number().int().min(0).optional().describe("New milestone ID (0 detaches from the current milestone)."),
314
+ parentTaskId: z.number().int().min(0).optional().describe("New parent task ID (0 clears the parent and promotes to a top-level task)."),
315
+ storyPoints: z.number().int().min(1).optional().describe(STORY_POINTS_ESTIMATE),
316
+ estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
317
+ 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."),
318
+ initiativeId: z.number().int().min(0).optional().describe("Initiative ID to link this task to. >0 links the task to that Initiative, 0 detaches."),
319
+ },
320
+ { readOnlyHint: false, destructiveHint: false },
321
+ async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, milestoneId, parentTaskId, storyPoints, estimatedHours, labelIds, initiativeId }) => {
322
+ try {
323
+ const clients = getClients();
324
+
325
+ // Read current task
326
+ const getRes = await clients.tasks.getTask({ id: taskId });
327
+ const task = getRes.task;
328
+ if (!task)
329
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
330
+
331
+ // Merge changes into full EditTaskRequest
332
+ const res = await clients.tasks.editTask({
333
+ id: task.id,
334
+ identifier: task.identifier,
335
+ title: title ?? task.title,
336
+ description: description ?? task.description,
337
+ displayOrder: task.displayOrder,
338
+ storyPoints: storyPoints ?? task.storyPoints,
339
+ estimatedHours: estimatedHours ?? task.estimatedHours,
340
+ actualHours: task.actualHours,
341
+ dueDate: task.dueDate,
342
+ aiModeEnabled: task.aiModeEnabled,
343
+ metadataJson: task.metadataJson,
344
+ boardId: task.board?.id ?? 0,
345
+ boardColumnId: boardColumnId ?? task.boardColumn?.id ?? 0,
346
+ projectId: task.project?.id ?? 0,
347
+ taskTypeId: taskTypeId ?? task.taskType?.id ?? 0,
348
+ taskPriorityId: priorityId ?? task.taskPriority?.id ?? 0,
349
+ reporterUserId: task.reporterUser?.id ?? 0,
350
+ assignedUserId: assigneeId ?? task.assignedUser?.id,
351
+ cycleId: cycleId ?? task.cycle?.id,
352
+ milestoneId: milestoneId ?? task.milestoneId,
353
+ parentTaskId: parentTaskId ?? task.parentTaskId,
354
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
355
+ labelIds,
356
+ initiativeId: initiativeId !== undefined ? initiativeId : task.initiativeId,
357
+ });
358
+
359
+ if (!res.task)
360
+ 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 };
361
+
362
+ return {
363
+ content: [
364
+ { type: "text", text: `Task updated:\n${formatTask(res.task, { full: true })}` },
365
+ ],
366
+ };
367
+ } catch (error) {
368
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
369
+ }
370
+ },
371
+ );
372
+ }
@@ -0,0 +1,159 @@
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
+ // Single-task relation edits (labels, initiative membership). Each one reads
7
+ // the task first and resends every field, because EditTask is a full replace:
8
+ // sending only the changed relation would blank everything else.
9
+ export function registerTaskRelationTools(
10
+ server: McpServer,
11
+ getClients: () => ApiClients,
12
+ ): void {
13
+ server.tool(
14
+ "opsee_replace_task_labels",
15
+ "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.",
16
+ {
17
+ taskId: z.number().int().min(1).describe("The task ID"),
18
+ labelIds: z.array(z.number().int().min(1)).describe("Label IDs to set (empty array clears all labels)"),
19
+ },
20
+ { readOnlyHint: false, destructiveHint: false },
21
+ async ({ taskId, labelIds }) => {
22
+ try {
23
+ const clients = getClients();
24
+ const getRes = await clients.tasks.getTask({ id: taskId });
25
+ const task = getRes.task;
26
+ if (!task) {
27
+ return { content: [{ type: "text", text: "Task not found." }] };
28
+ }
29
+ const res = await clients.tasks.editTask({
30
+ id: task.id,
31
+ identifier: task.identifier,
32
+ title: task.title,
33
+ description: task.description,
34
+ displayOrder: task.displayOrder,
35
+ storyPoints: task.storyPoints,
36
+ estimatedHours: task.estimatedHours,
37
+ actualHours: task.actualHours,
38
+ dueDate: task.dueDate,
39
+ aiModeEnabled: task.aiModeEnabled,
40
+ metadataJson: task.metadataJson,
41
+ boardId: task.board?.id ?? 0,
42
+ boardColumnId: task.boardColumn?.id ?? 0,
43
+ projectId: task.project?.id ?? 0,
44
+ taskTypeId: task.taskType?.id ?? 0,
45
+ taskPriorityId: task.taskPriority?.id ?? 0,
46
+ reporterUserId: task.reporterUser?.id ?? 0,
47
+ assignedUserId: task.assignedUser?.id,
48
+ cycleId: task.cycle?.id,
49
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
50
+ labelIds,
51
+ });
52
+ if (!res.task) {
53
+ 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 };
54
+ }
55
+ const verb = labelIds.length === 0 ? "Cleared all labels on" : `Set ${labelIds.length} label(s) on`;
56
+ return { content: [{ type: "text", text: `${verb} ${res.task.identifier}.` }] };
57
+ } catch (error) {
58
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
59
+ }
60
+ },
61
+ );
62
+
63
+ server.tool(
64
+ "opsee_link_task_to_initiative",
65
+ "Link an existing task to an Initiative — use this to bring an already-existing task into an initiative's scope. To create NEW tasks under an initiative, use opsee_decompose_initiative (first time) or opsee_reconcile_initiative (re-planning) instead. Fetches the task first so no other fields are blanked.",
66
+ {
67
+ taskId: z.number().int().min(1).describe("The task ID to link"),
68
+ initiativeId: z.number().int().min(1).describe("The initiative ID to link the task to"),
69
+ },
70
+ { readOnlyHint: false, destructiveHint: false },
71
+ async ({ taskId, initiativeId }) => {
72
+ try {
73
+ const clients = getClients();
74
+ const getRes = await clients.tasks.getTask({ id: taskId });
75
+ const task = getRes.task;
76
+ if (!task)
77
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
78
+ const res = await clients.tasks.editTask({
79
+ id: task.id,
80
+ identifier: task.identifier,
81
+ title: task.title,
82
+ description: task.description,
83
+ displayOrder: task.displayOrder,
84
+ storyPoints: task.storyPoints,
85
+ estimatedHours: task.estimatedHours,
86
+ actualHours: task.actualHours,
87
+ dueDate: task.dueDate,
88
+ aiModeEnabled: task.aiModeEnabled,
89
+ metadataJson: task.metadataJson,
90
+ boardId: task.board?.id ?? 0,
91
+ boardColumnId: task.boardColumn?.id ?? 0,
92
+ projectId: task.project?.id ?? 0,
93
+ taskTypeId: task.taskType?.id ?? 0,
94
+ taskPriorityId: task.taskPriority?.id ?? 0,
95
+ reporterUserId: task.reporterUser?.id ?? 0,
96
+ assignedUserId: task.assignedUser?.id,
97
+ cycleId: task.cycle?.id,
98
+ milestoneId: task.milestoneId,
99
+ parentTaskId: task.parentTaskId,
100
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
101
+ initiativeId,
102
+ });
103
+ if (!res.task)
104
+ return { content: [{ type: "text", text: "Failed to link task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
105
+ return { content: [{ type: "text", text: `Task ${res.task.identifier} linked to initiative ${initiativeId}.` }] };
106
+ } catch (error) {
107
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
108
+ }
109
+ },
110
+ );
111
+
112
+ server.tool(
113
+ "opsee_unlink_task_from_initiative",
114
+ "Detach a task from its current Initiative. Fetches the task first so no other fields are blanked.",
115
+ {
116
+ taskId: z.number().int().min(1).describe("The task ID to unlink"),
117
+ },
118
+ { readOnlyHint: false, destructiveHint: false },
119
+ async ({ taskId }) => {
120
+ try {
121
+ const clients = getClients();
122
+ const getRes = await clients.tasks.getTask({ id: taskId });
123
+ const task = getRes.task;
124
+ if (!task)
125
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
126
+ const res = await clients.tasks.editTask({
127
+ id: task.id,
128
+ identifier: task.identifier,
129
+ title: task.title,
130
+ description: task.description,
131
+ displayOrder: task.displayOrder,
132
+ storyPoints: task.storyPoints,
133
+ estimatedHours: task.estimatedHours,
134
+ actualHours: task.actualHours,
135
+ dueDate: task.dueDate,
136
+ aiModeEnabled: task.aiModeEnabled,
137
+ metadataJson: task.metadataJson,
138
+ boardId: task.board?.id ?? 0,
139
+ boardColumnId: task.boardColumn?.id ?? 0,
140
+ projectId: task.project?.id ?? 0,
141
+ taskTypeId: task.taskType?.id ?? 0,
142
+ taskPriorityId: task.taskPriority?.id ?? 0,
143
+ reporterUserId: task.reporterUser?.id ?? 0,
144
+ assignedUserId: task.assignedUser?.id,
145
+ cycleId: task.cycle?.id,
146
+ milestoneId: task.milestoneId,
147
+ parentTaskId: task.parentTaskId,
148
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
149
+ initiativeId: 0,
150
+ });
151
+ if (!res.task)
152
+ return { content: [{ type: "text", text: "Failed to unlink task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
153
+ return { content: [{ type: "text", text: `Task ${res.task.identifier} unlinked from initiative.` }] };
154
+ } catch (error) {
155
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
156
+ }
157
+ },
158
+ );
159
+ }