@opsee/mcp-server 0.6.1 → 0.6.4
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 +34 -4
- package/gen/api/v1/acceptance_criterion_pb.d.ts +195 -0
- package/gen/api/v1/acceptance_criterion_pb.js +73 -0
- package/gen/api/v1/doc_template_pb.d.ts +244 -0
- package/gen/api/v1/doc_template_pb.js +77 -0
- package/gen/api/v1/models_pb.d.ts +66 -0
- package/gen/api/v1/models_pb.js +8 -1
- package/gen/api/v1/task_dependency_pb.d.ts +67 -1
- package/gen/api/v1/task_dependency_pb.js +17 -3
- package/gen/api/v1/task_pb.d.ts +76 -1
- package/gen/api/v1/task_pb.js +1 -1
- package/gen/api/v1/task_template_pb.d.ts +349 -0
- package/gen/api/v1/task_template_pb.js +77 -0
- package/package.json +1 -1
- package/src/__tests__/tools.test.ts +409 -3
- package/src/client/api.ts +6 -0
- package/src/server.ts +6 -0
- package/src/tools/acceptance-criteria.ts +127 -0
- package/src/tools/comments.ts +40 -1
- package/src/tools/cycles.ts +189 -1
- package/src/tools/docs.ts +1 -1
- package/src/tools/labels.ts +3 -3
- package/src/tools/milestones.ts +3 -3
- package/src/tools/notifications.ts +171 -0
- package/src/tools/task-dependencies.ts +43 -1
- package/src/tools/tasks.ts +330 -11
- package/src/tools/work-logs.ts +123 -0
- package/src/utils/format.ts +3 -0
|
@@ -102,6 +102,73 @@ function createMockClients(): ApiClients {
|
|
|
102
102
|
getTask: async () => ({ task: mockTask }),
|
|
103
103
|
addTask: async () => ({ task: mockTask }),
|
|
104
104
|
editTask: async () => ({ task: { ...mockTask, title: "Updated bug" } }),
|
|
105
|
+
bulkEditTasks: async (req: { taskIds: number[] }) => ({
|
|
106
|
+
updatedCount: req.taskIds.length,
|
|
107
|
+
}),
|
|
108
|
+
deleteTask: async () => ({}),
|
|
109
|
+
bulkDeleteTasks: async () => ({ deletedCount: 2 }),
|
|
110
|
+
addWorkLog: async (req: { taskId: number; hours: number }) => ({
|
|
111
|
+
workLog: { id: 42, taskId: req.taskId, userId: 5, hours: req.hours },
|
|
112
|
+
}),
|
|
113
|
+
deleteWorkLog: async () => ({}),
|
|
114
|
+
},
|
|
115
|
+
comments: {
|
|
116
|
+
getComment: async () => ({ comment: { id: 11, content: "old text", isInternal: false, taskId: 10, userId: 5 } }),
|
|
117
|
+
getComments: async () => ({ comments: [] }),
|
|
118
|
+
editComment: async () => ({ comment: { id: 11, content: "edited text", isInternal: true, taskId: 10, userId: 5 } }),
|
|
119
|
+
},
|
|
120
|
+
taskLabels: {
|
|
121
|
+
getTaskLabels: async () => ({ taskLabels: [] }),
|
|
122
|
+
},
|
|
123
|
+
taskDependencies: {
|
|
124
|
+
getTaskDependencies: async () => ({ taskDependencies: [] }),
|
|
125
|
+
getDependencyChain: async () => ({
|
|
126
|
+
nodes: [{ id: 10, identifier: "TP-1", title: "Fix the bug" }],
|
|
127
|
+
edges: [],
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
acceptanceCriteria: {
|
|
131
|
+
getAcceptanceCriteriaByTask: async () => ({
|
|
132
|
+
criteria: [{ id: 1, taskId: 10, text: "endpoint returns 200", done: false, displayOrder: 0 }],
|
|
133
|
+
}),
|
|
134
|
+
addAcceptanceCriterion: async (req: { taskId: number; text: string; displayOrder?: number }) => ({
|
|
135
|
+
criterion: { id: 2, taskId: req.taskId, text: req.text, done: false, displayOrder: req.displayOrder ?? 0 },
|
|
136
|
+
}),
|
|
137
|
+
editAcceptanceCriterion: async (req: { id: number; text?: string; done?: boolean }) => ({
|
|
138
|
+
criterion: { id: req.id, taskId: 10, text: req.text ?? "old", done: req.done ?? true, displayOrder: 0 },
|
|
139
|
+
}),
|
|
140
|
+
deleteAcceptanceCriterion: async () => ({}),
|
|
141
|
+
},
|
|
142
|
+
notifications: {
|
|
143
|
+
getNotifications: async () => ({
|
|
144
|
+
notifications: [
|
|
145
|
+
{
|
|
146
|
+
id: 1,
|
|
147
|
+
title: "You were assigned to TP-1",
|
|
148
|
+
message: "Alice assigned you",
|
|
149
|
+
notificationType: "task_assigned",
|
|
150
|
+
entityType: "task",
|
|
151
|
+
entityId: 10,
|
|
152
|
+
isRead: false,
|
|
153
|
+
sentViaEmail: false,
|
|
154
|
+
userId: 5,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
getNotification: async () => ({
|
|
159
|
+
notification: {
|
|
160
|
+
id: 1,
|
|
161
|
+
title: "You were assigned to TP-1",
|
|
162
|
+
message: "Alice assigned you",
|
|
163
|
+
notificationType: "task_assigned",
|
|
164
|
+
isRead: false,
|
|
165
|
+
sentViaEmail: false,
|
|
166
|
+
userId: 5,
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
editNotification: async () => ({
|
|
170
|
+
notification: { id: 1, title: "x", message: "y", notificationType: "task_assigned", isRead: true, sentViaEmail: false, userId: 5 },
|
|
171
|
+
}),
|
|
105
172
|
},
|
|
106
173
|
boards: {
|
|
107
174
|
getBoards: async () => ({ boards: [mockBoard] }),
|
|
@@ -111,8 +178,10 @@ function createMockClients(): ApiClients {
|
|
|
111
178
|
},
|
|
112
179
|
cycles: {
|
|
113
180
|
getCycles: async () => ({ cycles: [mockCycle] }),
|
|
114
|
-
getCycle: async () => ({ cycle: mockCycle }),
|
|
115
|
-
addCycle: async () => ({ cycle: mockCycle }),
|
|
181
|
+
getCycle: async () => ({ cycle: { ...mockCycle, projectId: 1 } }),
|
|
182
|
+
addCycle: async () => ({ cycle: { ...mockCycle, id: 2, name: "Sprint 2" } }),
|
|
183
|
+
editCycle: async () => ({ cycle: { ...mockCycle, name: "Sprint 1 renamed" } }),
|
|
184
|
+
deleteCycle: async () => ({}),
|
|
116
185
|
},
|
|
117
186
|
docSpaces: {
|
|
118
187
|
getDocSpaces: async () => ({ docSpaces: [mockDocSpace] }),
|
|
@@ -338,7 +407,7 @@ describe("MCP Tools", () => {
|
|
|
338
407
|
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
339
408
|
.text;
|
|
340
409
|
expect(text).toContain("Cycle created");
|
|
341
|
-
expect(text).toContain("Sprint
|
|
410
|
+
expect(text).toContain("Sprint 2");
|
|
342
411
|
});
|
|
343
412
|
|
|
344
413
|
// --- Doc tools ---
|
|
@@ -435,6 +504,343 @@ describe("MCP Tools", () => {
|
|
|
435
504
|
await errorServer.close();
|
|
436
505
|
});
|
|
437
506
|
|
|
507
|
+
// --- Track 3 bulk + cycle + label additions ---
|
|
508
|
+
|
|
509
|
+
test("opsee_create_task accepts labelIds and returns the task", async () => {
|
|
510
|
+
const result = await client.callTool({
|
|
511
|
+
name: "opsee_create_task",
|
|
512
|
+
arguments: { projectId: 1, title: "with labels", labelIds: [1, 2] },
|
|
513
|
+
});
|
|
514
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
515
|
+
expect(text).toContain("Task created");
|
|
516
|
+
expect(text).toContain("TP-1");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("opsee_update_task accepts labelIds replace-set", async () => {
|
|
520
|
+
const result = await client.callTool({
|
|
521
|
+
name: "opsee_update_task",
|
|
522
|
+
arguments: { taskId: 10, labelIds: [3] },
|
|
523
|
+
});
|
|
524
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
525
|
+
expect(text).toContain("Task updated");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("opsee_bulk_update_tasks reports per-task count", async () => {
|
|
529
|
+
const result = await client.callTool({
|
|
530
|
+
name: "opsee_bulk_update_tasks",
|
|
531
|
+
arguments: { projectId: 1, taskIds: [10, 11, 12], boardColumnId: 5 },
|
|
532
|
+
});
|
|
533
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
534
|
+
expect(text).toContain("Updated 3 of 3");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("opsee_bulk_update_tasks accepts storyPoints/estimatedHours/parentTaskId/labelIds", async () => {
|
|
538
|
+
const result = await client.callTool({
|
|
539
|
+
name: "opsee_bulk_update_tasks",
|
|
540
|
+
arguments: {
|
|
541
|
+
projectId: 1,
|
|
542
|
+
taskIds: [10, 11],
|
|
543
|
+
storyPoints: 5,
|
|
544
|
+
estimatedHours: 8,
|
|
545
|
+
parentTaskId: 3,
|
|
546
|
+
labelIds: [1, 2],
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
550
|
+
expect(text).toContain("Updated 2 of 2");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("opsee_bulk_move_to_cycle delegates to bulk edit", async () => {
|
|
554
|
+
const result = await client.callTool({
|
|
555
|
+
name: "opsee_bulk_move_to_cycle",
|
|
556
|
+
arguments: { projectId: 1, taskIds: [10, 11], cycleId: 7 },
|
|
557
|
+
});
|
|
558
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
559
|
+
expect(text).toContain("Moved 2 of 2");
|
|
560
|
+
expect(text).toContain("cycle 7");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("opsee_bulk_move_to_column delegates to bulk edit", async () => {
|
|
564
|
+
const result = await client.callTool({
|
|
565
|
+
name: "opsee_bulk_move_to_column",
|
|
566
|
+
arguments: { projectId: 1, taskIds: [10], boardColumnId: 5 },
|
|
567
|
+
});
|
|
568
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
569
|
+
expect(text).toContain("Moved 1 of 1");
|
|
570
|
+
expect(text).toContain("column 5");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("opsee_bulk_assign delegates to bulk edit", async () => {
|
|
574
|
+
const result = await client.callTool({
|
|
575
|
+
name: "opsee_bulk_assign",
|
|
576
|
+
arguments: { projectId: 1, taskIds: [10, 11, 12], assigneeId: 5 },
|
|
577
|
+
});
|
|
578
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
579
|
+
expect(text).toContain("Assigned 3 of 3");
|
|
580
|
+
expect(text).toContain("user 5");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("opsee_bulk_set_priority delegates to bulk edit", async () => {
|
|
584
|
+
const result = await client.callTool({
|
|
585
|
+
name: "opsee_bulk_set_priority",
|
|
586
|
+
arguments: { projectId: 1, taskIds: [10], priorityId: 2 },
|
|
587
|
+
});
|
|
588
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
589
|
+
expect(text).toContain("Set priority 2");
|
|
590
|
+
expect(text).toContain("1 of 1");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("opsee_replace_task_labels with a set returns count", async () => {
|
|
594
|
+
const result = await client.callTool({
|
|
595
|
+
name: "opsee_replace_task_labels",
|
|
596
|
+
arguments: { taskId: 10, labelIds: [1, 2, 3] },
|
|
597
|
+
});
|
|
598
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
599
|
+
expect(text).toContain("Set 3 label(s)");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("opsee_replace_task_labels with empty clears", async () => {
|
|
603
|
+
const result = await client.callTool({
|
|
604
|
+
name: "opsee_replace_task_labels",
|
|
605
|
+
arguments: { taskId: 10, labelIds: [] },
|
|
606
|
+
});
|
|
607
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
608
|
+
expect(text).toContain("Cleared all labels");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("opsee_update_cycle returns the renamed cycle", async () => {
|
|
612
|
+
const result = await client.callTool({
|
|
613
|
+
name: "opsee_update_cycle",
|
|
614
|
+
arguments: { cycleId: 1, name: "Sprint 1 renamed" },
|
|
615
|
+
});
|
|
616
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
617
|
+
expect(text).toContain("Cycle updated");
|
|
618
|
+
expect(text).toContain("Sprint 1 renamed");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("opsee_close_cycle closes and carries forward", async () => {
|
|
622
|
+
const result = await client.callTool({
|
|
623
|
+
name: "opsee_close_cycle",
|
|
624
|
+
arguments: { cycleId: 1, nextCycleId: 2 },
|
|
625
|
+
});
|
|
626
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
627
|
+
expect(text).toContain("closed");
|
|
628
|
+
// mockTask is returned by getTasks, so we should see a carry-forward count.
|
|
629
|
+
expect(text).toContain("Carried 1 task(s) forward to cycle 2");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("opsee_close_cycle rejects mutually exclusive args", async () => {
|
|
633
|
+
const result = await client.callTool({
|
|
634
|
+
name: "opsee_close_cycle",
|
|
635
|
+
arguments: { cycleId: 1, nextCycleId: 2, createNextCycle: true },
|
|
636
|
+
});
|
|
637
|
+
expect(result.isError).toBe(true);
|
|
638
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
639
|
+
expect(text).toContain("mutually exclusive");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("opsee_get_active_cycle returns the active one", async () => {
|
|
643
|
+
const result = await client.callTool({
|
|
644
|
+
name: "opsee_get_active_cycle",
|
|
645
|
+
arguments: { projectId: 1 },
|
|
646
|
+
});
|
|
647
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
648
|
+
expect(text).toContain("Sprint 1");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("opsee_delete_cycle succeeds", async () => {
|
|
652
|
+
const result = await client.callTool({
|
|
653
|
+
name: "opsee_delete_cycle",
|
|
654
|
+
arguments: { cycleId: 1 },
|
|
655
|
+
});
|
|
656
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
657
|
+
expect(text).toContain("Cycle deleted");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// --- Track 1 defect fixes ---
|
|
661
|
+
|
|
662
|
+
test("opsee_delete_task succeeds", async () => {
|
|
663
|
+
const result = await client.callTool({
|
|
664
|
+
name: "opsee_delete_task",
|
|
665
|
+
arguments: { taskId: 10 },
|
|
666
|
+
});
|
|
667
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
668
|
+
expect(text).toContain("Task deleted");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("opsee_bulk_delete_tasks reports counts", async () => {
|
|
672
|
+
const result = await client.callTool({
|
|
673
|
+
name: "opsee_bulk_delete_tasks",
|
|
674
|
+
arguments: { projectId: 1, taskIds: [10, 11] },
|
|
675
|
+
});
|
|
676
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
677
|
+
expect(text).toContain("Deleted 2 of 2");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("opsee_edit_comment updates content", async () => {
|
|
681
|
+
const result = await client.callTool({
|
|
682
|
+
name: "opsee_edit_comment",
|
|
683
|
+
arguments: { commentId: 11, content: "edited text", isInternal: true },
|
|
684
|
+
});
|
|
685
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
686
|
+
expect(text).toContain("Comment updated");
|
|
687
|
+
expect(text).toContain("edited text");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("opsee_get_task_with_context composes related data", async () => {
|
|
691
|
+
const result = await client.callTool({
|
|
692
|
+
name: "opsee_get_task_with_context",
|
|
693
|
+
arguments: { taskId: 10 },
|
|
694
|
+
});
|
|
695
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
696
|
+
expect(text).toContain("TP-1");
|
|
697
|
+
expect(text).toContain("--- Labels ---");
|
|
698
|
+
expect(text).toContain("--- Dependencies ---");
|
|
699
|
+
expect(text).toContain("--- Subtasks ---");
|
|
700
|
+
expect(text).toContain("--- Comments ---");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// --- Acceptance Criteria (§3.7) ---
|
|
704
|
+
|
|
705
|
+
test("opsee_list_task_acceptance_criteria lists items", async () => {
|
|
706
|
+
const result = await client.callTool({
|
|
707
|
+
name: "opsee_list_task_acceptance_criteria",
|
|
708
|
+
arguments: { taskId: 10 },
|
|
709
|
+
});
|
|
710
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
711
|
+
expect(text).toContain("endpoint returns 200");
|
|
712
|
+
expect(text).toContain("[ ]");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("opsee_add_acceptance_criterion returns the new criterion", async () => {
|
|
716
|
+
const result = await client.callTool({
|
|
717
|
+
name: "opsee_add_acceptance_criterion",
|
|
718
|
+
arguments: { taskId: 10, text: "deploy to staging" },
|
|
719
|
+
});
|
|
720
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
721
|
+
expect(text).toContain("Criterion added");
|
|
722
|
+
expect(text).toContain("deploy to staging");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("opsee_update_acceptance_criterion toggles done", async () => {
|
|
726
|
+
const result = await client.callTool({
|
|
727
|
+
name: "opsee_update_acceptance_criterion",
|
|
728
|
+
arguments: { criterionId: 1, done: true },
|
|
729
|
+
});
|
|
730
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
731
|
+
expect(text).toContain("Criterion updated");
|
|
732
|
+
expect(text).toContain("[x]");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("opsee_delete_acceptance_criterion succeeds", async () => {
|
|
736
|
+
const result = await client.callTool({
|
|
737
|
+
name: "opsee_delete_acceptance_criterion",
|
|
738
|
+
arguments: { criterionId: 1 },
|
|
739
|
+
});
|
|
740
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
741
|
+
expect(text).toContain("Acceptance criterion deleted");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// --- Notifications (§15) ---
|
|
745
|
+
|
|
746
|
+
test("opsee_list_my_notifications returns notifications", async () => {
|
|
747
|
+
const result = await client.callTool({
|
|
748
|
+
name: "opsee_list_my_notifications",
|
|
749
|
+
arguments: {},
|
|
750
|
+
});
|
|
751
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
752
|
+
expect(text).toContain("notification(s)");
|
|
753
|
+
expect(text).toContain("task_assigned");
|
|
754
|
+
expect(text).toContain("unread");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("opsee_list_my_notifications unreadOnly returns unread header", async () => {
|
|
758
|
+
const result = await client.callTool({
|
|
759
|
+
name: "opsee_list_my_notifications",
|
|
760
|
+
arguments: { unreadOnly: true },
|
|
761
|
+
});
|
|
762
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
763
|
+
expect(text).toContain("unread notification");
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("opsee_mark_notification_read flips is_read", async () => {
|
|
767
|
+
const result = await client.callTool({
|
|
768
|
+
name: "opsee_mark_notification_read",
|
|
769
|
+
arguments: { notificationId: 1 },
|
|
770
|
+
});
|
|
771
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
772
|
+
expect(text).toContain("marked read");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("opsee_mark_all_notifications_read summarizes", async () => {
|
|
776
|
+
const result = await client.callTool({
|
|
777
|
+
name: "opsee_mark_all_notifications_read",
|
|
778
|
+
arguments: {},
|
|
779
|
+
});
|
|
780
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
781
|
+
expect(text).toContain("Marked 1 of 1");
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("opsee_get_dependency_chain returns nodes and edges summary", async () => {
|
|
785
|
+
const result = await client.callTool({
|
|
786
|
+
name: "opsee_get_dependency_chain",
|
|
787
|
+
arguments: { taskId: 10, direction: "both", depth: 3 },
|
|
788
|
+
});
|
|
789
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
790
|
+
expect(text).toContain("Dependency chain from task 10");
|
|
791
|
+
expect(text).toContain("1 task(s), 0 edge(s)");
|
|
792
|
+
expect(text).toContain("[TP-1]");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// --- WorkLog tools (§12) ---
|
|
796
|
+
|
|
797
|
+
test("opsee_log_work logs hours", async () => {
|
|
798
|
+
const result = await client.callTool({
|
|
799
|
+
name: "opsee_log_work",
|
|
800
|
+
arguments: { taskId: 10, hours: 2.5, description: "implemented foo" },
|
|
801
|
+
});
|
|
802
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
803
|
+
expect(text).toContain("Logged 2.5h on task 10");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("opsee_delete_work_log succeeds", async () => {
|
|
807
|
+
const result = await client.callTool({
|
|
808
|
+
name: "opsee_delete_work_log",
|
|
809
|
+
arguments: { taskId: 10, workLogId: 42 },
|
|
810
|
+
});
|
|
811
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
812
|
+
expect(text).toContain("Work log deleted");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("opsee_list_work_logs reports empty for tasks without logs", async () => {
|
|
816
|
+
const result = await client.callTool({
|
|
817
|
+
name: "opsee_list_work_logs",
|
|
818
|
+
arguments: { taskId: 10 },
|
|
819
|
+
});
|
|
820
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
821
|
+
expect(text).toContain("No work logs");
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("opsee_get_task_actual_hours sums to 0 with no logs", async () => {
|
|
825
|
+
const result = await client.callTool({
|
|
826
|
+
name: "opsee_get_task_actual_hours",
|
|
827
|
+
arguments: { taskId: 10 },
|
|
828
|
+
});
|
|
829
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
830
|
+
expect(text).toContain("TP-1");
|
|
831
|
+
expect(text).toContain("0h logged");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("formatTask surfaces storyPoints and estimatedHours", async () => {
|
|
835
|
+
// mockTask has storyPoints: 3 — confirms D4 fix.
|
|
836
|
+
const result = await client.callTool({
|
|
837
|
+
name: "opsee_get_task",
|
|
838
|
+
arguments: { taskId: 10 },
|
|
839
|
+
});
|
|
840
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
841
|
+
expect(text).toContain("Story Points: 3");
|
|
842
|
+
});
|
|
843
|
+
|
|
438
844
|
test("auth error returns login instruction", async () => {
|
|
439
845
|
const authClients = createMockClients();
|
|
440
846
|
(authClients.users as any).getMe = async () => {
|
package/src/client/api.ts
CHANGED
|
@@ -26,6 +26,8 @@ import { LabelService } from "../../gen/api/v1/label_pb.js";
|
|
|
26
26
|
import { TaskLabelService } from "../../gen/api/v1/task_label_pb.js";
|
|
27
27
|
import { CommentService } from "../../gen/api/v1/comment_pb.js";
|
|
28
28
|
import { TaskDependencyService } from "../../gen/api/v1/task_dependency_pb.js";
|
|
29
|
+
import { NotificationService } from "../../gen/api/v1/notification_pb.js";
|
|
30
|
+
import { AcceptanceCriterionService } from "../../gen/api/v1/acceptance_criterion_pb.js";
|
|
29
31
|
|
|
30
32
|
const authInterceptor: Interceptor = (next) => async (req) => {
|
|
31
33
|
// Remote mode: token from AsyncLocalStorage (per-request)
|
|
@@ -105,6 +107,8 @@ export type ApiClients = {
|
|
|
105
107
|
taskLabels: Client<typeof TaskLabelService>;
|
|
106
108
|
comments: Client<typeof CommentService>;
|
|
107
109
|
taskDependencies: Client<typeof TaskDependencyService>;
|
|
110
|
+
notifications: Client<typeof NotificationService>;
|
|
111
|
+
acceptanceCriteria: Client<typeof AcceptanceCriterionService>;
|
|
108
112
|
};
|
|
109
113
|
|
|
110
114
|
export function getClients(): ApiClients {
|
|
@@ -127,6 +131,8 @@ export function getClients(): ApiClients {
|
|
|
127
131
|
taskLabels: makeClient(TaskLabelService),
|
|
128
132
|
comments: makeClient(CommentService),
|
|
129
133
|
taskDependencies: makeClient(TaskDependencyService),
|
|
134
|
+
notifications: makeClient(NotificationService),
|
|
135
|
+
acceptanceCriteria: makeClient(AcceptanceCriterionService),
|
|
130
136
|
};
|
|
131
137
|
}
|
|
132
138
|
return cachedClients;
|
package/src/server.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { registerMilestoneTools } from "./tools/milestones.js";
|
|
|
11
11
|
import { registerLabelTools } from "./tools/labels.js";
|
|
12
12
|
import { registerCommentTools } from "./tools/comments.js";
|
|
13
13
|
import { registerTaskDependencyTools } from "./tools/task-dependencies.js";
|
|
14
|
+
import { registerWorkLogTools } from "./tools/work-logs.js";
|
|
15
|
+
import { registerNotificationTools } from "./tools/notifications.js";
|
|
16
|
+
import { registerAcceptanceCriterionTools } from "./tools/acceptance-criteria.js";
|
|
14
17
|
|
|
15
18
|
export function createServer(clientFactory?: () => ApiClients): McpServer {
|
|
16
19
|
const factory = clientFactory ?? getClients;
|
|
@@ -31,6 +34,9 @@ export function createServer(clientFactory?: () => ApiClients): McpServer {
|
|
|
31
34
|
registerLabelTools(server, factory);
|
|
32
35
|
registerCommentTools(server, factory);
|
|
33
36
|
registerTaskDependencyTools(server, factory);
|
|
37
|
+
registerWorkLogTools(server, factory);
|
|
38
|
+
registerNotificationTools(server, factory);
|
|
39
|
+
registerAcceptanceCriterionTools(server, factory);
|
|
34
40
|
|
|
35
41
|
return server;
|
|
36
42
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { ApiClients } from "../client/api.js";
|
|
4
|
+
import { formatError } from "../utils/format.js";
|
|
5
|
+
|
|
6
|
+
// Structured Acceptance Criterion entity — SECONDARY path.
|
|
7
|
+
//
|
|
8
|
+
// The PRIMARY way to record AC on an Opsee task is markdown inside the
|
|
9
|
+
// task description: a `## Acceptance criteria` heading followed by
|
|
10
|
+
// `- [ ]` / `- [x]` checkbox lines. BlockNote renders those as
|
|
11
|
+
// clickable checkbox blocks in the task detail UI. /tpm and any other
|
|
12
|
+
// AI agent should write AC via opsee_update_task's description field
|
|
13
|
+
// using that convention.
|
|
14
|
+
//
|
|
15
|
+
// These four tools operate on a SEPARATE DB entity (acceptance_criteria
|
|
16
|
+
// table) intended for automation paths that need machine-trackable AC
|
|
17
|
+
// outside the description — PR-to-criterion mapping, AI workflow
|
|
18
|
+
// outputs, reporting. They do NOT surface in the current task detail
|
|
19
|
+
// UI; do not use them as the default AC write path.
|
|
20
|
+
|
|
21
|
+
function formatCriterion(c: {
|
|
22
|
+
id: number;
|
|
23
|
+
text: string;
|
|
24
|
+
done: boolean;
|
|
25
|
+
displayOrder: number;
|
|
26
|
+
}): string {
|
|
27
|
+
const box = c.done ? "[x]" : "[ ]";
|
|
28
|
+
return `${box} ${c.text} (ID: ${c.id}, order: ${c.displayOrder})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerAcceptanceCriterionTools(
|
|
32
|
+
server: McpServer,
|
|
33
|
+
getClients: () => ApiClients,
|
|
34
|
+
): void {
|
|
35
|
+
server.tool(
|
|
36
|
+
"opsee_list_task_acceptance_criteria",
|
|
37
|
+
"Lists STRUCTURED acceptance criteria from a separate DB entity, NOT the checklist items inside the task description. Most tasks have their AC inline in the description as `- [ ]` markdown — to read those, fetch the task description via opsee_get_task and parse the checklist lines yourself. This tool only returns rows from the `acceptance_criteria` table, which is the automation/power-user path, not the main UI. Returns items ordered by display_order then id with text + done flag + display_order.",
|
|
38
|
+
{ taskId: z.number().describe("The task ID") },
|
|
39
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
40
|
+
async ({ taskId }) => {
|
|
41
|
+
try {
|
|
42
|
+
const clients = getClients();
|
|
43
|
+
const res = await clients.acceptanceCriteria.getAcceptanceCriteriaByTask({ taskId });
|
|
44
|
+
const items = res.criteria ?? [];
|
|
45
|
+
if (items.length === 0) {
|
|
46
|
+
return { content: [{ type: "text", text: `No acceptance criteria on task ${taskId}.` }] };
|
|
47
|
+
}
|
|
48
|
+
const lines = items.map((c: Parameters<typeof formatCriterion>[0], i: number) => `${i + 1}. ${formatCriterion(c)}`);
|
|
49
|
+
return { content: [{ type: "text", text: `Acceptance criteria for task ${taskId}:\n` + lines.join("\n") }] };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
server.tool(
|
|
57
|
+
"opsee_add_acceptance_criterion",
|
|
58
|
+
"POWER-USER PATH — writes a STRUCTURED criterion to the `acceptance_criteria` DB table, which does NOT surface in the task detail UI. For typical AC, use opsee_update_task and write `- [ ]` checkbox lines under a `## Acceptance criteria` heading in the task description instead — those render as clickable checkboxes via BlockNote. Use this tool only when you need machine-trackable AC for automation (e.g. mapping criteria to PRs, AI workflow outputs, reporting). display_order defaults to 0; pass an explicit value to control ordering.",
|
|
59
|
+
{
|
|
60
|
+
taskId: z.number().describe("The task ID"),
|
|
61
|
+
text: z.string().min(1).max(2000).describe("Criterion text (what passing this AC looks like)"),
|
|
62
|
+
displayOrder: z.number().int().optional().describe("Display order (default: 0)"),
|
|
63
|
+
},
|
|
64
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
65
|
+
async ({ taskId, text, displayOrder }) => {
|
|
66
|
+
try {
|
|
67
|
+
const clients = getClients();
|
|
68
|
+
const res = await clients.acceptanceCriteria.addAcceptanceCriterion({
|
|
69
|
+
taskId,
|
|
70
|
+
text,
|
|
71
|
+
displayOrder: displayOrder ?? 0,
|
|
72
|
+
});
|
|
73
|
+
if (!res.criterion) {
|
|
74
|
+
return { content: [{ type: "text", text: "Failed to add acceptance criterion. The backend returned an empty response." }], isError: true };
|
|
75
|
+
}
|
|
76
|
+
return { content: [{ type: "text", text: `Criterion added:\n${formatCriterion(res.criterion)}` }] };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
server.tool(
|
|
84
|
+
"opsee_update_acceptance_criterion",
|
|
85
|
+
"POWER-USER PATH — patches a STRUCTURED criterion in the `acceptance_criteria` DB table. Does NOT touch description-embedded checkboxes; those live in the task description and are edited via opsee_update_task's description field. Use this only for the automation path that created the criterion via opsee_add_acceptance_criterion. Patch semantics: any subset of text / done / displayOrder; fields not supplied are left alone.",
|
|
86
|
+
{
|
|
87
|
+
criterionId: z.number().describe("The criterion ID"),
|
|
88
|
+
text: z.string().max(2000).optional().describe("New text"),
|
|
89
|
+
done: z.boolean().optional().describe("Mark complete or incomplete"),
|
|
90
|
+
displayOrder: z.number().int().optional().describe("New display order"),
|
|
91
|
+
},
|
|
92
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
93
|
+
async ({ criterionId, text, done, displayOrder }) => {
|
|
94
|
+
try {
|
|
95
|
+
const clients = getClients();
|
|
96
|
+
const res = await clients.acceptanceCriteria.editAcceptanceCriterion({
|
|
97
|
+
id: criterionId,
|
|
98
|
+
text,
|
|
99
|
+
done,
|
|
100
|
+
displayOrder,
|
|
101
|
+
});
|
|
102
|
+
if (!res.criterion) {
|
|
103
|
+
return { content: [{ type: "text", text: "Failed to update acceptance criterion. The backend returned an empty response." }], isError: true };
|
|
104
|
+
}
|
|
105
|
+
return { content: [{ type: "text", text: `Criterion updated:\n${formatCriterion(res.criterion)}` }] };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.tool(
|
|
113
|
+
"opsee_delete_acceptance_criterion",
|
|
114
|
+
"POWER-USER PATH — deletes a STRUCTURED criterion row from the `acceptance_criteria` DB table by ID. Does NOT remove `- [ ]` lines from a task description; for those, edit the description via opsee_update_task. Soft delete; this is permanent at the application layer.",
|
|
115
|
+
{ criterionId: z.number().describe("The criterion ID") },
|
|
116
|
+
{ readOnlyHint: false, destructiveHint: true },
|
|
117
|
+
async ({ criterionId }) => {
|
|
118
|
+
try {
|
|
119
|
+
const clients = getClients();
|
|
120
|
+
await clients.acceptanceCriteria.deleteAcceptanceCriterion({ id: criterionId });
|
|
121
|
+
return { content: [{ type: "text", text: "Acceptance criterion deleted." }] };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/tools/comments.ts
CHANGED
|
@@ -68,7 +68,7 @@ export function registerCommentTools(
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
if (!res.comment) {
|
|
71
|
-
return { content: [{ type: "text", text: "Failed to add 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
72
|
}
|
|
73
73
|
|
|
74
74
|
return { content: [{ type: "text", text: `Comment added:\n${formatComment(res.comment)}` }] };
|
|
@@ -78,6 +78,45 @@ export function registerCommentTools(
|
|
|
78
78
|
},
|
|
79
79
|
);
|
|
80
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
|
+
|
|
81
120
|
server.tool(
|
|
82
121
|
"opsee_delete_comment",
|
|
83
122
|
"Delete a comment by ID. This is permanent.",
|