@opsee/mcp-server 0.5.7 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -3
- 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 +163 -1
- package/gen/api/v1/models_pb.js +28 -2
- package/gen/api/v1/task_dependency_pb.d.ts +217 -0
- package/gen/api/v1/task_dependency_pb.js +74 -0
- package/gen/api/v1/task_pb.d.ts +54 -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 +18 -0
- package/src/server.ts +12 -0
- package/src/tools/acceptance-criteria.ts +127 -0
- package/src/tools/comments.ts +135 -0
- package/src/tools/cycles.ts +189 -1
- package/src/tools/docs.ts +1 -1
- package/src/tools/labels.ts +199 -0
- package/src/tools/milestones.ts +3 -3
- package/src/tools/notifications.ts +171 -0
- package/src/tools/task-dependencies.ts +138 -0
- package/src/tools/tasks.ts +364 -11
- package/src/tools/user.ts +41 -1
- package/src/tools/work-logs.ts +123 -0
- package/src/utils/format.ts +84 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import type { ApiClients } from "../client/api.js";
|
|
5
|
+
import { defaultPagination } from "../client/api.js";
|
|
6
|
+
import { formatError } from "../utils/format.js";
|
|
7
|
+
import {
|
|
8
|
+
FilterOptionsSchema,
|
|
9
|
+
FilterSchema,
|
|
10
|
+
} from "../../gen/api/v1/filter_pb.js";
|
|
11
|
+
|
|
12
|
+
// Notifications are user-scoped server-side (the GetNotifications RPC reads
|
|
13
|
+
// the user_id off the auth context), so there's no per-call user parameter.
|
|
14
|
+
// notification_type values are free-form strings produced by the backend
|
|
15
|
+
// (e.g. "task_assigned", "comment_added", "task_blocked_unblocked",
|
|
16
|
+
// "mention", "ai_workflow_status_change"). Treat them as opaque tags;
|
|
17
|
+
// callers can filter via the unreadOnly param or post-process by type.
|
|
18
|
+
|
|
19
|
+
function formatNotification(n: {
|
|
20
|
+
id: number;
|
|
21
|
+
title: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
notificationType: string;
|
|
24
|
+
entityType?: string;
|
|
25
|
+
entityId?: number;
|
|
26
|
+
actionUrl?: string;
|
|
27
|
+
isRead: boolean;
|
|
28
|
+
}): string {
|
|
29
|
+
const flag = n.isRead ? "" : " [unread]";
|
|
30
|
+
const target =
|
|
31
|
+
n.entityType && n.entityId ? ` → ${n.entityType} #${n.entityId}` : "";
|
|
32
|
+
const link = n.actionUrl ? `\n Link: ${n.actionUrl}` : "";
|
|
33
|
+
const msg = n.message ? `\n ${n.message}` : "";
|
|
34
|
+
return `${n.title} (${n.notificationType})${flag}${target}${msg}${link}\n ID: ${n.id}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerNotificationTools(
|
|
38
|
+
server: McpServer,
|
|
39
|
+
getClients: () => ApiClients,
|
|
40
|
+
): void {
|
|
41
|
+
server.tool(
|
|
42
|
+
"opsee_list_my_notifications",
|
|
43
|
+
"Inbox-style listing of the authenticated user's notifications, newest first. Common notification_type values: task_assigned, comment_added, mention, task_blocked_unblocked, ai_workflow_status_change. Pass unreadOnly:true to see just the inbox.",
|
|
44
|
+
{
|
|
45
|
+
unreadOnly: z.boolean().optional().describe("If true, only return notifications where is_read=false"),
|
|
46
|
+
page: z.number().optional().describe("Page number (default: 1)"),
|
|
47
|
+
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
48
|
+
},
|
|
49
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
50
|
+
async ({ unreadOnly, page, pageSize }) => {
|
|
51
|
+
try {
|
|
52
|
+
const clients = getClients();
|
|
53
|
+
const filterOptions = unreadOnly
|
|
54
|
+
? create(FilterOptionsSchema, {
|
|
55
|
+
filters: [create(FilterSchema, { key: "IsRead", value: "false" })],
|
|
56
|
+
})
|
|
57
|
+
: undefined;
|
|
58
|
+
const res = await clients.notifications.getNotifications({
|
|
59
|
+
pagination: defaultPagination(page || 1, pageSize || 50),
|
|
60
|
+
filterOptions,
|
|
61
|
+
});
|
|
62
|
+
const items = res.notifications ?? [];
|
|
63
|
+
if (items.length === 0) {
|
|
64
|
+
return { content: [{ type: "text", text: unreadOnly ? "Inbox zero." : "No notifications." }] };
|
|
65
|
+
}
|
|
66
|
+
const header = unreadOnly
|
|
67
|
+
? `${items.length} unread notification(s):`
|
|
68
|
+
: `${items.length} notification(s):`;
|
|
69
|
+
const body = items.map((n: Parameters<typeof formatNotification>[0], i: number) => `${i + 1}. ${formatNotification(n)}`).join("\n\n");
|
|
70
|
+
return { content: [{ type: "text", text: `${header}\n${body}` }] };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.tool(
|
|
78
|
+
"opsee_mark_notification_read",
|
|
79
|
+
"Mark a notification as read. Read-modify-write: fetches the current notification and re-issues EditNotification with is_read=true so other fields aren't clobbered.",
|
|
80
|
+
{
|
|
81
|
+
notificationId: z.number().describe("The notification ID"),
|
|
82
|
+
},
|
|
83
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
84
|
+
async ({ notificationId }) => {
|
|
85
|
+
try {
|
|
86
|
+
const clients = getClients();
|
|
87
|
+
const getRes = await clients.notifications.getNotification({ id: notificationId });
|
|
88
|
+
const existing = getRes.notification;
|
|
89
|
+
if (!existing) {
|
|
90
|
+
return { content: [{ type: "text", text: "Notification not found." }] };
|
|
91
|
+
}
|
|
92
|
+
if (existing.isRead) {
|
|
93
|
+
return { content: [{ type: "text", text: `Notification ${notificationId} was already read.` }] };
|
|
94
|
+
}
|
|
95
|
+
const editRes = await clients.notifications.editNotification({
|
|
96
|
+
id: existing.id,
|
|
97
|
+
title: existing.title,
|
|
98
|
+
message: existing.message,
|
|
99
|
+
notificationType: existing.notificationType,
|
|
100
|
+
entityType: existing.entityType,
|
|
101
|
+
entityId: existing.entityId,
|
|
102
|
+
actionUrl: existing.actionUrl,
|
|
103
|
+
isRead: true,
|
|
104
|
+
sentViaEmail: existing.sentViaEmail,
|
|
105
|
+
metadataJson: existing.metadataJson,
|
|
106
|
+
userId: existing.userId,
|
|
107
|
+
});
|
|
108
|
+
if (!editRes.notification) {
|
|
109
|
+
return { content: [{ type: "text", text: "Failed to mark notification read." }], isError: true };
|
|
110
|
+
}
|
|
111
|
+
return { content: [{ type: "text", text: `Notification ${notificationId} marked read.` }] };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
server.tool(
|
|
119
|
+
"opsee_mark_all_notifications_read",
|
|
120
|
+
"Loop through every unread notification and mark each read. Best-effort: per-notification failures are reported in the summary but don't abort the call. Caller can pass a maximum to bound the call.",
|
|
121
|
+
{
|
|
122
|
+
max: z.number().int().min(1).max(500).optional().describe("Maximum number of unread notifications to mark read in this call (default: 100, hard cap 500)"),
|
|
123
|
+
},
|
|
124
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
125
|
+
async ({ max }) => {
|
|
126
|
+
try {
|
|
127
|
+
const clients = getClients();
|
|
128
|
+
const limit = Math.min(max ?? 100, 500);
|
|
129
|
+
const listRes = await clients.notifications.getNotifications({
|
|
130
|
+
pagination: defaultPagination(1, limit),
|
|
131
|
+
filterOptions: create(FilterOptionsSchema, {
|
|
132
|
+
filters: [create(FilterSchema, { key: "IsRead", value: "false" })],
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
const items = listRes.notifications ?? [];
|
|
136
|
+
if (items.length === 0) {
|
|
137
|
+
return { content: [{ type: "text", text: "Inbox zero — nothing to mark read." }] };
|
|
138
|
+
}
|
|
139
|
+
let succeeded = 0;
|
|
140
|
+
const failed: number[] = [];
|
|
141
|
+
for (const n of items) {
|
|
142
|
+
try {
|
|
143
|
+
await clients.notifications.editNotification({
|
|
144
|
+
id: n.id,
|
|
145
|
+
title: n.title,
|
|
146
|
+
message: n.message,
|
|
147
|
+
notificationType: n.notificationType,
|
|
148
|
+
entityType: n.entityType,
|
|
149
|
+
entityId: n.entityId,
|
|
150
|
+
actionUrl: n.actionUrl,
|
|
151
|
+
isRead: true,
|
|
152
|
+
sentViaEmail: n.sentViaEmail,
|
|
153
|
+
metadataJson: n.metadataJson,
|
|
154
|
+
userId: n.userId,
|
|
155
|
+
});
|
|
156
|
+
succeeded++;
|
|
157
|
+
} catch {
|
|
158
|
+
failed.push(n.id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let text = `Marked ${succeeded} of ${items.length} notification(s) read.`;
|
|
162
|
+
if (failed.length > 0) {
|
|
163
|
+
text += ` Failed IDs: ${failed.join(", ")}.`;
|
|
164
|
+
}
|
|
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,138 @@
|
|
|
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 { defaultPagination } from "../client/api.js";
|
|
5
|
+
import {
|
|
6
|
+
formatTaskDependency,
|
|
7
|
+
formatTaskDependencyList,
|
|
8
|
+
formatError,
|
|
9
|
+
} from "../utils/format.js";
|
|
10
|
+
import { TaskDependencyType } from "../../gen/api/v1/models_pb.js";
|
|
11
|
+
|
|
12
|
+
const dependencyTypeSchema = z.enum(["BLOCKS", "BLOCKED_BY", "DUPLICATES", "RELATES_TO"]);
|
|
13
|
+
|
|
14
|
+
const typeMap: Record<string, TaskDependencyType> = {
|
|
15
|
+
BLOCKS: TaskDependencyType.BLOCKS,
|
|
16
|
+
BLOCKED_BY: TaskDependencyType.BLOCKED_BY,
|
|
17
|
+
DUPLICATES: TaskDependencyType.DUPLICATES,
|
|
18
|
+
RELATES_TO: TaskDependencyType.RELATES_TO,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function registerTaskDependencyTools(
|
|
22
|
+
server: McpServer,
|
|
23
|
+
getClients: () => ApiClients,
|
|
24
|
+
): void {
|
|
25
|
+
server.tool(
|
|
26
|
+
"opsee_create_task_dependency",
|
|
27
|
+
"Create a dependency relationship between two tasks. Use BLOCKS when the from-task must be completed before the to-task. Use BLOCKED_BY for the inverse. Use DUPLICATES when tasks are duplicates. Use RELATES_TO for a general association.",
|
|
28
|
+
{
|
|
29
|
+
fromTaskId: z.number().describe("The ID of the source task"),
|
|
30
|
+
toTaskId: z.number().describe("The ID of the target task"),
|
|
31
|
+
type: dependencyTypeSchema.describe("Dependency type: BLOCKS, BLOCKED_BY, DUPLICATES, or RELATES_TO"),
|
|
32
|
+
},
|
|
33
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
34
|
+
async ({ fromTaskId, toTaskId, type }) => {
|
|
35
|
+
try {
|
|
36
|
+
const clients = getClients();
|
|
37
|
+
const res = await clients.taskDependencies.addTaskDependency({
|
|
38
|
+
fromTaskId,
|
|
39
|
+
toTaskId,
|
|
40
|
+
type: typeMap[type],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!res.taskDependency) {
|
|
44
|
+
return { content: [{ type: "text", text: "Failed to create task dependency. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: `Task dependency created:\n${formatTaskDependency(res.taskDependency)}` }],
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
server.tool(
|
|
57
|
+
"opsee_list_task_dependencies",
|
|
58
|
+
"List all dependencies for a task — returns both outgoing (task blocks others) and incoming (task is blocked by others) relationships.",
|
|
59
|
+
{
|
|
60
|
+
taskId: z.number().describe("The task ID"),
|
|
61
|
+
page: z.number().optional().describe("Page number (default: 1)"),
|
|
62
|
+
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
63
|
+
},
|
|
64
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
65
|
+
async ({ taskId, page, pageSize }) => {
|
|
66
|
+
try {
|
|
67
|
+
const clients = getClients();
|
|
68
|
+
const res = await clients.taskDependencies.getTaskDependencies({
|
|
69
|
+
taskId,
|
|
70
|
+
pagination: defaultPagination(page || 1, pageSize || 50),
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: formatTaskDependencyList(res.taskDependencies) }],
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
server.tool(
|
|
82
|
+
"opsee_delete_task_dependency",
|
|
83
|
+
"Delete a task dependency by its ID. This is permanent.",
|
|
84
|
+
{ dependencyId: z.number().describe("The task dependency ID") },
|
|
85
|
+
{ readOnlyHint: false, destructiveHint: true },
|
|
86
|
+
async ({ dependencyId }) => {
|
|
87
|
+
try {
|
|
88
|
+
const clients = getClients();
|
|
89
|
+
await clients.taskDependencies.deleteTaskDependency({ id: dependencyId });
|
|
90
|
+
return { content: [{ type: "text", text: "Task dependency deleted." }] };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"opsee_get_dependency_chain",
|
|
99
|
+
"Server-side BFS over the BLOCKS/BLOCKED_BY graph from a task. Returns the reachable tasks and the edges that connect them. Use direction='blocks' for downstream (what does this task unblock), 'blocked_by' for upstream (what is this task waiting on), 'both' for the full neighborhood. Depth caps at 10.",
|
|
100
|
+
{
|
|
101
|
+
taskId: z.number().describe("The starting task ID"),
|
|
102
|
+
direction: z.enum(["blocks", "blocked_by", "both"]).optional().describe("Direction to walk (default: both)"),
|
|
103
|
+
depth: z.number().int().min(1).max(10).optional().describe("Max hops from the source (default: 5, capped at 10)"),
|
|
104
|
+
},
|
|
105
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
106
|
+
async ({ taskId, direction, depth }) => {
|
|
107
|
+
try {
|
|
108
|
+
const clients = getClients();
|
|
109
|
+
const res = await clients.taskDependencies.getDependencyChain({
|
|
110
|
+
taskId,
|
|
111
|
+
direction: direction ?? "both",
|
|
112
|
+
depth: depth ?? 5,
|
|
113
|
+
});
|
|
114
|
+
const nodes = res.nodes ?? [];
|
|
115
|
+
const edges = res.edges ?? [];
|
|
116
|
+
const lines = [
|
|
117
|
+
`Dependency chain from task ${taskId} (direction: ${direction ?? "both"}, depth: ${depth ?? 5}):`,
|
|
118
|
+
` ${nodes.length} task(s), ${edges.length} edge(s)`,
|
|
119
|
+
];
|
|
120
|
+
if (nodes.length > 0) {
|
|
121
|
+
lines.push("\nTasks:");
|
|
122
|
+
for (const n of nodes as Array<{ identifier?: string; id: number; title?: string }>) {
|
|
123
|
+
lines.push(` [${n.identifier ?? `#${n.id}`}] ${n.title ?? ""}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (edges.length > 0) {
|
|
127
|
+
lines.push("\nEdges:");
|
|
128
|
+
for (const e of edges as Array<{ fromTaskId: number; toTaskId: number; type: number }>) {
|
|
129
|
+
lines.push(` task ${e.fromTaskId} → task ${e.toTaskId} (type ${e.type})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|