@opsee/mcp-server 0.5.6 → 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/README.md +58 -1
- package/gen/api/v1/models_pb.d.ts +102 -1
- package/gen/api/v1/models_pb.js +21 -2
- package/gen/api/v1/notification_pb.d.ts +21 -1
- package/gen/api/v1/notification_pb.js +12 -5
- package/gen/api/v1/task_dependency_pb.d.ts +151 -0
- package/gen/api/v1/task_dependency_pb.js +60 -0
- package/gen/api/v1/task_pb.d.ts +136 -0
- package/gen/api/v1/task_pb.js +37 -9
- package/package.json +1 -1
- package/src/client/api.ts +12 -0
- package/src/server.ts +6 -0
- package/src/tools/comments.ts +96 -0
- package/src/tools/labels.ts +199 -0
- package/src/tools/task-dependencies.ts +96 -0
- package/src/tools/tasks.ts +56 -8
- package/src/tools/user.ts +41 -1
- package/src/utils/format.ts +81 -1
|
@@ -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
|
+
}
|
package/src/tools/tasks.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
}
|
package/src/utils/format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Task, Project, Cycle, DocPage, DocSpace, Milestone, MilestoneTask } from "../../gen/api/v1/models_pb.js";
|
|
1
|
+
import type { Task, Project, Cycle, DocPage, DocSpace, Milestone, MilestoneTask, User, Label, TaskLabel, Comment, TaskDependency } from "../../gen/api/v1/models_pb.js";
|
|
2
|
+
import { TaskDependencyType } from "../../gen/api/v1/models_pb.js";
|
|
2
3
|
|
|
3
4
|
export function formatProject(p: Project): string {
|
|
4
5
|
const lines = [
|
|
@@ -162,6 +163,85 @@ export function formatMilestoneTaskList(milestoneTasks: MilestoneTask[]): string
|
|
|
162
163
|
return header + milestoneTasks.map((mt, i) => `${i + 1}. ${formatMilestoneTask(mt)}`).join("\n\n");
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
export function formatUser(u: User): string {
|
|
167
|
+
const lines = [`${u.fullName} (${u.email})`];
|
|
168
|
+
lines.push(` ID: ${u.id}`);
|
|
169
|
+
if (u.flattenedRoles?.length) lines.push(` Roles: ${u.flattenedRoles.join(", ")}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function formatUserList(users: User[]): string {
|
|
174
|
+
if (users.length === 0) return "No users found.";
|
|
175
|
+
const header = `Found ${users.length} user(s):\n`;
|
|
176
|
+
return header + users.map((u, i) => `${i + 1}. ${formatUser(u)}`).join("\n\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatLabel(l: Label): string {
|
|
180
|
+
const lines = [`${l.name}`];
|
|
181
|
+
lines.push(` Color: ${l.color}`);
|
|
182
|
+
if (l.description) lines.push(` Description: ${l.description}`);
|
|
183
|
+
lines.push(` Active: ${l.isActive ? "Yes" : "No"}`);
|
|
184
|
+
lines.push(` Project ID: ${l.projectId}`);
|
|
185
|
+
lines.push(` ID: ${l.id}`);
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatLabelList(labels: Label[]): string {
|
|
190
|
+
if (labels.length === 0) return "No labels found.";
|
|
191
|
+
const header = `Found ${labels.length} label(s):\n`;
|
|
192
|
+
return header + labels.map((l, i) => `${i + 1}. ${formatLabel(l)}`).join("\n\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function formatTaskLabelList(taskLabels: TaskLabel[]): string {
|
|
196
|
+
if (taskLabels.length === 0) return "No labels attached to this task.";
|
|
197
|
+
const header = `Found ${taskLabels.length} label attachment(s):\n`;
|
|
198
|
+
return header + taskLabels.map((tl, i) => {
|
|
199
|
+
const labelName = tl.label?.name || `Label #${tl.labelId}`;
|
|
200
|
+
return `${i + 1}. ${labelName} (Label ID: ${tl.labelId} | TaskLabel ID: ${tl.id})`;
|
|
201
|
+
}).join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatComment(c: Comment): string {
|
|
205
|
+
const lines = [];
|
|
206
|
+
const author = c.user?.fullName || `User #${c.userId}`;
|
|
207
|
+
const created = c.createdAt
|
|
208
|
+
? new Date((typeof c.createdAt.seconds === "bigint" ? Number(c.createdAt.seconds) : c.createdAt.seconds) * 1000).toISOString()
|
|
209
|
+
: "—";
|
|
210
|
+
lines.push(`${author} @ ${created}${c.isInternal ? " [internal]" : ""}`);
|
|
211
|
+
lines.push(` ${c.content}`);
|
|
212
|
+
lines.push(` ID: ${c.id}`);
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function formatCommentList(comments: Comment[]): string {
|
|
217
|
+
if (comments.length === 0) return "No comments found.";
|
|
218
|
+
const header = `Found ${comments.length} comment(s):\n`;
|
|
219
|
+
return header + comments.map((c, i) => `${i + 1}. ${formatComment(c)}`).join("\n\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function taskDependencyTypeToString(t: TaskDependencyType): string {
|
|
223
|
+
switch (t) {
|
|
224
|
+
case TaskDependencyType.BLOCKS: return "BLOCKS";
|
|
225
|
+
case TaskDependencyType.BLOCKED_BY: return "BLOCKED_BY";
|
|
226
|
+
case TaskDependencyType.DUPLICATES: return "DUPLICATES";
|
|
227
|
+
case TaskDependencyType.RELATES_TO: return "RELATES_TO";
|
|
228
|
+
default: return "UNSPECIFIED";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function formatTaskDependency(d: TaskDependency): string {
|
|
233
|
+
const typeStr = taskDependencyTypeToString(d.type);
|
|
234
|
+
const from = d.fromTask?.identifier || `Task #${d.fromTaskId}`;
|
|
235
|
+
const to = d.toTask?.identifier || `Task #${d.toTaskId}`;
|
|
236
|
+
return `${from} ${typeStr} ${to}\n ID: ${d.id}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatTaskDependencyList(deps: TaskDependency[]): string {
|
|
240
|
+
if (deps.length === 0) return "No task dependencies found.";
|
|
241
|
+
return `Found ${deps.length} dependency(ies):\n` +
|
|
242
|
+
deps.map((d, i) => `${i + 1}. ${formatTaskDependency(d)}`).join("\n\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
165
245
|
export function formatError(error: unknown): string {
|
|
166
246
|
if (error instanceof Error) {
|
|
167
247
|
const msg = error.message;
|