@opsee/mcp-server 0.9.31 → 0.9.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { blockNoteToMarkdown } from "../utils/format.js";
6
+
7
+ // The BlockNote <-> markdown mapping is implemented three times against no
8
+ // shared contract: this reader, orchestrator/internal/util/blocknote.go, and
9
+ // the frontend BlockEditor via @blocknote/core. testdata/blocknote is the
10
+ // language-neutral fixture set all three should eventually assert against;
11
+ // this suite pins the TypeScript side of it.
12
+ const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../testdata/blocknote");
13
+
14
+ interface Fixture {
15
+ name: string;
16
+ description: string;
17
+ /** Stored value when it is BlockNote JSON. Serialized before rendering. */
18
+ blocknote?: unknown[];
19
+ /** Stored value when it is not BlockNote JSON, passed through verbatim. */
20
+ input?: string;
21
+ markdown: string;
22
+ }
23
+
24
+ const fixtures = readdirSync(FIXTURE_DIR)
25
+ .filter((f) => f.endsWith(".json"))
26
+ .sort()
27
+ .map((f) => JSON.parse(readFileSync(join(FIXTURE_DIR, f), "utf8")) as Fixture);
28
+
29
+ describe("blockNoteToMarkdown golden fixtures", () => {
30
+ // Naming the cases rather than counting them: a suite whose whole job is to
31
+ // catch drift must fail, not vacuously pass, if the fixtures go missing.
32
+ test("covers every construct the task-description contract promises", () => {
33
+ expect(fixtures.map((f) => f.name).sort()).toEqual([
34
+ "block-separation",
35
+ "checklist",
36
+ "code-fence",
37
+ "empty-document",
38
+ "heading-level-clamp",
39
+ "headings",
40
+ "inline-styles",
41
+ "links",
42
+ "nested-bullets",
43
+ "passthrough-malformed-block",
44
+ "passthrough-non-array-json",
45
+ "passthrough-plain-text",
46
+ "task-description",
47
+ ]);
48
+ });
49
+
50
+ for (const fixture of fixtures) {
51
+ test(`${fixture.name}: ${fixture.description}`, () => {
52
+ const isBlockNote = fixture.blocknote !== undefined;
53
+ expect(isBlockNote || typeof fixture.input === "string").toBe(true);
54
+ const stored = isBlockNote ? JSON.stringify(fixture.blocknote) : fixture.input!;
55
+ expect(blockNoteToMarkdown(stored)).toBe(fixture.markdown);
56
+ });
57
+ }
58
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Description strings shared by more than one tool or schema field.
3
+ *
4
+ * These are LLM-facing contract text: an agent picks a tool and fills its
5
+ * arguments from nothing but this wording. Copies that drift teach two
6
+ * different contracts for the same field, so anything repeated lives here.
7
+ */
8
+
9
+ /** Task/description authoring rules. Shared by create and update. */
10
+ export const TASK_DESCRIPTION_GUIDANCE =
11
+ "Markdown is supported — headings (##), bullets (-), code fences, inline **bold** / *italic* / `code` / links all render as proper formatted blocks in the UI. For ACCEPTANCE CRITERIA, write them inline in this description as a `## Acceptance criteria` heading followed by markdown checkbox lines: `- [ ] criterion text` and `- [x] done item`. The UI renders those as clickable checkboxes (BlockNote checkListItem) and users can toggle them. Do NOT use the structured opsee_add_acceptance_criterion tool for typical AC — it writes to a separate DB table that does not surface in the task detail UI; it's reserved for automation paths (PR-to-criterion mapping etc.). Written as-is to the backend; the frontend BlockEditor parses markdown on read and saves the parsed BlockNote JSON on the next edit, so the round-trip preserves structure.";
12
+
13
+ export const CREATE_TASK_DESCRIPTION = `Task description. ${TASK_DESCRIPTION_GUIDANCE}`;
14
+
15
+ export const UPDATE_TASK_DESCRIPTION = `New description. ${TASK_DESCRIPTION_GUIDANCE}`;
16
+
17
+ export const BULK_PROJECT_ID = "Project ID - all tasks must belong to this project";
18
+
19
+ export const STORY_POINTS_ESTIMATE = "Story points estimate (positive integer)";
20
+
21
+ export const INITIATIVE_TASK_REF = "Temporary reference ID used to wire edges and parent relationships";
22
+
23
+ export const INITIATIVE_TASK_PARENT_REF = "Ref of the parent task (to nest this as a subtask)";
24
+
25
+ export const INITIATIVE_EDGE_TYPE = "Dependency type: blocks | blocked_by | duplicates | relates_to";
@@ -12,6 +12,11 @@ import {
12
12
  formatReconcileChangeset,
13
13
  formatError,
14
14
  } from "../utils/format.js";
15
+ import {
16
+ INITIATIVE_EDGE_TYPE,
17
+ INITIATIVE_TASK_PARENT_REF,
18
+ INITIATIVE_TASK_REF,
19
+ } from "./descriptions.js";
15
20
 
16
21
  const INITIATIVE_STATUSES = ["draft", "active", "paused", "completed", "abandoned"] as const;
17
22
  const ANCHOR_TYPES = ["none", "task", "milestone", "cycle"] as const;
@@ -212,10 +217,10 @@ export function registerInitiativeTools(
212
217
  {
213
218
  initiativeId: z.number().int().min(1).describe("The initiative ID"),
214
219
  tasks: z.array(z.object({
215
- ref: z.string().describe("Temporary reference ID used to wire edges and parent relationships"),
220
+ ref: z.string().describe(INITIATIVE_TASK_REF),
216
221
  title: z.string().describe("Task title"),
217
222
  description: z.string().optional().describe("Task description"),
218
- parentRef: z.string().optional().describe("Ref of the parent task (to nest this as a subtask)"),
223
+ parentRef: z.string().optional().describe(INITIATIVE_TASK_PARENT_REF),
219
224
  taskTypeId: z.number().int().min(1).optional().describe("Task type ID"),
220
225
  taskPriorityId: z.number().int().min(1).optional().describe("Task priority ID"),
221
226
  boardColumnId: z.number().int().min(1).optional().describe("Board column ID (status)"),
@@ -223,7 +228,7 @@ export function registerInitiativeTools(
223
228
  edges: z.array(z.object({
224
229
  fromRef: z.string().describe("Ref of the source task"),
225
230
  toRef: z.string().describe("Ref of the target task"),
226
- type: z.enum(EDGE_TYPES).describe("Dependency type: blocks | blocked_by | duplicates | relates_to"),
231
+ type: z.enum(EDGE_TYPES).describe(INITIATIVE_EDGE_TYPE),
227
232
  })).describe("Dependency edges between tasks (by ref)"),
228
233
  },
229
234
  { readOnlyHint: false, destructiveHint: false },
@@ -283,10 +288,10 @@ export function registerInitiativeTools(
283
288
  {
284
289
  initiativeId: z.number().int().min(1).describe("The initiative ID"),
285
290
  tasks: z.array(z.object({
286
- ref: z.string().describe("Temporary reference ID used to wire edges and parent relationships"),
291
+ ref: z.string().describe(INITIATIVE_TASK_REF),
287
292
  title: z.string().describe("Task title"),
288
293
  description: z.string().optional().describe("Task description"),
289
- parentRef: z.string().optional().describe("Ref of the parent task (to nest this as a subtask)"),
294
+ parentRef: z.string().optional().describe(INITIATIVE_TASK_PARENT_REF),
290
295
  taskTypeId: z.number().int().min(1).optional().describe("Task type ID"),
291
296
  taskPriorityId: z.number().int().min(1).optional().describe("Task priority ID"),
292
297
  boardColumnId: z.number().int().min(1).optional().describe("Board column ID (status)"),
@@ -295,7 +300,7 @@ export function registerInitiativeTools(
295
300
  edges: z.array(z.object({
296
301
  fromRef: z.string().describe("Ref of the source task"),
297
302
  toRef: z.string().describe("Ref of the target task"),
298
- type: z.enum(EDGE_TYPES).describe("Dependency type: blocks | blocked_by | duplicates | relates_to"),
303
+ type: z.enum(EDGE_TYPES).describe(INITIATIVE_EDGE_TYPE),
299
304
  })).describe("Dependency edges between tasks (by ref)"),
300
305
  dryRun: z.boolean().optional().describe("When true, compute and return the changeset without writing anything. Always call with dryRun:true first to preview."),
301
306
  },
@@ -0,0 +1,171 @@
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
+ import { BULK_PROJECT_ID } from "./descriptions.js";
6
+
7
+ // Multi-task edits. All of these go through the backend BulkEditTasks RPC,
8
+ // which applies every change in one transaction and silently skips task ids
9
+ // the caller cannot see - hence the "n of m" wording in every response.
10
+ export function registerTaskBulkTools(
11
+ server: McpServer,
12
+ getClients: () => ApiClients,
13
+ ): void {
14
+ server.tool(
15
+ "opsee_bulk_update_tasks",
16
+ "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. labelIds with a non-empty list replaces each task's labels; empty/omitted leaves them alone.",
17
+ {
18
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
19
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to update (at least one)"),
20
+ boardColumnId: z.number().int().min(1).optional().describe("New board column ID (status)"),
21
+ taskPriorityId: z.number().int().min(1).optional().describe("New priority ID"),
22
+ taskTypeId: z.number().int().min(1).optional().describe("New task type ID"),
23
+ assignedUserId: z.number().int().min(1).optional().describe("New assigned user ID"),
24
+ cycleId: z.number().int().min(1).optional().describe("New cycle/sprint ID"),
25
+ milestoneId: z.number().int().min(1).optional().describe("Milestone ID to attach every task to. Bulk only supports set, not clear — use opsee_update_task for detaching a single task."),
26
+ storyPoints: z.number().int().min(1).optional().describe("Story points to set on every task"),
27
+ estimatedHours: z.number().min(0).optional().describe("Estimated hours to set on every task"),
28
+ parentTaskId: z.number().int().min(0).optional().describe("Parent task ID for every task (pass 0 to clear)"),
29
+ labelIds: z.array(z.number().int().min(1)).optional().describe("Replace label attachments on every task with this set. Empty / omitted leaves labels untouched."),
30
+ },
31
+ { readOnlyHint: false, destructiveHint: false },
32
+ async ({ projectId, taskIds, boardColumnId, taskPriorityId, taskTypeId, assignedUserId, cycleId, milestoneId, storyPoints, estimatedHours, parentTaskId, labelIds }) => {
33
+ try {
34
+ const clients = getClients();
35
+ const res = await clients.tasks.bulkEditTasks({
36
+ projectId,
37
+ taskIds,
38
+ boardColumnId,
39
+ taskPriorityId,
40
+ taskTypeId,
41
+ assignedUserId,
42
+ cycleId,
43
+ milestoneId,
44
+ storyPoints,
45
+ estimatedHours,
46
+ parentTaskId,
47
+ repositoryIds: [],
48
+ labelIds: labelIds ?? [],
49
+ });
50
+
51
+ const requested = taskIds.length;
52
+ const updated = res.updatedCount;
53
+ let text = `Updated ${updated} of ${requested} task(s).`;
54
+ if (updated < requested) {
55
+ text += " (Tasks not visible to your company or not found were skipped.)";
56
+ }
57
+ return { content: [{ type: "text", text }] };
58
+ } catch (error) {
59
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
60
+ }
61
+ },
62
+ );
63
+
64
+ server.tool(
65
+ "opsee_bulk_move_to_cycle",
66
+ "Convenience wrapper: move many tasks to the same cycle in one call. Equivalent to opsee_bulk_update_tasks with only cycleId set.",
67
+ {
68
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
69
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to move"),
70
+ cycleId: z.number().int().min(1).describe("Target cycle/sprint ID"),
71
+ },
72
+ { readOnlyHint: false, destructiveHint: false },
73
+ async ({ projectId, taskIds, cycleId }) => {
74
+ try {
75
+ const clients = getClients();
76
+ const res = await clients.tasks.bulkEditTasks({
77
+ projectId,
78
+ taskIds,
79
+ cycleId,
80
+ repositoryIds: [],
81
+ labelIds: [],
82
+ });
83
+ const text = `Moved ${res.updatedCount} of ${taskIds.length} task(s) to cycle ${cycleId}.`;
84
+ return { content: [{ type: "text", text }] };
85
+ } catch (error) {
86
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
87
+ }
88
+ },
89
+ );
90
+
91
+ server.tool(
92
+ "opsee_bulk_move_to_column",
93
+ "Convenience wrapper: move many tasks to the same board column (status) in one call.",
94
+ {
95
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
96
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to move"),
97
+ boardColumnId: z.number().int().min(1).describe("Target board column ID (status)"),
98
+ },
99
+ { readOnlyHint: false, destructiveHint: false },
100
+ async ({ projectId, taskIds, boardColumnId }) => {
101
+ try {
102
+ const clients = getClients();
103
+ const res = await clients.tasks.bulkEditTasks({
104
+ projectId,
105
+ taskIds,
106
+ boardColumnId,
107
+ repositoryIds: [],
108
+ labelIds: [],
109
+ });
110
+ const text = `Moved ${res.updatedCount} of ${taskIds.length} task(s) to column ${boardColumnId}.`;
111
+ return { content: [{ type: "text", text }] };
112
+ } catch (error) {
113
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
114
+ }
115
+ },
116
+ );
117
+
118
+ server.tool(
119
+ "opsee_bulk_assign",
120
+ "Convenience wrapper: assign many tasks to the same user in one call.",
121
+ {
122
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
123
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to reassign"),
124
+ assigneeId: z.number().int().min(1).describe("Target user ID"),
125
+ },
126
+ { readOnlyHint: false, destructiveHint: false },
127
+ async ({ projectId, taskIds, assigneeId }) => {
128
+ try {
129
+ const clients = getClients();
130
+ const res = await clients.tasks.bulkEditTasks({
131
+ projectId,
132
+ taskIds,
133
+ assignedUserId: assigneeId,
134
+ repositoryIds: [],
135
+ labelIds: [],
136
+ });
137
+ const text = `Assigned ${res.updatedCount} of ${taskIds.length} task(s) to user ${assigneeId}.`;
138
+ return { content: [{ type: "text", text }] };
139
+ } catch (error) {
140
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
141
+ }
142
+ },
143
+ );
144
+
145
+ server.tool(
146
+ "opsee_bulk_set_priority",
147
+ "Convenience wrapper: set the same priority on many tasks in one call.",
148
+ {
149
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
150
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to reprioritize"),
151
+ priorityId: z.number().int().min(1).describe("Target priority ID (from opsee_list_task_priorities)"),
152
+ },
153
+ { readOnlyHint: false, destructiveHint: false },
154
+ async ({ projectId, taskIds, priorityId }) => {
155
+ try {
156
+ const clients = getClients();
157
+ const res = await clients.tasks.bulkEditTasks({
158
+ projectId,
159
+ taskIds,
160
+ taskPriorityId: priorityId,
161
+ repositoryIds: [],
162
+ labelIds: [],
163
+ });
164
+ const text = `Set priority ${priorityId} on ${res.updatedCount} of ${taskIds.length} task(s).`;
165
+ return { content: [{ type: "text", text }] };
166
+ } catch (error) {
167
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
168
+ }
169
+ },
170
+ );
171
+ }
@@ -0,0 +1,52 @@
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
+ import { BULK_PROJECT_ID } from "./descriptions.js";
6
+
7
+ // Task deletion, single and bulk.
8
+ export function registerTaskDeleteTools(
9
+ server: McpServer,
10
+ getClients: () => ApiClients,
11
+ ): void {
12
+ server.tool(
13
+ "opsee_delete_task",
14
+ "Delete a task by ID. This is permanent.",
15
+ { taskId: z.number().int().min(1).describe("The task ID to delete") },
16
+ { readOnlyHint: false, destructiveHint: true },
17
+ async ({ taskId }) => {
18
+ try {
19
+ const clients = getClients();
20
+ await clients.tasks.deleteTask({ id: taskId });
21
+ return { content: [{ type: "text", text: "Task deleted." }] };
22
+ } catch (error) {
23
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
24
+ }
25
+ },
26
+ );
27
+
28
+ server.tool(
29
+ "opsee_bulk_delete_tasks",
30
+ "Delete multiple tasks in one call. Tasks not visible to your company or not found are silently skipped — compare deletedCount against taskIds.length to detect partial application. This is permanent.",
31
+ {
32
+ projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
33
+ taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to delete (at least one)"),
34
+ },
35
+ { readOnlyHint: false, destructiveHint: true },
36
+ async ({ projectId, taskIds }) => {
37
+ try {
38
+ const clients = getClients();
39
+ const res = await clients.tasks.bulkDeleteTasks({ projectId, taskIds });
40
+ const requested = taskIds.length;
41
+ const deleted = res.deletedCount;
42
+ let text = `Deleted ${deleted} of ${requested} task(s).`;
43
+ if (deleted < requested) {
44
+ text += " (Tasks not visible to your company or not found were skipped.)";
45
+ }
46
+ return { content: [{ type: "text", text }] };
47
+ } catch (error) {
48
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
49
+ }
50
+ },
51
+ );
52
+ }