@opsee/mcp-server 0.9.47 → 0.9.49

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 CHANGED
@@ -41,15 +41,10 @@ Ask Claude things like:
41
41
  | `opsee_list_projects` | List all projects |
42
42
  | `opsee_get_project` | Get project details |
43
43
  | `opsee_list_tasks` | List tasks with filters |
44
- | `opsee_get_task` | Get task details |
45
- | `opsee_get_task_with_context` | Get task plus labels, comments, dependencies, parent, subtasks in one call |
44
+ | `opsee_get_task` | Get task details; `includeContext: true` adds labels, comments, dependencies, parent, subtasks in the same call |
46
45
  | `opsee_create_task` | Create a new task (accepts inline labelIds[] to skip follow-up attach calls) |
47
46
  | `opsee_update_task` | Update task fields (accepts labelIds[] with replace-set semantics) |
48
- | `opsee_bulk_update_tasks` | Update many tasks in one transaction (storyPoints, estimatedHours, parentTaskId, labelIds all supported) |
49
- | `opsee_bulk_move_to_cycle` | Move many tasks to the same cycle in one call |
50
- | `opsee_bulk_move_to_column` | Move many tasks to the same status column in one call |
51
- | `opsee_bulk_assign` | Assign many tasks to the same user in one call |
52
- | `opsee_bulk_set_priority` | Set the same priority on many tasks in one call |
47
+ | `opsee_bulk_update_tasks` | Update many tasks in one transaction — assignee, priority, column, cycle, storyPoints, estimatedHours, parentTaskId, labelIds |
53
48
  | `opsee_replace_task_labels` | Replace a single task's full set of labels |
54
49
  | `opsee_delete_task` | Delete a task by ID |
55
50
  | `opsee_bulk_delete_tasks` | Delete many tasks in one call |
@@ -93,11 +88,9 @@ Ask Claude things like:
93
88
  | `opsee_update_doc_template` | Update a doc template |
94
89
  | `opsee_delete_doc_template` | Delete a doc template |
95
90
  | `opsee_list_task_doc_pages` | List doc pages attached to a task |
96
- | `opsee_attach_doc_page_to_task` | Attach a doc page to a task |
97
- | `opsee_detach_doc_page_from_task` | Detach a doc page from a task |
98
91
  | `opsee_list_milestone_doc_pages` | List doc pages attached to a milestone |
99
- | `opsee_attach_doc_page_to_milestone` | Attach a doc page to a milestone |
100
- | `opsee_detach_doc_page_from_milestone` | Detach a doc page from a milestone |
92
+ | `opsee_attach_doc_page` | Attach a doc page to a task or a milestone |
93
+ | `opsee_detach_doc_page` | Detach a doc page from a task or a milestone |
101
94
  | `opsee_list_repositories` | List connected repositories |
102
95
 
103
96
  ## Task description format
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.47",
3
+ "version": "0.9.49",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
package/src/server.ts CHANGED
@@ -18,6 +18,7 @@ import { registerInitiativeTools } from "./tools/initiatives.js";
18
18
  import { registerDocSpaceWriteTools } from "./tools/doc-spaces.js";
19
19
  import { registerDocTemplateTools } from "./tools/doc-templates.js";
20
20
  import { registerDocLinkTools } from "./tools/doc-links.js";
21
+ import { compactToolDefinitions } from "./utils/tool-schema.js";
21
22
 
22
23
  export function createServer(clientFactory?: () => ApiClients): McpServer {
23
24
  const factory = clientFactory ?? getClients;
@@ -46,5 +47,7 @@ export function createServer(clientFactory?: () => ApiClients): McpServer {
46
47
  registerDocTemplateTools(server, factory);
47
48
  registerDocLinkTools(server, factory);
48
49
 
50
+ compactToolDefinitions(server);
51
+
49
52
  return server;
50
53
  }
@@ -13,6 +13,31 @@ function toDocPages<T extends { docPage?: DocPage }>(rows: T[]): DocPage[] {
13
13
  return rows.map((r) => r.docPage).filter((p): p is DocPage => !!p);
14
14
  }
15
15
 
16
+ type LinkTarget =
17
+ | { kind: "task" | "milestone"; id: number }
18
+ | { error: { content: [{ type: "text"; text: string }]; isError: true } };
19
+
20
+ // OPS-203: attach/detach used to be four tools (task and milestone variants of
21
+ // each). They are now one pair with the target passed as a parameter, so the
22
+ // exactly-one-of rule has to be enforced here rather than by the tool split.
23
+ function resolveTarget(taskId?: number, milestoneId?: number): LinkTarget {
24
+ if ((taskId === undefined) === (milestoneId === undefined)) {
25
+ return {
26
+ error: {
27
+ content: [{ type: "text", text: "Pass exactly one of taskId or milestoneId." }],
28
+ isError: true,
29
+ },
30
+ };
31
+ }
32
+ return taskId !== undefined
33
+ ? { kind: "task", id: taskId }
34
+ : { kind: "milestone", id: milestoneId as number };
35
+ }
36
+
37
+ function attachedText(docPage?: DocPage): string {
38
+ return docPage ? `Doc page attached:\n${formatDocPage(docPage)}` : "Doc page attached.";
39
+ }
40
+
16
41
  export function registerDocLinkTools(
17
42
  server: McpServer,
18
43
  getClients: () => ApiClients,
@@ -37,20 +62,26 @@ export function registerDocLinkTools(
37
62
  );
38
63
 
39
64
  server.tool(
40
- "opsee_attach_doc_page_to_task",
41
- "Attach an existing doc page to a task.",
65
+ "opsee_attach_doc_page",
66
+ "Attach an existing doc page to a task or a milestone. Pass exactly one of taskId / milestoneId.",
42
67
  {
43
- taskId: z.number().int().min(1).describe("The task ID"),
44
68
  docPageId: z.number().int().min(1).describe("The doc page ID to attach"),
69
+ taskId: z.number().int().min(1).optional().describe("Attach to this task"),
70
+ milestoneId: z.number().int().min(1).optional().describe("Attach to this milestone"),
45
71
  },
46
72
  { readOnlyHint: false, destructiveHint: false },
47
- async ({ taskId, docPageId }) => {
73
+ async ({ docPageId, taskId, milestoneId }) => {
74
+ const target = resolveTarget(taskId, milestoneId);
75
+ if ("error" in target) return target.error;
76
+
48
77
  try {
49
78
  const clients = getClients();
50
- const res = await clients.taskDocPages.addTaskDocPage({ taskId, docPageId });
51
- if (!res.taskDocPage?.docPage)
52
- return { content: [{ type: "text", text: "Doc page attached." }] };
53
- return { content: [{ type: "text", text: `Doc page attached:\n${formatDocPage(res.taskDocPage.docPage)}` }] };
79
+ if (target.kind === "task") {
80
+ const res = await clients.taskDocPages.addTaskDocPage({ taskId: target.id, docPageId });
81
+ return { content: [{ type: "text", text: attachedText(res.taskDocPage?.docPage) }] };
82
+ }
83
+ const res = await clients.milestoneDocPages.addMilestoneDocPage({ milestoneId: target.id, docPageId });
84
+ return { content: [{ type: "text", text: attachedText(res.milestoneDocPage?.docPage) }] };
54
85
  } catch (error) {
55
86
  return { content: [{ type: "text", text: formatError(error) }], isError: true };
56
87
  }
@@ -58,32 +89,45 @@ export function registerDocLinkTools(
58
89
  );
59
90
 
60
91
  server.tool(
61
- "opsee_detach_doc_page_from_task",
62
- "Detach a doc page from a task. The DeleteTaskDocPage RPC takes the join-row id, which callers never have — this tool resolves it by listing the task's doc pages and matching on docPageId.",
92
+ "opsee_detach_doc_page",
93
+ "Detach a doc page from a task or a milestone. Pass exactly one of taskId / milestoneId. The delete RPCs take the join-row id, which callers never have — this tool resolves it by listing the links and matching on docPageId.",
63
94
  {
64
- taskId: z.number().int().min(1).describe("The task ID"),
65
95
  docPageId: z.number().int().min(1).describe("The doc page ID to detach"),
96
+ taskId: z.number().int().min(1).optional().describe("Detach from this task"),
97
+ milestoneId: z.number().int().min(1).optional().describe("Detach from this milestone"),
66
98
  },
67
99
  { readOnlyHint: false, destructiveHint: false },
68
- async ({ taskId, docPageId }) => {
100
+ async ({ docPageId, taskId, milestoneId }) => {
101
+ const target = resolveTarget(taskId, milestoneId);
102
+ if ("error" in target) return target.error;
103
+
104
+ const notAttached = {
105
+ content: [{
106
+ type: "text" as const,
107
+ text: `Doc page ${docPageId} is not attached to ${target.kind} ${target.id}.`,
108
+ }],
109
+ };
110
+
69
111
  try {
70
112
  const clients = getClients();
71
- const listRes = await clients.taskDocPages.getTaskDocPages({
72
- taskId,
73
- pagination: overridePagination(),
74
- });
75
- const row = listRes.taskDocPages.find((r) => r.docPageId === docPageId);
76
- if (!row)
77
- return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to task ${taskId}.` }] };
113
+ const rowId = target.kind === "task"
114
+ ? (await clients.taskDocPages.getTaskDocPages({ taskId: target.id, pagination: overridePagination() }))
115
+ .taskDocPages.find((r) => r.docPageId === docPageId)?.id
116
+ : (await clients.milestoneDocPages.getMilestoneDocPages({ milestoneId: target.id, pagination: overridePagination() }))
117
+ .milestoneDocPages.find((r) => r.docPageId === docPageId)?.id;
118
+ if (rowId === undefined) return notAttached;
78
119
 
79
120
  try {
80
- await clients.taskDocPages.deleteTaskDocPage({ id: row.id });
121
+ if (target.kind === "task") {
122
+ await clients.taskDocPages.deleteTaskDocPage({ id: rowId });
123
+ } else {
124
+ await clients.milestoneDocPages.deleteMilestoneDocPage({ id: rowId });
125
+ }
81
126
  } catch (deleteError) {
82
127
  // A concurrent detach can remove the row between the list above and
83
128
  // this delete. The caller's intent (link gone) is already
84
129
  // satisfied, so report the same outcome instead of a raw NotFound.
85
- if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound)
86
- return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to task ${taskId}.` }] };
130
+ if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound) return notAttached;
87
131
  throw deleteError;
88
132
  }
89
133
  return { content: [{ type: "text", text: "Doc page detached." }] };
@@ -112,59 +156,4 @@ export function registerDocLinkTools(
112
156
  },
113
157
  );
114
158
 
115
- server.tool(
116
- "opsee_attach_doc_page_to_milestone",
117
- "Attach an existing doc page to a milestone.",
118
- {
119
- milestoneId: z.number().int().min(1).describe("The milestone ID"),
120
- docPageId: z.number().int().min(1).describe("The doc page ID to attach"),
121
- },
122
- { readOnlyHint: false, destructiveHint: false },
123
- async ({ milestoneId, docPageId }) => {
124
- try {
125
- const clients = getClients();
126
- const res = await clients.milestoneDocPages.addMilestoneDocPage({ milestoneId, docPageId });
127
- if (!res.milestoneDocPage?.docPage)
128
- return { content: [{ type: "text", text: "Doc page attached." }] };
129
- return { content: [{ type: "text", text: `Doc page attached:\n${formatDocPage(res.milestoneDocPage.docPage)}` }] };
130
- } catch (error) {
131
- return { content: [{ type: "text", text: formatError(error) }], isError: true };
132
- }
133
- },
134
- );
135
-
136
- server.tool(
137
- "opsee_detach_doc_page_from_milestone",
138
- "Detach a doc page from a milestone. The DeleteMilestoneDocPage RPC takes the join-row id, which callers never have — this tool resolves it by listing the milestone's doc pages and matching on docPageId.",
139
- {
140
- milestoneId: z.number().int().min(1).describe("The milestone ID"),
141
- docPageId: z.number().int().min(1).describe("The doc page ID to detach"),
142
- },
143
- { readOnlyHint: false, destructiveHint: false },
144
- async ({ milestoneId, docPageId }) => {
145
- try {
146
- const clients = getClients();
147
- const listRes = await clients.milestoneDocPages.getMilestoneDocPages({
148
- milestoneId,
149
- pagination: overridePagination(),
150
- });
151
- const row = listRes.milestoneDocPages.find((r) => r.docPageId === docPageId);
152
- if (!row)
153
- return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to milestone ${milestoneId}.` }] };
154
-
155
- try {
156
- await clients.milestoneDocPages.deleteMilestoneDocPage({ id: row.id });
157
- } catch (deleteError) {
158
- // Same TOCTOU as the task variant above: a concurrent detach can
159
- // remove the row between the list and this delete.
160
- if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound)
161
- return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to milestone ${milestoneId}.` }] };
162
- throw deleteError;
163
- }
164
- return { content: [{ type: "text", text: "Doc page detached." }] };
165
- } catch (error) {
166
- return { content: [{ type: "text", text: formatError(error) }], isError: true };
167
- }
168
- },
169
- );
170
159
  }
package/src/tools/docs.ts CHANGED
@@ -162,7 +162,7 @@ export function registerDocTools(
162
162
 
163
163
  server.tool(
164
164
  "opsee_update_doc_page",
165
- "Update a documentation page's title, content, or contentType. Performs a read-merge-write: any field you don't pass is preserved from the existing page (EditDocPageRequest has no field mask, so an incomplete request would blank the rest). No optimistic lock -- a concurrent edit from the web UI can be silently lost. Cannot clear content to an empty string: the backend's struct-based update silently skips empty-string fields, so that call is rejected up front instead of reporting a false success. Does not take a display-order argument -- ordering is owned by opsee_move_doc_page (MoveDocPage), a separate endpoint. By default, changing the title or content also snapshots the prior version into page history -- pass snapshotVersion: false to skip that.",
165
+ "Update a documentation page's title, content, or contentType. Read-merge-write: fields you don't pass are preserved. No optimistic lock -- a concurrent edit from the web UI can be silently lost. Content cannot be cleared to an empty string; that call is rejected up front rather than reporting a false success. Ordering is not settable here -- it belongs to opsee_move_doc_page. Changing title or content snapshots the prior version into page history unless snapshotVersion: false.",
166
166
  {
167
167
  pageId: z.number().int().min(1).describe("The doc page ID"),
168
168
  title: z.string().optional().describe("New title. When provided, the slug is regenerated from it."),
@@ -284,7 +284,7 @@ export function registerDocTools(
284
284
 
285
285
  server.tool(
286
286
  "opsee_delete_doc_page",
287
- "Delete a documentation page. This is a soft delete with no cascade: child pages are neither deleted nor reparented, so they keep their parent_page_id pointing at the now-deleted page. They still exist and are returned by opsee_list_doc_pages / opsee_get_doc_page, but they disappear from the page tree since their parent no longer resolves there. There is no undo exposed through this API. Requires the Admin or Owner role -- the User role can add/edit/read doc pages but not delete them, and this call fails with a permission error for a User-role caller. To hide a page reversibly instead, use opsee_set_doc_page_archived.",
287
+ "Delete a documentation page. Soft delete with no cascade: child pages are neither deleted nor reparented, so they keep pointing at the deleted parent -- still listable, but gone from the page tree. No undo through this API. Requires Admin or Owner; a User-role caller gets a permission error. To hide a page reversibly instead, use opsee_set_doc_page_archived.",
288
288
  { pageId: z.number().int().min(1).describe("The doc page ID") },
289
289
  { readOnlyHint: false, destructiveHint: true },
290
290
  async ({ pageId }) => {
@@ -214,7 +214,7 @@ export function registerInitiativeTools(
214
214
 
215
215
  server.tool(
216
216
  "opsee_decompose_initiative",
217
- "Materialize a proposed breakdown as real Tasks under the Initiative. PROPOSE FIRST: present the proposed task tree (titles, structure, dependencies) to the human and get their approval BEFORE calling this — that approval is the \"confirm\" step. Nothing is created until you call it, so to let the human decline, simply don't call it. Calling it materializes the tasks onto the board in one atomic operation and moves a draft Initiative to active (the conclude→execute transition). 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.",
217
+ "Materialize a proposed breakdown as real Tasks under the Initiative. PROPOSE FIRST: present the task tree (titles, structure, dependencies) to the human and get approval BEFORE calling — that approval is the confirm step, and not calling is how the human declines. One atomic operation; also moves a draft Initiative to active. Wire the tree with temporary `ref` ids: `parentRef` nests a subtask, `edges` (from_ref blocks to_ref) express sequencing, and tasks with no blocking edge run in parallel. All-or-nothing: a bad ref rolls the whole thing back.",
218
218
  {
219
219
  initiativeId: z.number().int().min(1).describe("The initiative ID"),
220
220
  tasks: z.array(z.object({
@@ -270,21 +270,16 @@ export function registerInitiativeTools(
270
270
  server.tool(
271
271
  "opsee_reconcile_initiative",
272
272
  [
273
- "Reconcile a proposed task tree against the current Initiative — diff, flag, and optionally apply.",
273
+ "Diff a proposed task tree against an Initiative that already has tasks, and optionally apply it.",
274
+ "For first-time task creation on a fresh Initiative use opsee_decompose_initiative instead.",
274
275
  "",
275
- "Use this when the Initiative already has tasks (from opsee_decompose_initiative) and the plan has changed.",
276
- "For first-time task creation on a fresh Initiative, use opsee_decompose_initiative instead.",
277
- "",
278
- "Workflow (follow this every time):",
279
- "1. Call opsee_get_initiative_context to load the current task tree. Note each task's ID.",
280
- "2. Build your proposed task list. For tasks that already exist and should be kept, set existingTaskId",
281
- " to the task's real ID (from the context). Omit existingTaskId for brand-new tasks.",
282
- "3. Call opsee_reconcile_initiative with dryRun:true to preview the changeset.",
283
- "4. Present the changeset to the human — ESPECIALLY the flagged items. Flagged tasks carry real",
284
- " execution state (linked PRs, worklogs, in-progress status) and will NOT be deleted. They stay",
285
- " on the board unchanged; the human must decide what to do with them.",
286
- "5. Get explicit human approval before applying.",
287
- "6. Call opsee_reconcile_initiative with dryRun:false to apply the approved plan.",
276
+ "Workflow (follow every time):",
277
+ "1. opsee_get_initiative_context to load the current tree; note each task's ID.",
278
+ "2. Build the proposed list. Set existingTaskId to a real ID to keep that task; omit it for new tasks.",
279
+ "3. Call with dryRun:true to preview the changeset.",
280
+ "4. Present it to the human, ESPECIALLY flagged items: those carry execution state (PRs, worklogs,",
281
+ " in-progress) and are never deleted — they stay on the board and the human decides their fate.",
282
+ "5. Only after explicit approval, call again with dryRun:false.",
288
283
  ].join("\n"),
289
284
  {
290
285
  initiativeId: z.number().int().min(1).describe("The initiative ID"),
@@ -4,16 +4,21 @@ import type { ApiClients } from "../client/api.js";
4
4
  import { formatError } from "../utils/format.js";
5
5
  import { BULK_PROJECT_ID } from "./descriptions.js";
6
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.
7
+ // Multi-task edits. These go through the backend BulkEditTasks RPC, which
8
+ // applies every change in one transaction and silently skips task ids the
9
+ // caller cannot see - hence the "n of m" wording in the response.
10
+ //
11
+ // OPS-203: the per-field convenience wrappers (bulk_assign, bulk_set_priority,
12
+ // bulk_move_to_column, bulk_move_to_cycle) were removed. Each was a strict
13
+ // subset of opsee_bulk_update_tasks with a single field set, and four extra
14
+ // tool definitions cost more context on every request than they saved.
10
15
  export function registerTaskBulkTools(
11
16
  server: McpServer,
12
17
  getClients: () => ApiClients,
13
18
  ): void {
14
19
  server.tool(
15
20
  "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.",
21
+ "Apply the same field changes to many tasks at once — reassign, reprioritize, move to a column or cycle, or any combination. 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
22
  {
18
23
  projectId: z.number().int().min(1).describe(BULK_PROJECT_ID),
19
24
  taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to update (at least one)"),
@@ -60,112 +65,4 @@ export function registerTaskBulkTools(
60
65
  }
61
66
  },
62
67
  );
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
68
  }
@@ -114,27 +114,13 @@ export function registerTaskReadWriteTools(
114
114
 
115
115
  server.tool(
116
116
  "opsee_get_task",
117
- "Get full details of a specific task by ID.",
118
- { taskId: z.number().int().min(1).describe("The task ID") },
119
- { readOnlyHint: true, destructiveHint: false },
120
- async ({ taskId }) => {
121
- try {
122
- const clients = getClients();
123
- const res = await clients.tasks.getTask({ id: taskId });
124
- if (!res.task) return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
125
- return { content: [{ type: "text", text: formatTask(res.task, { full: true }) }] };
126
- } catch (error) {
127
- return { content: [{ type: "text", text: formatError(error) }], isError: true };
128
- }
117
+ "Get full details of a specific task by ID. Pass includeContext=true when picking up a ticket to also get its labels, comments, dependencies (both directions), parent task, and subtasks in the same call — saves ~5 round-trips.",
118
+ {
119
+ taskId: z.number().int().min(1).describe("The task ID"),
120
+ includeContext: z.boolean().optional().describe("Also return labels, comments, dependencies, parent task, and subtasks (default false)"),
129
121
  },
130
- );
131
-
132
- server.tool(
133
- "opsee_get_task_with_context",
134
- "One-shot composer that returns a task plus all its related context: labels, comments, dependencies (both directions), parent task, and subtasks. Use this when picking up a ticket — saves ~5 round-trips compared to calling each tool separately.",
135
- { taskId: z.number().int().min(1).describe("The task ID") },
136
122
  { readOnlyHint: true, destructiveHint: false },
137
- async ({ taskId }) => {
123
+ async ({ taskId, includeContext }) => {
138
124
  try {
139
125
  const clients = getClients();
140
126
 
@@ -144,6 +130,10 @@ export function registerTaskReadWriteTools(
144
130
  return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
145
131
  }
146
132
 
133
+ if (!includeContext) {
134
+ return { content: [{ type: "text", text: formatTask(task, { full: true }) }] };
135
+ }
136
+
147
137
  const [labelsRes, depsRes, commentsRes, subtasksRes, parentRes] = await Promise.all([
148
138
  clients.taskLabels.getTaskLabels({
149
139
  taskId,
@@ -0,0 +1,90 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
+
4
+ // Every tool definition is re-sent on every AI request, so the tools/list
5
+ // payload is a permanent tax on the context window. Three constructs the SDK
6
+ // emits carry no information for a caller:
7
+ //
8
+ // "$schema": "http://json-schema.org/draft-07/schema#" (once per tool)
9
+ // "maximum": 9007199254740991 (per unbounded int)
10
+ // "minimum": -9007199254740991 (per unbounded int)
11
+ // "execution": {"taskSupport": "forbidden"} (once per tool)
12
+ //
13
+ // The bounds are Number.MAX/MIN_SAFE_INTEGER, which `z.number().int()` records
14
+ // on any int field that doesn't declare its own; no caller is going to pass an
15
+ // id near 2^53, and the server-side Zod schema still rejects one if they do.
16
+ // Stripping them leaves the advertised schema a valid, strictly weaker JSON
17
+ // Schema — an explicit bound like `.min(1)` is left alone.
18
+ //
19
+ // `execution.taskSupport` is optional in the protocol and clients only act on
20
+ // "required"/"optional" (see the SDK client's callTool path) — an absent field
21
+ // and an explicit "forbidden" are the same thing to them.
22
+ function stripSafeIntBounds(node: unknown): void {
23
+ if (Array.isArray(node)) {
24
+ for (const child of node) stripSafeIntBounds(child);
25
+ return;
26
+ }
27
+ if (typeof node !== "object" || node === null) return;
28
+
29
+ const obj = node as Record<string, unknown>;
30
+ if (obj.maximum === Number.MAX_SAFE_INTEGER) delete obj.maximum;
31
+ if (obj.minimum === Number.MIN_SAFE_INTEGER) delete obj.minimum;
32
+ for (const value of Object.values(obj)) stripSafeIntBounds(value);
33
+ }
34
+
35
+ function compactInputSchema(schema: unknown): unknown {
36
+ if (typeof schema !== "object" || schema === null) return schema;
37
+ const copy = structuredClone(schema) as Record<string, unknown>;
38
+ delete copy.$schema;
39
+ stripSafeIntBounds(copy);
40
+ return copy;
41
+ }
42
+
43
+ type ToolDefinition = {
44
+ inputSchema?: unknown;
45
+ execution?: { taskSupport?: string };
46
+ };
47
+
48
+ function compactTool(tool: ToolDefinition): void {
49
+ tool.inputSchema = compactInputSchema(tool.inputSchema);
50
+
51
+ const keys = Object.keys(tool.execution ?? {});
52
+ if (tool.execution?.taskSupport === "forbidden" && keys.length === 1) {
53
+ delete tool.execution;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Wrap the SDK's tools/list handler so every advertised tool definition is
59
+ * compacted on the way out. The SDK builds the JSON Schema internally (tools
60
+ * are registered as Zod shapes and converted inside its list handler), so
61
+ * post-processing the response is the only place this can happen.
62
+ *
63
+ * Degrades to a no-op if the SDK ever stops storing handlers where we look for
64
+ * them — the definitions go back to being verbose, nothing breaks. The test in
65
+ * tool-schema.test.ts asserts the compaction is live so that degradation is
66
+ * caught in CI rather than in production token bills.
67
+ */
68
+ export function compactToolDefinitions(server: McpServer): void {
69
+ const handlers = (
70
+ server.server as unknown as {
71
+ _requestHandlers?: Map<
72
+ string,
73
+ (request: unknown, extra: unknown) => Promise<unknown>
74
+ >;
75
+ }
76
+ )._requestHandlers;
77
+ const original = handlers?.get("tools/list");
78
+ if (!original) return;
79
+
80
+ server.server.setRequestHandler(
81
+ ListToolsRequestSchema,
82
+ async (request, extra) => {
83
+ const result = (await original(request, extra)) as {
84
+ tools?: ToolDefinition[];
85
+ };
86
+ for (const tool of result.tools ?? []) compactTool(tool);
87
+ return result;
88
+ },
89
+ );
90
+ }