@opsee/mcp-server 0.8.1 → 0.8.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,321 @@
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 { overridePagination } from "../client/api.js";
5
+ import {
6
+ formatInitiative,
7
+ formatInitiativeList,
8
+ formatInitiativeMemoryEntry,
9
+ formatInitiativeMemoryList,
10
+ formatInitiativeGraph,
11
+ formatInitiativeContext,
12
+ formatError,
13
+ } from "../utils/format.js";
14
+
15
+ const INITIATIVE_STATUSES = ["draft", "active", "paused", "completed", "abandoned"] as const;
16
+ const ANCHOR_TYPES = ["none", "task", "milestone", "cycle"] as const;
17
+ const MEMORY_KINDS = ["decision", "outcome", "learning", "blocker", "context"] as const;
18
+ const MEMORY_SOURCE_TYPES = ["none", "task", "pull_request", "doc", "workflow"] as const;
19
+ const EDGE_TYPES = ["blocks", "blocked_by", "duplicates", "relates_to"] as const;
20
+
21
+ export function registerInitiativeTools(
22
+ server: McpServer,
23
+ getClients: () => ApiClients,
24
+ ): void {
25
+ server.tool(
26
+ "opsee_list_initiatives",
27
+ "List initiatives in an Opsee project.",
28
+ { projectId: z.number().describe("The project ID") },
29
+ { readOnlyHint: true, destructiveHint: false },
30
+ async ({ projectId }) => {
31
+ try {
32
+ const clients = getClients();
33
+ const res = await clients.initiatives.getInitiatives({
34
+ projectId,
35
+ pagination: overridePagination(),
36
+ });
37
+ return { content: [{ type: "text", text: formatInitiativeList(res.initiatives) }] };
38
+ } catch (error) {
39
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
40
+ }
41
+ },
42
+ );
43
+
44
+ server.tool(
45
+ "opsee_get_initiative",
46
+ "Get details of a specific initiative by ID.",
47
+ { initiativeId: z.number().describe("The initiative ID") },
48
+ { readOnlyHint: true, destructiveHint: false },
49
+ async ({ initiativeId }) => {
50
+ try {
51
+ const clients = getClients();
52
+ const res = await clients.initiatives.getInitiative({ id: initiativeId });
53
+ if (!res.initiative) return { content: [{ type: "text", text: "Initiative not found." }] };
54
+ return { content: [{ type: "text", text: formatInitiative(res.initiative) }] };
55
+ } catch (error) {
56
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
57
+ }
58
+ },
59
+ );
60
+
61
+ server.tool(
62
+ "opsee_create_initiative",
63
+ "Create a new initiative in an Opsee project.",
64
+ {
65
+ projectId: z.number().describe("The project ID"),
66
+ title: z.string().describe("Initiative title"),
67
+ summary: z.string().optional().describe("Short summary of the initiative"),
68
+ status: z.enum(INITIATIVE_STATUSES).optional().describe("Initiative status. Defaults to 'draft'"),
69
+ anchorType: z.enum(ANCHOR_TYPES).optional().describe("Anchor type (none | task | milestone | cycle). Defaults to 'none'"),
70
+ anchorId: z.number().optional().describe("Anchor entity ID. Required when anchorType is not 'none'"),
71
+ coreIdea: z.string().optional().describe("Core idea body (the initiative's primary description). Markdown is supported and recommended — headings (##), bullets (-), checklists, code fences, **bold**/*italic*/`code`/links all render as formatted blocks in the UI (same behavior as task descriptions). Written as-is; the UI parses the markdown on read and re-saves it as BlockNote JSON on the next manual edit."),
72
+ coreIdeaContentType: z.string().optional().describe("Content type of coreIdea: 'text' (markdown — the default) or 'json' (BlockNote). Leave unset and just write markdown."),
73
+ },
74
+ { readOnlyHint: false, destructiveHint: false },
75
+ async ({ projectId, title, summary, status, anchorType, anchorId, coreIdea, coreIdeaContentType }) => {
76
+ try {
77
+ const clients = getClients();
78
+ const res = await clients.initiatives.addInitiative({
79
+ projectId,
80
+ title,
81
+ summary,
82
+ status: status ?? "draft",
83
+ anchorType: anchorType ?? "none",
84
+ anchorId,
85
+ coreIdea,
86
+ coreIdeaContentType: coreIdeaContentType ?? "text",
87
+ });
88
+ if (!res.initiative)
89
+ return { content: [{ type: "text", text: "Failed to create initiative. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
90
+ return {
91
+ content: [{ type: "text", text: `Initiative created:\n${formatInitiative(res.initiative)}` }],
92
+ };
93
+ } catch (error) {
94
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
95
+ }
96
+ },
97
+ );
98
+
99
+ server.tool(
100
+ "opsee_update_initiative",
101
+ "Update an initiative's fields. Fetches the current initiative first, then applies only the provided changes.",
102
+ {
103
+ initiativeId: z.number().describe("The initiative ID to update"),
104
+ title: z.string().optional().describe("New title"),
105
+ summary: z.string().optional().describe("New summary"),
106
+ status: z.enum(INITIATIVE_STATUSES).optional().describe("New status"),
107
+ anchorType: z.enum(ANCHOR_TYPES).optional().describe("New anchor type"),
108
+ anchorId: z.number().optional().describe("New anchor entity ID"),
109
+ coreIdea: z.string().optional().describe("New core idea body. Markdown is supported and renders as formatted blocks in the UI (same as task descriptions)."),
110
+ coreIdeaContentType: z.string().optional().describe("Content type for coreIdea: 'text' (markdown — default) or 'json' (BlockNote)."),
111
+ },
112
+ { readOnlyHint: false, destructiveHint: false },
113
+ async ({ initiativeId, title, summary, status, anchorType, anchorId, coreIdea, coreIdeaContentType }) => {
114
+ try {
115
+ const clients = getClients();
116
+ const current = await clients.initiatives.getInitiative({ id: initiativeId });
117
+ if (!current.initiative)
118
+ return { content: [{ type: "text", text: "Initiative not found." }] };
119
+ const ini = current.initiative;
120
+ const res = await clients.initiatives.editInitiative({
121
+ id: initiativeId,
122
+ title: title ?? ini.title,
123
+ summary: summary !== undefined ? summary : ini.summary,
124
+ status: status ?? ini.status,
125
+ anchorType: anchorType ?? ini.anchorType,
126
+ anchorId: anchorId !== undefined ? anchorId : ini.anchorId,
127
+ coreIdea: coreIdea !== undefined ? coreIdea : ini.coreIdea,
128
+ coreIdeaContentType: coreIdeaContentType !== undefined ? coreIdeaContentType : ini.coreIdeaContentType,
129
+ });
130
+ if (!res.initiative)
131
+ return { content: [{ type: "text", text: "Failed to update initiative. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
132
+ return {
133
+ content: [{ type: "text", text: `Initiative updated:\n${formatInitiative(res.initiative)}` }],
134
+ };
135
+ } catch (error) {
136
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
137
+ }
138
+ },
139
+ );
140
+
141
+ server.tool(
142
+ "opsee_delete_initiative",
143
+ "Delete an initiative permanently.",
144
+ { initiativeId: z.number().describe("The initiative ID to delete") },
145
+ { readOnlyHint: false, destructiveHint: true },
146
+ async ({ initiativeId }) => {
147
+ try {
148
+ const clients = getClients();
149
+ await clients.initiatives.deleteInitiative({ id: initiativeId });
150
+ return { content: [{ type: "text", text: "Initiative deleted." }] };
151
+ } catch (error) {
152
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
153
+ }
154
+ },
155
+ );
156
+
157
+ server.tool(
158
+ "opsee_get_initiative_context",
159
+ "Load an entire Initiative work session in one call: the core-idea doc body, the execution task tree with parallel slices (what can run concurrently vs sequentially), the append-only memory log, and rolled-up PRs. Call this first when picking up or resuming work on an Initiative.",
160
+ {
161
+ initiativeId: z.number().describe("The initiative ID"),
162
+ memoryLimit: z.number().optional().describe("Cap on memory entries returned (most recent first); 0 or omitted = service default"),
163
+ },
164
+ { readOnlyHint: true, destructiveHint: false },
165
+ async ({ initiativeId, memoryLimit }) => {
166
+ try {
167
+ const clients = getClients();
168
+ const res = await clients.initiatives.getInitiativeContext({
169
+ initiativeId,
170
+ memoryLimit: memoryLimit ?? 0,
171
+ });
172
+ if (!res.initiative) return { content: [{ type: "text", text: "Initiative not found." }] };
173
+ const text = formatInitiativeContext(
174
+ res.initiative,
175
+ res.graph,
176
+ res.memoryEntries,
177
+ res.pullRequests,
178
+ res.comments,
179
+ );
180
+ return { content: [{ type: "text", text }] };
181
+ } catch (error) {
182
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
183
+ }
184
+ },
185
+ );
186
+
187
+ server.tool(
188
+ "opsee_get_initiative_graph",
189
+ "Get the task dependency graph for an initiative, including parallel execution batches (slices).",
190
+ { initiativeId: z.number().describe("The initiative ID") },
191
+ { readOnlyHint: true, destructiveHint: false },
192
+ async ({ initiativeId }) => {
193
+ try {
194
+ const clients = getClients();
195
+ const res = await clients.initiatives.getInitiativeGraph({ initiativeId });
196
+ if (!res.graph) return { content: [{ type: "text", text: "No graph found for this initiative." }] };
197
+ return { content: [{ type: "text", text: formatInitiativeGraph(res.graph) }] };
198
+ } catch (error) {
199
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
200
+ }
201
+ },
202
+ );
203
+
204
+ server.tool(
205
+ "opsee_decompose_initiative",
206
+ "Materialize a proposed breakdown as real Tasks under the Initiative in one atomic call. Wire the tree with temporary `ref` ids: set `parentRef` to nest a subtask, and add `edges` (from_ref blocks to_ref) to express sequencing — tasks with no blocking edge between them become parallel slices. All-or-nothing: a bad ref rolls the whole thing back.",
207
+ {
208
+ initiativeId: z.number().describe("The initiative ID"),
209
+ tasks: z.array(z.object({
210
+ ref: z.string().describe("Temporary reference ID used to wire edges and parent relationships"),
211
+ title: z.string().describe("Task title"),
212
+ description: z.string().optional().describe("Task description"),
213
+ parentRef: z.string().optional().describe("Ref of the parent task (to nest this as a subtask)"),
214
+ taskTypeId: z.number().optional().describe("Task type ID"),
215
+ taskPriorityId: z.number().optional().describe("Task priority ID"),
216
+ boardColumnId: z.number().optional().describe("Board column ID (status)"),
217
+ })).describe("Tasks to create under this initiative"),
218
+ edges: z.array(z.object({
219
+ fromRef: z.string().describe("Ref of the source task"),
220
+ toRef: z.string().describe("Ref of the target task"),
221
+ type: z.enum(EDGE_TYPES).describe("Dependency type: blocks | blocked_by | duplicates | relates_to"),
222
+ })).describe("Dependency edges between tasks (by ref)"),
223
+ },
224
+ { readOnlyHint: false, destructiveHint: false },
225
+ async ({ initiativeId, tasks, edges }) => {
226
+ try {
227
+ const clients = getClients();
228
+ const res = await clients.initiatives.decomposeInitiative({
229
+ initiativeId,
230
+ tasks: tasks.map((t) => ({
231
+ ref: t.ref,
232
+ title: t.title,
233
+ description: t.description,
234
+ parentRef: t.parentRef,
235
+ taskTypeId: t.taskTypeId,
236
+ taskPriorityId: t.taskPriorityId,
237
+ boardColumnId: t.boardColumnId,
238
+ })),
239
+ edges: edges.map((e) => ({
240
+ fromRef: e.fromRef,
241
+ toRef: e.toRef,
242
+ type: e.type,
243
+ })),
244
+ });
245
+ const lines: string[] = [`Created ${res.createdTasks.length} task(s).`];
246
+ res.createdTasks.forEach((t, i) => {
247
+ lines.push(` ${i + 1}. [${t.identifier || `#${t.id}`}] ${t.title} (ID: ${t.id})`);
248
+ });
249
+ if (res.graph) {
250
+ lines.push("\n" + formatInitiativeGraph(res.graph));
251
+ }
252
+ return { content: [{ type: "text", text: lines.join("\n") }] };
253
+ } catch (error) {
254
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
255
+ }
256
+ },
257
+ );
258
+
259
+ server.tool(
260
+ "opsee_add_initiative_memory",
261
+ "Record a decision, outcome, learning, blocker, or context entry to the Initiative's append-only memory log. Agents should record here as they work, citing the source task or PR where relevant.",
262
+ {
263
+ initiativeId: z.number().describe("The initiative ID"),
264
+ kind: z.enum(MEMORY_KINDS).describe("Entry kind: decision | outcome | learning | blocker | context"),
265
+ body: z.string().describe("The memory entry body text"),
266
+ contentType: z.string().optional().describe("Content type of body (e.g. 'text'). Defaults to 'text'"),
267
+ sourceType: z.enum(MEMORY_SOURCE_TYPES).optional().describe("Source type: none | task | pull_request | doc | workflow. Defaults to 'none'"),
268
+ sourceId: z.number().optional().describe("ID of the source entity (task ID, PR ID, etc.)"),
269
+ sourceUrl: z.string().optional().describe("URL linking to the source"),
270
+ },
271
+ { readOnlyHint: false, destructiveHint: false },
272
+ async ({ initiativeId, kind, body, contentType, sourceType, sourceId, sourceUrl }) => {
273
+ try {
274
+ const clients = getClients();
275
+ const res = await clients.initiatives.addInitiativeMemoryEntry({
276
+ initiativeId,
277
+ kind,
278
+ body,
279
+ contentType: contentType ?? "text",
280
+ sourceType: sourceType ?? "none",
281
+ sourceId,
282
+ sourceUrl,
283
+ });
284
+ if (!res.memoryEntry)
285
+ return { content: [{ type: "text", text: "Failed to add memory entry. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
286
+ return {
287
+ content: [{ type: "text", text: `Memory entry added:\n${formatInitiativeMemoryEntry(res.memoryEntry)}` }],
288
+ };
289
+ } catch (error) {
290
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
291
+ }
292
+ },
293
+ );
294
+
295
+ server.tool(
296
+ "opsee_list_initiative_memory",
297
+ "List memory entries for an initiative (newest first). Optionally filter by kind, source type, or source ID.",
298
+ {
299
+ initiativeId: z.number().describe("The initiative ID"),
300
+ kinds: z.array(z.string()).optional().describe("Filter by entry kinds (decision | outcome | learning | blocker | context)"),
301
+ sourceType: z.string().optional().describe("Filter by source type (none | task | pull_request | doc | workflow)"),
302
+ sourceId: z.number().optional().describe("Filter by source entity ID"),
303
+ },
304
+ { readOnlyHint: true, destructiveHint: false },
305
+ async ({ initiativeId, kinds, sourceType, sourceId }) => {
306
+ try {
307
+ const clients = getClients();
308
+ const res = await clients.initiatives.getInitiativeMemory({
309
+ initiativeId,
310
+ kinds: kinds ?? [],
311
+ sourceType: sourceType ?? "",
312
+ sourceId,
313
+ pagination: overridePagination(),
314
+ });
315
+ return { content: [{ type: "text", text: formatInitiativeMemoryList(res.memoryEntries) }] };
316
+ } catch (error) {
317
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
318
+ }
319
+ },
320
+ );
321
+ }
@@ -309,9 +309,10 @@ export function registerTaskTools(
309
309
  storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
310
310
  estimatedHours: z.number().min(0).optional().describe("Estimated effort in hours"),
311
311
  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."),
312
+ initiativeId: z.number().int().min(0).optional().describe("Initiative ID to link this task to. >0 links the task to that Initiative, 0 detaches."),
312
313
  },
313
314
  { readOnlyHint: false, destructiveHint: false },
314
- async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, milestoneId, parentTaskId, storyPoints, estimatedHours, labelIds }) => {
315
+ async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId, milestoneId, parentTaskId, storyPoints, estimatedHours, labelIds, initiativeId }) => {
315
316
  try {
316
317
  const clients = getClients();
317
318
 
@@ -346,6 +347,7 @@ export function registerTaskTools(
346
347
  parentTaskId: parentTaskId ?? task.parentTaskId,
347
348
  repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
348
349
  labelIds,
350
+ initiativeId: initiativeId !== undefined ? initiativeId : task.initiativeId,
349
351
  });
350
352
 
351
353
  if (!res.task)
@@ -570,6 +572,103 @@ export function registerTaskTools(
570
572
  },
571
573
  );
572
574
 
575
+ server.tool(
576
+ "opsee_link_task_to_initiative",
577
+ "Link an existing task to an Initiative. Fetches the task first so no other fields are blanked.",
578
+ {
579
+ taskId: z.number().describe("The task ID to link"),
580
+ initiativeId: z.number().int().min(1).describe("The initiative ID to link the task to"),
581
+ },
582
+ { readOnlyHint: false, destructiveHint: false },
583
+ async ({ taskId, initiativeId }) => {
584
+ try {
585
+ const clients = getClients();
586
+ const getRes = await clients.tasks.getTask({ id: taskId });
587
+ const task = getRes.task;
588
+ if (!task)
589
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
590
+ const res = await clients.tasks.editTask({
591
+ id: task.id,
592
+ identifier: task.identifier,
593
+ title: task.title,
594
+ description: task.description,
595
+ displayOrder: task.displayOrder,
596
+ storyPoints: task.storyPoints,
597
+ estimatedHours: task.estimatedHours,
598
+ actualHours: task.actualHours,
599
+ dueDate: task.dueDate,
600
+ aiModeEnabled: task.aiModeEnabled,
601
+ metadataJson: task.metadataJson,
602
+ boardId: task.board?.id ?? 0,
603
+ boardColumnId: task.boardColumn?.id ?? 0,
604
+ projectId: task.project?.id ?? 0,
605
+ taskTypeId: task.taskType?.id ?? 0,
606
+ taskPriorityId: task.taskPriority?.id ?? 0,
607
+ reporterUserId: task.reporterUser?.id ?? 0,
608
+ assignedUserId: task.assignedUser?.id,
609
+ cycleId: task.cycle?.id,
610
+ milestoneId: task.milestoneId,
611
+ parentTaskId: task.parentTaskId,
612
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
613
+ initiativeId,
614
+ });
615
+ if (!res.task)
616
+ 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 };
617
+ return { content: [{ type: "text", text: `Task ${res.task.identifier} linked to initiative ${initiativeId}.` }] };
618
+ } catch (error) {
619
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
620
+ }
621
+ },
622
+ );
623
+
624
+ server.tool(
625
+ "opsee_unlink_task_from_initiative",
626
+ "Detach a task from its current Initiative. Fetches the task first so no other fields are blanked.",
627
+ {
628
+ taskId: z.number().describe("The task ID to unlink"),
629
+ },
630
+ { readOnlyHint: false, destructiveHint: false },
631
+ async ({ taskId }) => {
632
+ try {
633
+ const clients = getClients();
634
+ const getRes = await clients.tasks.getTask({ id: taskId });
635
+ const task = getRes.task;
636
+ if (!task)
637
+ return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
638
+ const res = await clients.tasks.editTask({
639
+ id: task.id,
640
+ identifier: task.identifier,
641
+ title: task.title,
642
+ description: task.description,
643
+ displayOrder: task.displayOrder,
644
+ storyPoints: task.storyPoints,
645
+ estimatedHours: task.estimatedHours,
646
+ actualHours: task.actualHours,
647
+ dueDate: task.dueDate,
648
+ aiModeEnabled: task.aiModeEnabled,
649
+ metadataJson: task.metadataJson,
650
+ boardId: task.board?.id ?? 0,
651
+ boardColumnId: task.boardColumn?.id ?? 0,
652
+ projectId: task.project?.id ?? 0,
653
+ taskTypeId: task.taskType?.id ?? 0,
654
+ taskPriorityId: task.taskPriority?.id ?? 0,
655
+ reporterUserId: task.reporterUser?.id ?? 0,
656
+ assignedUserId: task.assignedUser?.id,
657
+ cycleId: task.cycle?.id,
658
+ milestoneId: task.milestoneId,
659
+ parentTaskId: task.parentTaskId,
660
+ repositoryIds: task.taskRepositories?.map((r: { id: number }) => r.id) ?? [],
661
+ initiativeId: 0,
662
+ });
663
+ if (!res.task)
664
+ 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 };
665
+ return { content: [{ type: "text", text: `Task ${res.task.identifier} unlinked from initiative.` }] };
666
+ } catch (error) {
667
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
668
+ }
669
+ },
670
+ );
671
+
573
672
  server.tool(
574
673
  "opsee_delete_task",
575
674
  "Delete a task by ID. This is permanent.",