@opsee/mcp-server 0.5.7 → 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.
@@ -0,0 +1,135 @@
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 { defaultPagination } from "../client/api.js";
5
+ import { authManager } from "../auth/manager.js";
6
+ import {
7
+ formatComment,
8
+ formatCommentList,
9
+ formatError,
10
+ } from "../utils/format.js";
11
+
12
+ export function registerCommentTools(
13
+ server: McpServer,
14
+ getClients: () => ApiClients,
15
+ ): void {
16
+ server.tool(
17
+ "opsee_list_comments",
18
+ "List comments on a task, oldest first. Includes both regular and internal comments visible to the caller.",
19
+ {
20
+ taskId: z.number().describe("The task ID"),
21
+ page: z.number().optional().describe("Page number (default: 1)"),
22
+ pageSize: z.number().optional().describe("Items per page (default: 50)"),
23
+ },
24
+ { readOnlyHint: true, destructiveHint: false },
25
+ async ({ taskId, page, pageSize }) => {
26
+ try {
27
+ const clients = getClients();
28
+ const res = await clients.comments.getComments({
29
+ taskId,
30
+ pagination: defaultPagination(page || 1, pageSize || 50),
31
+ });
32
+ return { content: [{ type: "text", text: formatCommentList(res.comments) }] };
33
+ } catch (error) {
34
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
35
+ }
36
+ },
37
+ );
38
+
39
+ server.tool(
40
+ "opsee_add_comment",
41
+ "Add a comment to a task. Posts as the authenticated user. Set isInternal=true to mark the comment as internal (visibility depends on backend RBAC).",
42
+ {
43
+ taskId: z.number().describe("The task ID to comment on"),
44
+ content: z.string().min(1).describe("Comment body"),
45
+ isInternal: z.boolean().optional().describe("Mark as internal comment (default: false)"),
46
+ },
47
+ { readOnlyHint: false, destructiveHint: false },
48
+ async ({ taskId, content, isInternal }) => {
49
+ try {
50
+ const clients = getClients();
51
+
52
+ let userId = 0;
53
+ const cachedId = authManager.getUserId();
54
+ if (cachedId) userId = Number(cachedId);
55
+ if (!userId) {
56
+ const meRes = await clients.users.getMe({});
57
+ if (meRes.user) userId = meRes.user.id;
58
+ }
59
+ if (!userId) {
60
+ return { content: [{ type: "text", text: "Could not resolve current user. Authentication may be incomplete." }], isError: true };
61
+ }
62
+
63
+ const res = await clients.comments.addComment({
64
+ content,
65
+ isInternal: isInternal ?? false,
66
+ taskId,
67
+ userId,
68
+ });
69
+
70
+ if (!res.comment) {
71
+ return { content: [{ type: "text", text: "Failed to add comment. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
72
+ }
73
+
74
+ return { content: [{ type: "text", text: `Comment added:\n${formatComment(res.comment)}` }] };
75
+ } catch (error) {
76
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
77
+ }
78
+ },
79
+ );
80
+
81
+ server.tool(
82
+ "opsee_edit_comment",
83
+ "Edit an existing comment's content and/or internal flag. Posts as the original author. Use opsee_list_comments first to confirm the comment ID.",
84
+ {
85
+ commentId: z.number().describe("The comment ID to edit"),
86
+ content: z.string().min(1).describe("New comment body"),
87
+ isInternal: z.boolean().optional().describe("Update the internal flag (defaults to the comment's existing value if omitted)"),
88
+ },
89
+ { readOnlyHint: false, destructiveHint: false },
90
+ async ({ commentId, content, isInternal }) => {
91
+ try {
92
+ const clients = getClients();
93
+
94
+ const getRes = await clients.comments.getComment({ id: commentId });
95
+ const existing = getRes.comment;
96
+ if (!existing) {
97
+ return { content: [{ type: "text", text: "Comment not found. Use opsee_list_comments to see available comments." }] };
98
+ }
99
+
100
+ const res = await clients.comments.editComment({
101
+ id: existing.id,
102
+ content,
103
+ isInternal: isInternal ?? existing.isInternal,
104
+ taskId: existing.taskId,
105
+ docPageId: existing.docPageId,
106
+ userId: existing.userId,
107
+ });
108
+
109
+ if (!res.comment) {
110
+ return { content: [{ type: "text", text: "Failed to edit comment. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
111
+ }
112
+
113
+ return { content: [{ type: "text", text: `Comment updated:\n${formatComment(res.comment)}` }] };
114
+ } catch (error) {
115
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
116
+ }
117
+ },
118
+ );
119
+
120
+ server.tool(
121
+ "opsee_delete_comment",
122
+ "Delete a comment by ID. This is permanent.",
123
+ { commentId: z.number().describe("The comment ID") },
124
+ { readOnlyHint: false, destructiveHint: true },
125
+ async ({ commentId }) => {
126
+ try {
127
+ const clients = getClients();
128
+ await clients.comments.deleteComment({ id: commentId });
129
+ return { content: [{ type: "text", text: "Comment deleted." }] };
130
+ } catch (error) {
131
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
132
+ }
133
+ },
134
+ );
135
+ }
@@ -1,8 +1,14 @@
1
1
  import { z } from "zod";
2
+ import { create } from "@bufbuild/protobuf";
2
3
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
3
4
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import type { ApiClients } from "../client/api.js";
6
+ import { defaultPagination } from "../client/api.js";
5
7
  import { formatCycleList, formatCycle, formatError } from "../utils/format.js";
8
+ import {
9
+ FilterOptionsSchema,
10
+ FilterSchema,
11
+ } from "../../gen/api/v1/filter_pb.js";
6
12
 
7
13
  export function registerCycleTools(
8
14
  server: McpServer,
@@ -65,7 +71,7 @@ export function registerCycleTools(
65
71
  description,
66
72
  });
67
73
  if (!res.cycle)
68
- return { content: [{ type: "text", text: "Failed to create cycle." }] };
74
+ return { content: [{ type: "text", text: "Failed to create cycle. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
69
75
 
70
76
  return {
71
77
  content: [
@@ -77,4 +83,186 @@ export function registerCycleTools(
77
83
  }
78
84
  },
79
85
  );
86
+
87
+ server.tool(
88
+ "opsee_update_cycle",
89
+ "Update a cycle's fields (patch semantics — only provided fields change). Fetches the current cycle first, merges your changes, sends the full update.",
90
+ {
91
+ cycleId: z.number().describe("The cycle ID to update"),
92
+ name: z.string().optional().describe("New name"),
93
+ goal: z.string().optional().describe("New goal"),
94
+ startDate: z.string().optional().describe("New start date (ISO 8601)"),
95
+ endDate: z.string().optional().describe("New end date (ISO 8601)"),
96
+ description: z.string().optional().describe("New description"),
97
+ isActive: z.boolean().optional().describe("Whether the cycle is active"),
98
+ },
99
+ { readOnlyHint: false, destructiveHint: false },
100
+ async ({ cycleId, name, goal, startDate, endDate, description, isActive }) => {
101
+ try {
102
+ const clients = getClients();
103
+ const getRes = await clients.cycles.getCycle({ id: cycleId });
104
+ const existing = getRes.cycle;
105
+ if (!existing) {
106
+ return { content: [{ type: "text", text: "Cycle not found." }] };
107
+ }
108
+
109
+ const res = await clients.cycles.editCycle({
110
+ id: existing.id,
111
+ name: name ?? existing.name,
112
+ goal: goal ?? existing.goal,
113
+ startDate: startDate ? timestampFromDate(new Date(startDate)) : existing.startDate,
114
+ endDate: endDate ? timestampFromDate(new Date(endDate)) : existing.endDate,
115
+ isActive: isActive ?? existing.isActive,
116
+ description: description ?? existing.description,
117
+ });
118
+
119
+ if (!res.cycle) {
120
+ return { content: [{ type: "text", text: "Failed to update cycle. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
121
+ }
122
+ return {
123
+ content: [
124
+ { type: "text", text: `Cycle updated:\n${formatCycle(res.cycle)}` },
125
+ ],
126
+ };
127
+ } catch (error) {
128
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
129
+ }
130
+ },
131
+ );
132
+
133
+ server.tool(
134
+ "opsee_close_cycle",
135
+ "Close a cycle (mark inactive) and optionally carry every remaining task forward to a target cycle. Composes a single bulk_move_to_cycle + EditCycle, so /tpm cycle close becomes one call instead of a loop. Pass createNextCycle:true with name/startDate/endDate to also create the next cycle and carry forward into it in the same call.",
136
+ {
137
+ cycleId: z.number().describe("ID of the cycle to close"),
138
+ nextCycleId: z.number().optional().describe("Existing cycle to carry remaining tasks into. Mutually exclusive with createNextCycle."),
139
+ createNextCycle: z.boolean().optional().describe("If true, create the next cycle in this project (requires name/startDate/endDate) and carry tasks into it"),
140
+ nextCycleName: z.string().optional().describe("Name of the cycle to create when createNextCycle:true"),
141
+ nextCycleStartDate: z.string().optional().describe("ISO 8601 start date when createNextCycle:true"),
142
+ nextCycleEndDate: z.string().optional().describe("ISO 8601 end date when createNextCycle:true"),
143
+ },
144
+ { readOnlyHint: false, destructiveHint: false },
145
+ async ({ cycleId, nextCycleId, createNextCycle, nextCycleName, nextCycleStartDate, nextCycleEndDate }) => {
146
+ try {
147
+ const clients = getClients();
148
+
149
+ const cycleRes = await clients.cycles.getCycle({ id: cycleId });
150
+ const existing = cycleRes.cycle;
151
+ if (!existing) {
152
+ return { content: [{ type: "text", text: "Cycle not found." }] };
153
+ }
154
+ const projectId = existing.projectId;
155
+
156
+ // Resolve the target cycle for carry-forward.
157
+ let targetCycleId: number | undefined = nextCycleId;
158
+ let createdNextCycle: { id: number; name: string } | undefined;
159
+ if (createNextCycle) {
160
+ if (nextCycleId) {
161
+ return { content: [{ type: "text", text: "createNextCycle and nextCycleId are mutually exclusive — pick one." }], isError: true };
162
+ }
163
+ if (!nextCycleName || !nextCycleStartDate || !nextCycleEndDate) {
164
+ return { content: [{ type: "text", text: "createNextCycle requires nextCycleName, nextCycleStartDate, nextCycleEndDate." }], isError: true };
165
+ }
166
+ const addRes = await clients.cycles.addCycle({
167
+ projectId,
168
+ name: nextCycleName,
169
+ goal: "",
170
+ startDate: timestampFromDate(new Date(nextCycleStartDate)),
171
+ endDate: timestampFromDate(new Date(nextCycleEndDate)),
172
+ isActive: true,
173
+ description: undefined,
174
+ });
175
+ if (!addRes.cycle) {
176
+ return { content: [{ type: "text", text: "Failed to create next cycle. The backend returned an empty response." }], isError: true };
177
+ }
178
+ createdNextCycle = { id: addRes.cycle.id, name: addRes.cycle.name };
179
+ targetCycleId = addRes.cycle.id;
180
+ }
181
+
182
+ // Carry forward all tasks currently in the cycle, if a target was provided.
183
+ let carriedForward = 0;
184
+ if (targetCycleId) {
185
+ const tasksRes = await clients.tasks.getTasks({
186
+ projectId,
187
+ pagination: defaultPagination(1, 500),
188
+ filterOptions: create(FilterOptionsSchema, {
189
+ filters: [create(FilterSchema, { key: "CycleId", value: String(cycleId) })],
190
+ }),
191
+ labelIds: [],
192
+ });
193
+ const taskIds = (tasksRes.tasks ?? []).map((t: { id: number }) => t.id);
194
+ if (taskIds.length > 0) {
195
+ const bulkRes = await clients.tasks.bulkEditTasks({
196
+ projectId,
197
+ taskIds,
198
+ cycleId: targetCycleId,
199
+ repositoryIds: [],
200
+ labelIds: [],
201
+ });
202
+ carriedForward = bulkRes.updatedCount;
203
+ }
204
+ }
205
+
206
+ // Close the cycle by marking it inactive.
207
+ const editRes = await clients.cycles.editCycle({
208
+ id: existing.id,
209
+ name: existing.name,
210
+ goal: existing.goal,
211
+ startDate: existing.startDate,
212
+ endDate: existing.endDate,
213
+ isActive: false,
214
+ description: existing.description,
215
+ });
216
+ if (!editRes.cycle) {
217
+ return { content: [{ type: "text", text: "Failed to close cycle. Carry-forward may have partially applied; check opsee_list_tasks." }], isError: true };
218
+ }
219
+
220
+ const lines = [`Cycle "${existing.name}" (ID ${existing.id}) closed.`];
221
+ if (carriedForward > 0 && targetCycleId) {
222
+ lines.push(`Carried ${carriedForward} task(s) forward to cycle ${targetCycleId}${createdNextCycle ? ` ("${createdNextCycle.name}")` : ""}.`);
223
+ } else if (targetCycleId) {
224
+ lines.push("No tasks to carry forward.");
225
+ }
226
+ return { content: [{ type: "text", text: lines.join("\n") }] };
227
+ } catch (error) {
228
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
229
+ }
230
+ },
231
+ );
232
+
233
+ server.tool(
234
+ "opsee_get_active_cycle",
235
+ "Return the currently-active cycle for a project, or 'No active cycle' if none.",
236
+ { projectId: z.number().describe("The project ID") },
237
+ { readOnlyHint: true, destructiveHint: false },
238
+ async ({ projectId }) => {
239
+ try {
240
+ const clients = getClients();
241
+ const res = await clients.cycles.getCycles({ projectId });
242
+ const active = (res.cycles ?? []).find((c: { isActive: boolean }) => c.isActive);
243
+ if (!active) {
244
+ return { content: [{ type: "text", text: `No active cycle in project ${projectId}.` }] };
245
+ }
246
+ return { content: [{ type: "text", text: formatCycle(active) }] };
247
+ } catch (error) {
248
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
249
+ }
250
+ },
251
+ );
252
+
253
+ server.tool(
254
+ "opsee_delete_cycle",
255
+ "Delete a cycle by ID. This is permanent. Tasks assigned to the cycle are not deleted, but their cycle reference is cleared.",
256
+ { cycleId: z.number().describe("The cycle ID") },
257
+ { readOnlyHint: false, destructiveHint: true },
258
+ async ({ cycleId }) => {
259
+ try {
260
+ const clients = getClients();
261
+ await clients.cycles.deleteCycle({ id: cycleId });
262
+ return { content: [{ type: "text", text: "Cycle deleted." }] };
263
+ } catch (error) {
264
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
265
+ }
266
+ },
267
+ );
80
268
  }
package/src/tools/docs.ts CHANGED
@@ -134,7 +134,7 @@ export function registerDocTools(
134
134
  });
135
135
 
136
136
  if (!res.docPage)
137
- return { content: [{ type: "text", text: "Failed to create doc page." }] };
137
+ return { content: [{ type: "text", text: "Failed to create doc page. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
138
138
 
139
139
  return {
140
140
  content: [
@@ -0,0 +1,199 @@
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 { defaultPagination } from "../client/api.js";
5
+ import {
6
+ formatLabel,
7
+ formatLabelList,
8
+ formatTaskLabelList,
9
+ formatError,
10
+ } from "../utils/format.js";
11
+
12
+ export function registerLabelTools(
13
+ server: McpServer,
14
+ getClients: () => ApiClients,
15
+ ): void {
16
+ server.tool(
17
+ "opsee_list_labels",
18
+ "List labels defined in an Opsee project. Use opsee_list_task_labels for labels attached to a specific task.",
19
+ {
20
+ projectId: z.number().describe("The project ID"),
21
+ page: z.number().optional().describe("Page number (default: 1)"),
22
+ pageSize: z.number().optional().describe("Items per page (default: 50)"),
23
+ },
24
+ { readOnlyHint: true, destructiveHint: false },
25
+ async ({ projectId, page, pageSize }) => {
26
+ try {
27
+ const clients = getClients();
28
+ const res = await clients.labels.getLabels({
29
+ projectId,
30
+ pagination: defaultPagination(page || 1, pageSize || 50),
31
+ });
32
+ return { content: [{ type: "text", text: formatLabelList(res.labels) }] };
33
+ } catch (error) {
34
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
35
+ }
36
+ },
37
+ );
38
+
39
+ server.tool(
40
+ "opsee_get_label",
41
+ "Get full details of a specific label by ID.",
42
+ {
43
+ labelId: z.coerce.number().describe("The label ID"),
44
+ },
45
+ { readOnlyHint: true, destructiveHint: false },
46
+ async ({ labelId }) => {
47
+ try {
48
+ const clients = getClients();
49
+ const res = await clients.labels.getLabel({ id: labelId });
50
+ if (!res.label) return { content: [{ type: "text", text: "Label not found. Use opsee_list_labels to see available labels." }] };
51
+ return { content: [{ type: "text", text: formatLabel(res.label) }] };
52
+ } catch (error) {
53
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
54
+ }
55
+ },
56
+ );
57
+
58
+ server.tool(
59
+ "opsee_create_label",
60
+ "Create a new label in an Opsee project.",
61
+ {
62
+ projectId: z.number().describe("The project ID"),
63
+ name: z.string().min(1).max(50).describe("Label name"),
64
+ color: z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("Hex color, e.g. #ffaa00 or ffaa00"),
65
+ description: z.string().optional().describe("Label description"),
66
+ isActive: z.boolean().optional().describe("Whether the label is active (default: true)"),
67
+ },
68
+ { readOnlyHint: false, destructiveHint: false },
69
+ async ({ projectId, name, color, description, isActive }) => {
70
+ try {
71
+ const clients = getClients();
72
+ const res = await clients.labels.addLabel({
73
+ name,
74
+ color,
75
+ projectId,
76
+ description,
77
+ isActive,
78
+ });
79
+ if (!res.label) return { content: [{ type: "text", text: "Failed to create label. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
80
+ return { content: [{ type: "text", text: `Label created:\n${formatLabel(res.label)}` }] };
81
+ } catch (error) {
82
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
83
+ }
84
+ },
85
+ );
86
+
87
+ server.tool(
88
+ "opsee_update_label",
89
+ "Update a label's fields (patch semantics - only provided fields are changed). Fetches the current label first, merges your changes, and sends the full update.",
90
+ {
91
+ labelId: z.number().describe("The label ID to update"),
92
+ name: z.string().min(1).max(50).optional().describe("New label name"),
93
+ color: z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("New hex color, e.g. #ffaa00 or ffaa00"),
94
+ description: z.string().optional().describe("New description"),
95
+ isActive: z.boolean().optional().describe("Whether the label is active"),
96
+ },
97
+ { readOnlyHint: false, destructiveHint: false },
98
+ async ({ labelId, name, color, description, isActive }) => {
99
+ try {
100
+ const clients = getClients();
101
+ const getRes = await clients.labels.getLabel({ id: labelId });
102
+ const label = getRes.label;
103
+ if (!label) return { content: [{ type: "text", text: "Label not found. Use opsee_list_labels to see available labels." }] };
104
+ const res = await clients.labels.editLabel({
105
+ id: label.id,
106
+ name: name ?? label.name,
107
+ color: color ?? label.color,
108
+ description: description ?? label.description,
109
+ isActive: isActive ?? label.isActive,
110
+ });
111
+ if (!res.label) return { content: [{ type: "text", text: "Failed to update label. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
112
+ return { content: [{ type: "text", text: `Label updated:\n${formatLabel(res.label)}` }] };
113
+ } catch (error) {
114
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
115
+ }
116
+ },
117
+ );
118
+
119
+ server.tool(
120
+ "opsee_delete_label",
121
+ "Delete a label permanently. Attached TaskLabel rows are not cleaned up automatically by this tool — backend behavior dictates whether cascade applies.",
122
+ {
123
+ labelId: z.number().describe("The label ID to delete"),
124
+ },
125
+ { readOnlyHint: false, destructiveHint: true },
126
+ async ({ labelId }) => {
127
+ try {
128
+ const clients = getClients();
129
+ await clients.labels.deleteLabel({ id: labelId });
130
+ return { content: [{ type: "text", text: "Label deleted." }] };
131
+ } catch (error) {
132
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
133
+ }
134
+ },
135
+ );
136
+
137
+ server.tool(
138
+ "opsee_attach_label_to_task",
139
+ "Attach an existing label to a task. Returns the TaskLabel join-row ID needed for opsee_detach_label_from_task.",
140
+ {
141
+ taskId: z.number().describe("The task ID"),
142
+ labelId: z.number().describe("The label ID to attach"),
143
+ },
144
+ { readOnlyHint: false, destructiveHint: false },
145
+ async ({ taskId, labelId }) => {
146
+ try {
147
+ const clients = getClients();
148
+ const res = await clients.taskLabels.addTaskLabel({ taskId, labelId });
149
+ if (!res.taskLabel) return { content: [{ type: "text", text: "Label attached. Use opsee_list_task_labels to see the join-row ID." }] };
150
+ const tl = res.taskLabel;
151
+ const labelName = tl.label?.name || `Label #${tl.labelId}`;
152
+ return { content: [{ type: "text", text: `Label attached: ${labelName} (Label ID: ${tl.labelId} | TaskLabel ID: ${tl.id})` }] };
153
+ } catch (error) {
154
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
155
+ }
156
+ },
157
+ );
158
+
159
+ server.tool(
160
+ "opsee_detach_label_from_task",
161
+ "Pass the TaskLabel join-row id (returned from opsee_list_task_labels), not the labelId. Proto DeleteTaskLabelRequest takes the join PK.",
162
+ {
163
+ taskLabelId: z.number().describe("The TaskLabel join-row ID (from opsee_list_task_labels), not the label ID"),
164
+ },
165
+ { readOnlyHint: false, destructiveHint: true },
166
+ async ({ taskLabelId }) => {
167
+ try {
168
+ const clients = getClients();
169
+ await clients.taskLabels.deleteTaskLabel({ id: taskLabelId });
170
+ return { content: [{ type: "text", text: "Label detached." }] };
171
+ } catch (error) {
172
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
173
+ }
174
+ },
175
+ );
176
+
177
+ server.tool(
178
+ "opsee_list_task_labels",
179
+ "List labels attached to a specific task (returns TaskLabel join rows with embedded Label data).",
180
+ {
181
+ taskId: z.number().describe("The task ID"),
182
+ page: z.number().optional().describe("Page number (default: 1)"),
183
+ pageSize: z.number().optional().describe("Items per page (default: 50)"),
184
+ },
185
+ { readOnlyHint: true, destructiveHint: false },
186
+ async ({ taskId, page, pageSize }) => {
187
+ try {
188
+ const clients = getClients();
189
+ const res = await clients.taskLabels.getTaskLabels({
190
+ taskId,
191
+ pagination: defaultPagination(page || 1, pageSize || 50),
192
+ });
193
+ return { content: [{ type: "text", text: formatTaskLabelList(res.taskLabels) }] };
194
+ } catch (error) {
195
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
196
+ }
197
+ },
198
+ );
199
+ }
@@ -77,7 +77,7 @@ export function registerMilestoneTools(
77
77
  endDate: endDate ? timestampFromDate(new Date(endDate)) : undefined,
78
78
  });
79
79
  if (!res.milestone)
80
- return { content: [{ type: "text", text: "Failed to create milestone." }] };
80
+ return { content: [{ type: "text", text: "Failed to create milestone. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
81
81
  return {
82
82
  content: [{ type: "text", text: `Milestone created:\n${formatMilestone(res.milestone)}` }],
83
83
  };
@@ -115,7 +115,7 @@ export function registerMilestoneTools(
115
115
  endDate: endDate ? timestampFromDate(new Date(endDate)) : m.endDate,
116
116
  });
117
117
  if (!res.milestone)
118
- return { content: [{ type: "text", text: "Failed to update milestone." }] };
118
+ return { content: [{ type: "text", text: "Failed to update milestone. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
119
119
  return {
120
120
  content: [{ type: "text", text: `Milestone updated:\n${formatMilestone(res.milestone)}` }],
121
121
  };
@@ -159,7 +159,7 @@ export function registerMilestoneTools(
159
159
  const clients = getClients();
160
160
  const res = await clients.milestoneTasks.addMilestoneTask({ milestoneId, taskId });
161
161
  if (!res.milestoneTask)
162
- return { content: [{ type: "text", text: "Failed to attach task." }] };
162
+ return { content: [{ type: "text", text: "Failed to attach task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
163
163
  return {
164
164
  content: [{ type: "text", text: `Task attached:\n${formatMilestoneTask(res.milestoneTask)}` }],
165
165
  };