@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 +4 -11
- package/package.json +1 -1
- package/src/server.ts +3 -0
- package/src/tools/doc-links.ts +66 -77
- package/src/tools/docs.ts +2 -2
- package/src/tools/initiatives.ts +10 -15
- package/src/tools/tasks-bulk.ts +9 -112
- package/src/tools/tasks-read-write.ts +9 -19
- package/src/utils/tool-schema.ts +90 -0
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
|
|
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
|
-
| `
|
|
100
|
-
| `
|
|
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
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
|
}
|
package/src/tools/doc-links.ts
CHANGED
|
@@ -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
|
-
"
|
|
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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
return { content: [{ type: "text", text:
|
|
53
|
-
|
|
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
|
-
"
|
|
62
|
-
"Detach a doc page from a task. The
|
|
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,
|
|
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
|
|
72
|
-
taskId,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 }) => {
|
package/src/tools/initiatives.ts
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
276
|
-
"
|
|
277
|
-
"",
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
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"),
|
package/src/tools/tasks-bulk.ts
CHANGED
|
@@ -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.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
"
|
|
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
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|