@opsee/mcp-server 0.5.7 → 0.6.1

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/src/server.ts CHANGED
@@ -8,6 +8,9 @@ import { registerCycleTools } from "./tools/cycles.js";
8
8
  import { registerDocTools } from "./tools/docs.js";
9
9
  import { registerRepositoryTools } from "./tools/repositories.js";
10
10
  import { registerMilestoneTools } from "./tools/milestones.js";
11
+ import { registerLabelTools } from "./tools/labels.js";
12
+ import { registerCommentTools } from "./tools/comments.js";
13
+ import { registerTaskDependencyTools } from "./tools/task-dependencies.js";
11
14
 
12
15
  export function createServer(clientFactory?: () => ApiClients): McpServer {
13
16
  const factory = clientFactory ?? getClients;
@@ -25,6 +28,9 @@ export function createServer(clientFactory?: () => ApiClients): McpServer {
25
28
  registerDocTools(server, factory);
26
29
  registerRepositoryTools(server, factory);
27
30
  registerMilestoneTools(server, factory);
31
+ registerLabelTools(server, factory);
32
+ registerCommentTools(server, factory);
33
+ registerTaskDependencyTools(server, factory);
28
34
 
29
35
  return server;
30
36
  }
@@ -0,0 +1,96 @@
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." }] };
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_delete_comment",
83
+ "Delete a comment by ID. This is permanent.",
84
+ { commentId: z.number().describe("The comment ID") },
85
+ { readOnlyHint: false, destructiveHint: true },
86
+ async ({ commentId }) => {
87
+ try {
88
+ const clients = getClients();
89
+ await clients.comments.deleteComment({ id: commentId });
90
+ return { content: [{ type: "text", text: "Comment deleted." }] };
91
+ } catch (error) {
92
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
93
+ }
94
+ },
95
+ );
96
+ }
@@ -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.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." }] };
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." }] };
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
+ }
@@ -0,0 +1,96 @@
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
+ formatTaskDependency,
7
+ formatTaskDependencyList,
8
+ formatError,
9
+ } from "../utils/format.js";
10
+ import { TaskDependencyType } from "../../gen/api/v1/models_pb.js";
11
+
12
+ const dependencyTypeSchema = z.enum(["BLOCKS", "BLOCKED_BY", "DUPLICATES", "RELATES_TO"]);
13
+
14
+ const typeMap: Record<string, TaskDependencyType> = {
15
+ BLOCKS: TaskDependencyType.BLOCKS,
16
+ BLOCKED_BY: TaskDependencyType.BLOCKED_BY,
17
+ DUPLICATES: TaskDependencyType.DUPLICATES,
18
+ RELATES_TO: TaskDependencyType.RELATES_TO,
19
+ };
20
+
21
+ export function registerTaskDependencyTools(
22
+ server: McpServer,
23
+ getClients: () => ApiClients,
24
+ ): void {
25
+ server.tool(
26
+ "opsee_create_task_dependency",
27
+ "Create a dependency relationship between two tasks. Use BLOCKS when the from-task must be completed before the to-task. Use BLOCKED_BY for the inverse. Use DUPLICATES when tasks are duplicates. Use RELATES_TO for a general association.",
28
+ {
29
+ fromTaskId: z.number().describe("The ID of the source task"),
30
+ toTaskId: z.number().describe("The ID of the target task"),
31
+ type: dependencyTypeSchema.describe("Dependency type: BLOCKS, BLOCKED_BY, DUPLICATES, or RELATES_TO"),
32
+ },
33
+ { readOnlyHint: false, destructiveHint: false },
34
+ async ({ fromTaskId, toTaskId, type }) => {
35
+ try {
36
+ const clients = getClients();
37
+ const res = await clients.taskDependencies.addTaskDependency({
38
+ fromTaskId,
39
+ toTaskId,
40
+ type: typeMap[type],
41
+ });
42
+
43
+ if (!res.taskDependency) {
44
+ return { content: [{ type: "text", text: "Failed to create task dependency." }] };
45
+ }
46
+
47
+ return {
48
+ content: [{ type: "text", text: `Task dependency created:\n${formatTaskDependency(res.taskDependency)}` }],
49
+ };
50
+ } catch (error) {
51
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
52
+ }
53
+ },
54
+ );
55
+
56
+ server.tool(
57
+ "opsee_list_task_dependencies",
58
+ "List all dependencies for a task — returns both outgoing (task blocks others) and incoming (task is blocked by others) relationships.",
59
+ {
60
+ taskId: z.number().describe("The task ID"),
61
+ page: z.number().optional().describe("Page number (default: 1)"),
62
+ pageSize: z.number().optional().describe("Items per page (default: 50)"),
63
+ },
64
+ { readOnlyHint: true, destructiveHint: false },
65
+ async ({ taskId, page, pageSize }) => {
66
+ try {
67
+ const clients = getClients();
68
+ const res = await clients.taskDependencies.getTaskDependencies({
69
+ taskId,
70
+ pagination: defaultPagination(page || 1, pageSize || 50),
71
+ });
72
+ return {
73
+ content: [{ type: "text", text: formatTaskDependencyList(res.taskDependencies) }],
74
+ };
75
+ } catch (error) {
76
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
77
+ }
78
+ },
79
+ );
80
+
81
+ server.tool(
82
+ "opsee_delete_task_dependency",
83
+ "Delete a task dependency by its ID. This is permanent.",
84
+ { dependencyId: z.number().describe("The task dependency ID") },
85
+ { readOnlyHint: false, destructiveHint: true },
86
+ async ({ dependencyId }) => {
87
+ try {
88
+ const clients = getClients();
89
+ await clients.taskDependencies.deleteTaskDependency({ id: dependencyId });
90
+ return { content: [{ type: "text", text: "Task dependency deleted." }] };
91
+ } catch (error) {
92
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
93
+ }
94
+ },
95
+ );
96
+ }
@@ -16,17 +16,18 @@ export function registerTaskTools(
16
16
  ): void {
17
17
  server.tool(
18
18
  "opsee_list_tasks",
19
- "List tasks in an Opsee project. Supports filtering by status, assignee, board column, and cycle.",
19
+ "List tasks in an Opsee project. Supports filtering by status, assignee, board column, cycle, and labels.",
20
20
  {
21
21
  projectId: z.number().describe("The project ID"),
22
22
  columnId: z.number().optional().describe("Filter by board column ID (status)"),
23
23
  assigneeId: z.number().optional().describe("Filter by assigned user ID"),
24
24
  cycleId: z.number().optional().describe("Filter by cycle/sprint ID"),
25
+ labelIds: z.array(z.number().int().min(1)).optional().describe("Filter by label IDs (AND semantics — task must have all listed labels)"),
25
26
  page: z.number().optional().describe("Page number (default: 1)"),
26
27
  pageSize: z.number().optional().describe("Items per page (default: 50)"),
27
28
  },
28
29
  { readOnlyHint: true, destructiveHint: false },
29
- async ({ projectId, columnId, assigneeId, cycleId, page, pageSize }) => {
30
+ async ({ projectId, columnId, assigneeId, cycleId, labelIds, page, pageSize }) => {
30
31
  try {
31
32
  const clients = getClients();
32
33
 
@@ -45,6 +46,7 @@ export function registerTaskTools(
45
46
  projectId,
46
47
  pagination: defaultPagination(page || 1, pageSize || 50),
47
48
  filterOptions,
49
+ labelIds: labelIds ?? [],
48
50
  });
49
51
 
50
52
  return { content: [{ type: "text", text: formatTaskList(res.tasks) }] };
@@ -77,15 +79,17 @@ export function registerTaskTools(
77
79
  {
78
80
  projectId: z.number().describe("The project ID"),
79
81
  title: z.string().describe("Task title"),
80
- description: z.string().optional().describe("Task description"),
82
+ description: z.string().optional().describe("Task description. Backend may transform plain text or markdown into BlockNote JSON on write; check returned task to see exact stored form. See README \"Task description format\" for details."),
81
83
  taskTypeId: z.number().optional().describe("Task type ID (from opsee_list_task_types)"),
82
84
  priorityId: z.number().optional().describe("Priority ID (from opsee_list_task_priorities)"),
83
85
  boardColumnId: z.number().optional().describe("Board column ID (from opsee_list_board_columns)"),
84
86
  assigneeId: z.number().optional().describe("Assigned user ID"),
85
87
  cycleId: z.number().optional().describe("Cycle/sprint ID"),
88
+ storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
89
+ estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
86
90
  },
87
91
  { readOnlyHint: false, destructiveHint: false },
88
- async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
92
+ async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours }) => {
89
93
  try {
90
94
  const clients = getClients();
91
95
 
@@ -151,6 +155,8 @@ export function registerTaskTools(
151
155
  displayOrder: 0,
152
156
  aiModeEnabled: false,
153
157
  cycleId,
158
+ storyPoints,
159
+ estimatedHours,
154
160
  repositoryIds: [],
155
161
  });
156
162
 
@@ -174,15 +180,17 @@ export function registerTaskTools(
174
180
  {
175
181
  taskId: z.number().describe("The task ID to update"),
176
182
  title: z.string().optional().describe("New title"),
177
- description: z.string().optional().describe("New description"),
183
+ description: z.string().optional().describe("New description. Backend may transform plain text or markdown into BlockNote JSON on write; check returned task to see exact stored form. See README \"Task description format\" for details."),
178
184
  taskTypeId: z.number().optional().describe("New task type ID"),
179
185
  priorityId: z.number().optional().describe("New priority ID"),
180
186
  boardColumnId: z.number().optional().describe("New board column ID (status)"),
181
187
  assigneeId: z.number().optional().describe("New assigned user ID"),
182
188
  cycleId: z.number().optional().describe("New cycle/sprint ID"),
189
+ storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
190
+ estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
183
191
  },
184
192
  { readOnlyHint: false, destructiveHint: false },
185
- async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
193
+ async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, storyPoints, estimatedHours }) => {
186
194
  try {
187
195
  const clients = getClients();
188
196
 
@@ -199,8 +207,8 @@ export function registerTaskTools(
199
207
  title: title ?? task.title,
200
208
  description: description ?? task.description,
201
209
  displayOrder: task.displayOrder,
202
- storyPoints: task.storyPoints,
203
- estimatedHours: task.estimatedHours,
210
+ storyPoints: storyPoints ?? task.storyPoints,
211
+ estimatedHours: estimatedHours ?? task.estimatedHours,
204
212
  actualHours: task.actualHours,
205
213
  dueDate: task.dueDate,
206
214
  aiModeEnabled: task.aiModeEnabled,
@@ -229,4 +237,44 @@ export function registerTaskTools(
229
237
  }
230
238
  },
231
239
  );
240
+
241
+ server.tool(
242
+ "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.",
244
+ {
245
+ projectId: z.number().describe("Project ID - all tasks must belong to this project"),
246
+ taskIds: z.array(z.number()).min(1).describe("Task IDs to update (at least one)"),
247
+ boardColumnId: z.number().optional().describe("New board column ID (status)"),
248
+ taskPriorityId: z.number().optional().describe("New priority ID"),
249
+ taskTypeId: z.number().optional().describe("New task type ID"),
250
+ assignedUserId: z.number().optional().describe("New assigned user ID"),
251
+ cycleId: z.number().optional().describe("New cycle/sprint ID"),
252
+ },
253
+ { readOnlyHint: false, destructiveHint: false },
254
+ async ({ projectId, taskIds, boardColumnId, taskPriorityId, taskTypeId, assignedUserId, cycleId }) => {
255
+ try {
256
+ const clients = getClients();
257
+ const res = await clients.tasks.bulkEditTasks({
258
+ projectId,
259
+ taskIds,
260
+ boardColumnId,
261
+ taskPriorityId,
262
+ taskTypeId,
263
+ assignedUserId,
264
+ cycleId,
265
+ repositoryIds: [],
266
+ });
267
+
268
+ const requested = taskIds.length;
269
+ const updated = res.updatedCount;
270
+ let text = `Updated ${updated} of ${requested} task(s).`;
271
+ if (updated < requested) {
272
+ text += " (Tasks not visible to your company or not found were skipped.)";
273
+ }
274
+ return { content: [{ type: "text", text }] };
275
+ } catch (error) {
276
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
277
+ }
278
+ },
279
+ );
232
280
  }
package/src/tools/user.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { z } from "zod";
1
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import type { ApiClients } from "../client/api.js";
3
- import { formatError } from "../utils/format.js";
4
+ import { defaultPagination } from "../client/api.js";
5
+ import { formatError, formatUser, formatUserList } from "../utils/format.js";
4
6
 
5
7
  export function registerUserTools(
6
8
  server: McpServer,
@@ -31,4 +33,42 @@ export function registerUserTools(
31
33
  }
32
34
  },
33
35
  );
36
+
37
+ server.tool(
38
+ "opsee_list_users",
39
+ "Lists users in the authenticated company. Opsee scopes users at the Company level — there is no project-scoped user listing because Users do not have a per-project relation. Use this to resolve assignee names to IDs.",
40
+ {
41
+ page: z.number().optional().describe("Page number (default: 1)"),
42
+ pageSize: z.number().optional().describe("Items per page (default: 50)"),
43
+ },
44
+ { readOnlyHint: true, destructiveHint: false },
45
+ async ({ page, pageSize }) => {
46
+ try {
47
+ const clients = getClients();
48
+ const res = await clients.users.getUsers({
49
+ pagination: defaultPagination(page || 1, pageSize || 50),
50
+ });
51
+ return { content: [{ type: "text", text: formatUserList(res.users) }] };
52
+ } catch (error) {
53
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
54
+ }
55
+ },
56
+ );
57
+
58
+ server.tool(
59
+ "opsee_get_user",
60
+ "Get full details of a specific user by ID.",
61
+ { userId: z.number().describe("The user ID") },
62
+ { readOnlyHint: true, destructiveHint: false },
63
+ async ({ userId }) => {
64
+ try {
65
+ const clients = getClients();
66
+ const res = await clients.users.getUser({ id: userId });
67
+ if (!res.user) return { content: [{ type: "text", text: "User not found. Use opsee_list_users to see available users." }] };
68
+ return { content: [{ type: "text", text: formatUser(res.user) }] };
69
+ } catch (error) {
70
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
71
+ }
72
+ },
73
+ );
34
74
  }