@opsee/mcp-server 0.9.14 → 0.9.17
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 +1 -1
- package/src/__tests__/kv-store.test.ts +53 -0
- package/src/__tests__/tools.test.ts +17 -1
- package/src/auth/__tests__/manager.test.ts +67 -0
- package/src/auth/__tests__/oauth-provider.test.ts +45 -0
- package/src/auth/kv-store.ts +33 -0
- package/src/auth/login.ts +1 -1
- package/src/auth/manager.ts +9 -2
- package/src/auth/oauth-provider.ts +28 -22
- package/src/server-http.ts +102 -25
- package/src/tools/acceptance-criteria.ts +4 -4
- package/src/tools/comments.ts +8 -8
- package/src/tools/cycles.ts +8 -8
- package/src/tools/docs.ts +5 -5
- package/src/tools/initiatives.ts +25 -25
- package/src/tools/labels.ts +12 -12
- package/src/tools/milestones.ts +8 -8
- package/src/tools/notifications.ts +3 -3
- package/src/tools/projects.ts +1 -1
- package/src/tools/repositories.ts +1 -1
- package/src/tools/task-dependencies.ts +7 -7
- package/src/tools/task-metadata.ts +4 -4
- package/src/tools/tasks.ts +45 -45
- package/src/tools/user.ts +3 -3
- package/src/tools/work-logs.ts +5 -5
- package/src/utils/__tests__/format.test.ts +53 -0
- package/src/utils/format.ts +19 -9
package/src/tools/tasks.ts
CHANGED
|
@@ -25,10 +25,10 @@ export function registerTaskTools(
|
|
|
25
25
|
"opsee_list_tasks",
|
|
26
26
|
"List tasks in an Opsee project. Supports filtering by status, assignee, board column, cycle, labels, priority, type, milestone, parent, due date, and update/create recency. labelIds uses AND semantics; multi-id filters (priorityIds/taskTypeIds/milestoneIds) use OR semantics. Supports sorting via sortBy (created_at|updated_at|due_date|priority|title|id|display_order) and sortDir (asc|desc).",
|
|
27
27
|
{
|
|
28
|
-
projectId: z.number().describe("The project ID"),
|
|
29
|
-
columnId: z.number().optional().describe("Filter by board column ID (status)"),
|
|
30
|
-
assigneeId: z.number().optional().describe("Filter by assigned user ID"),
|
|
31
|
-
cycleId: z.number().optional().describe("Filter by cycle/sprint ID"),
|
|
28
|
+
projectId: z.number().int().min(1).describe("The project ID"),
|
|
29
|
+
columnId: z.number().int().min(1).optional().describe("Filter by board column ID (status)"),
|
|
30
|
+
assigneeId: z.number().int().min(1).optional().describe("Filter by assigned user ID"),
|
|
31
|
+
cycleId: z.number().int().min(1).optional().describe("Filter by cycle/sprint ID"),
|
|
32
32
|
labelIds: z.array(z.number().int().min(1)).optional().describe("Filter by label IDs (AND semantics — task must have all listed labels)"),
|
|
33
33
|
priorityIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more priority IDs (OR semantics)"),
|
|
34
34
|
taskTypeIds: z.array(z.number().int().min(1)).optional().describe("Filter by one or more task type IDs (OR semantics)"),
|
|
@@ -42,8 +42,8 @@ export function registerTaskTools(
|
|
|
42
42
|
dueAfter: z.string().optional().describe("Only tasks due at or after this date (RFC3339 or YYYY-MM-DD)"),
|
|
43
43
|
sortBy: z.enum(["created_at", "updated_at", "due_date", "priority", "title", "id", "display_order"]).optional().describe("Sort column. NULLs sort last."),
|
|
44
44
|
sortDir: z.enum(["asc", "desc"]).optional().describe("Sort direction (default: desc)"),
|
|
45
|
-
page: z.number().optional().describe("Page number (default: 1)"),
|
|
46
|
-
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
45
|
+
page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
|
|
46
|
+
pageSize: z.number().int().min(1).optional().describe("Items per page (default: 50)"),
|
|
47
47
|
},
|
|
48
48
|
{ readOnlyHint: true, destructiveHint: false },
|
|
49
49
|
async ({
|
|
@@ -109,7 +109,7 @@ export function registerTaskTools(
|
|
|
109
109
|
server.tool(
|
|
110
110
|
"opsee_get_task",
|
|
111
111
|
"Get full details of a specific task by ID.",
|
|
112
|
-
{ taskId: z.number().describe("The task ID") },
|
|
112
|
+
{ taskId: z.number().int().min(1).describe("The task ID") },
|
|
113
113
|
{ readOnlyHint: true, destructiveHint: false },
|
|
114
114
|
async ({ taskId }) => {
|
|
115
115
|
try {
|
|
@@ -126,7 +126,7 @@ export function registerTaskTools(
|
|
|
126
126
|
server.tool(
|
|
127
127
|
"opsee_get_task_with_context",
|
|
128
128
|
"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.",
|
|
129
|
-
{ taskId: z.number().describe("The task ID") },
|
|
129
|
+
{ taskId: z.number().int().min(1).describe("The task ID") },
|
|
130
130
|
{ readOnlyHint: true, destructiveHint: false },
|
|
131
131
|
async ({ taskId }) => {
|
|
132
132
|
try {
|
|
@@ -189,14 +189,14 @@ export function registerTaskTools(
|
|
|
189
189
|
"opsee_create_task",
|
|
190
190
|
"Create a new task in an Opsee project. Use opsee_list_task_types, opsee_list_task_priorities, and opsee_list_board_columns first to get valid values for optional fields.",
|
|
191
191
|
{
|
|
192
|
-
projectId: z.number().describe("The project ID"),
|
|
192
|
+
projectId: z.number().int().min(1).describe("The project ID"),
|
|
193
193
|
title: z.string().describe("Task title"),
|
|
194
194
|
description: z.string().optional().describe("Task description. 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."),
|
|
195
|
-
taskTypeId: z.number().optional().describe("Task type ID (from opsee_list_task_types)"),
|
|
196
|
-
priorityId: z.number().optional().describe("Priority ID (from opsee_list_task_priorities)"),
|
|
197
|
-
boardColumnId: z.number().optional().describe("Board column ID (from opsee_list_board_columns)"),
|
|
198
|
-
assigneeId: z.number().optional().describe("Assigned user ID"),
|
|
199
|
-
cycleId: z.number().optional().describe("Cycle/sprint ID"),
|
|
195
|
+
taskTypeId: z.number().int().min(1).optional().describe("Task type ID (from opsee_list_task_types)"),
|
|
196
|
+
priorityId: z.number().int().min(1).optional().describe("Priority ID (from opsee_list_task_priorities)"),
|
|
197
|
+
boardColumnId: z.number().int().min(1).optional().describe("Board column ID (from opsee_list_board_columns)"),
|
|
198
|
+
assigneeId: z.number().int().min(1).optional().describe("Assigned user ID"),
|
|
199
|
+
cycleId: z.number().int().min(1).optional().describe("Cycle/sprint ID"),
|
|
200
200
|
milestoneId: z.number().int().min(1).optional().describe("Milestone ID — attaches this task to the milestone."),
|
|
201
201
|
parentTaskId: z.number().int().min(1).optional().describe("Parent task ID — makes this task a subtask of the given task."),
|
|
202
202
|
storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
|
|
@@ -296,14 +296,14 @@ export function registerTaskTools(
|
|
|
296
296
|
"opsee_update_task",
|
|
297
297
|
"Update a task's fields (patch semantics - only provided fields are changed). Fetches the current task first, merges your changes, and sends the full update.",
|
|
298
298
|
{
|
|
299
|
-
taskId: z.number().describe("The task ID to update"),
|
|
299
|
+
taskId: z.number().int().min(1).describe("The task ID to update"),
|
|
300
300
|
title: z.string().optional().describe("New title"),
|
|
301
301
|
description: z.string().optional().describe("New description. 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."),
|
|
302
|
-
taskTypeId: z.number().optional().describe("New task type ID"),
|
|
303
|
-
priorityId: z.number().optional().describe("New priority ID"),
|
|
304
|
-
boardColumnId: z.number().optional().describe("New board column ID (status)"),
|
|
305
|
-
assigneeId: z.number().optional().describe("New assigned user ID"),
|
|
306
|
-
cycleId: z.number().optional().describe("New cycle/sprint ID"),
|
|
302
|
+
taskTypeId: z.number().int().min(1).optional().describe("New task type ID"),
|
|
303
|
+
priorityId: z.number().int().min(1).optional().describe("New priority ID"),
|
|
304
|
+
boardColumnId: z.number().int().min(1).optional().describe("New board column ID (status)"),
|
|
305
|
+
assigneeId: z.number().int().min(1).optional().describe("New assigned user ID"),
|
|
306
|
+
cycleId: z.number().int().min(1).optional().describe("New cycle/sprint ID"),
|
|
307
307
|
milestoneId: z.number().int().min(0).optional().describe("New milestone ID (0 detaches from the current milestone)."),
|
|
308
308
|
parentTaskId: z.number().int().min(0).optional().describe("New parent task ID (0 clears the parent and promotes to a top-level task)."),
|
|
309
309
|
storyPoints: z.number().int().min(1).optional().describe("Story points estimate (positive integer)"),
|
|
@@ -368,13 +368,13 @@ export function registerTaskTools(
|
|
|
368
368
|
"opsee_bulk_update_tasks",
|
|
369
369
|
"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.",
|
|
370
370
|
{
|
|
371
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
372
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to update (at least one)"),
|
|
373
|
-
boardColumnId: z.number().optional().describe("New board column ID (status)"),
|
|
374
|
-
taskPriorityId: z.number().optional().describe("New priority ID"),
|
|
375
|
-
taskTypeId: z.number().optional().describe("New task type ID"),
|
|
376
|
-
assignedUserId: z.number().optional().describe("New assigned user ID"),
|
|
377
|
-
cycleId: z.number().optional().describe("New cycle/sprint ID"),
|
|
371
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
372
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to update (at least one)"),
|
|
373
|
+
boardColumnId: z.number().int().min(1).optional().describe("New board column ID (status)"),
|
|
374
|
+
taskPriorityId: z.number().int().min(1).optional().describe("New priority ID"),
|
|
375
|
+
taskTypeId: z.number().int().min(1).optional().describe("New task type ID"),
|
|
376
|
+
assignedUserId: z.number().int().min(1).optional().describe("New assigned user ID"),
|
|
377
|
+
cycleId: z.number().int().min(1).optional().describe("New cycle/sprint ID"),
|
|
378
378
|
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."),
|
|
379
379
|
storyPoints: z.number().int().min(1).optional().describe("Story points to set on every task"),
|
|
380
380
|
estimatedHours: z.number().min(0).optional().describe("Estimated hours to set on every task"),
|
|
@@ -418,9 +418,9 @@ export function registerTaskTools(
|
|
|
418
418
|
"opsee_bulk_move_to_cycle",
|
|
419
419
|
"Convenience wrapper: move many tasks to the same cycle in one call. Equivalent to opsee_bulk_update_tasks with only cycleId set.",
|
|
420
420
|
{
|
|
421
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
422
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to move"),
|
|
423
|
-
cycleId: z.number().describe("Target cycle/sprint ID"),
|
|
421
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
422
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to move"),
|
|
423
|
+
cycleId: z.number().int().min(1).describe("Target cycle/sprint ID"),
|
|
424
424
|
},
|
|
425
425
|
{ readOnlyHint: false, destructiveHint: false },
|
|
426
426
|
async ({ projectId, taskIds, cycleId }) => {
|
|
@@ -445,9 +445,9 @@ export function registerTaskTools(
|
|
|
445
445
|
"opsee_bulk_move_to_column",
|
|
446
446
|
"Convenience wrapper: move many tasks to the same board column (status) in one call.",
|
|
447
447
|
{
|
|
448
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
449
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to move"),
|
|
450
|
-
boardColumnId: z.number().describe("Target board column ID (status)"),
|
|
448
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
449
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to move"),
|
|
450
|
+
boardColumnId: z.number().int().min(1).describe("Target board column ID (status)"),
|
|
451
451
|
},
|
|
452
452
|
{ readOnlyHint: false, destructiveHint: false },
|
|
453
453
|
async ({ projectId, taskIds, boardColumnId }) => {
|
|
@@ -472,9 +472,9 @@ export function registerTaskTools(
|
|
|
472
472
|
"opsee_bulk_assign",
|
|
473
473
|
"Convenience wrapper: assign many tasks to the same user in one call.",
|
|
474
474
|
{
|
|
475
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
476
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to reassign"),
|
|
477
|
-
assigneeId: z.number().describe("Target user ID"),
|
|
475
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
476
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to reassign"),
|
|
477
|
+
assigneeId: z.number().int().min(1).describe("Target user ID"),
|
|
478
478
|
},
|
|
479
479
|
{ readOnlyHint: false, destructiveHint: false },
|
|
480
480
|
async ({ projectId, taskIds, assigneeId }) => {
|
|
@@ -499,9 +499,9 @@ export function registerTaskTools(
|
|
|
499
499
|
"opsee_bulk_set_priority",
|
|
500
500
|
"Convenience wrapper: set the same priority on many tasks in one call.",
|
|
501
501
|
{
|
|
502
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
503
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to reprioritize"),
|
|
504
|
-
priorityId: z.number().describe("Target priority ID (from opsee_list_task_priorities)"),
|
|
502
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
503
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to reprioritize"),
|
|
504
|
+
priorityId: z.number().int().min(1).describe("Target priority ID (from opsee_list_task_priorities)"),
|
|
505
505
|
},
|
|
506
506
|
{ readOnlyHint: false, destructiveHint: false },
|
|
507
507
|
async ({ projectId, taskIds, priorityId }) => {
|
|
@@ -526,7 +526,7 @@ export function registerTaskTools(
|
|
|
526
526
|
"opsee_replace_task_labels",
|
|
527
527
|
"Replace the entire set of labels on a single task. Use opsee_update_task with labelIds for the same effect, but this tool is more discoverable for the common 'set my labels to [...]' flow.",
|
|
528
528
|
{
|
|
529
|
-
taskId: z.number().describe("The task ID"),
|
|
529
|
+
taskId: z.number().int().min(1).describe("The task ID"),
|
|
530
530
|
labelIds: z.array(z.number().int().min(1)).describe("Label IDs to set (empty array clears all labels)"),
|
|
531
531
|
},
|
|
532
532
|
{ readOnlyHint: false, destructiveHint: false },
|
|
@@ -576,7 +576,7 @@ export function registerTaskTools(
|
|
|
576
576
|
"opsee_link_task_to_initiative",
|
|
577
577
|
"Link an existing task to an Initiative — use this to bring an already-existing task into an initiative's scope. To create NEW tasks under an initiative, use opsee_decompose_initiative (first time) or opsee_reconcile_initiative (re-planning) instead. Fetches the task first so no other fields are blanked.",
|
|
578
578
|
{
|
|
579
|
-
taskId: z.number().describe("The task ID to link"),
|
|
579
|
+
taskId: z.number().int().min(1).describe("The task ID to link"),
|
|
580
580
|
initiativeId: z.number().int().min(1).describe("The initiative ID to link the task to"),
|
|
581
581
|
},
|
|
582
582
|
{ readOnlyHint: false, destructiveHint: false },
|
|
@@ -625,7 +625,7 @@ export function registerTaskTools(
|
|
|
625
625
|
"opsee_unlink_task_from_initiative",
|
|
626
626
|
"Detach a task from its current Initiative. Fetches the task first so no other fields are blanked.",
|
|
627
627
|
{
|
|
628
|
-
taskId: z.number().describe("The task ID to unlink"),
|
|
628
|
+
taskId: z.number().int().min(1).describe("The task ID to unlink"),
|
|
629
629
|
},
|
|
630
630
|
{ readOnlyHint: false, destructiveHint: false },
|
|
631
631
|
async ({ taskId }) => {
|
|
@@ -672,7 +672,7 @@ export function registerTaskTools(
|
|
|
672
672
|
server.tool(
|
|
673
673
|
"opsee_delete_task",
|
|
674
674
|
"Delete a task by ID. This is permanent.",
|
|
675
|
-
{ taskId: z.number().describe("The task ID to delete") },
|
|
675
|
+
{ taskId: z.number().int().min(1).describe("The task ID to delete") },
|
|
676
676
|
{ readOnlyHint: false, destructiveHint: true },
|
|
677
677
|
async ({ taskId }) => {
|
|
678
678
|
try {
|
|
@@ -689,8 +689,8 @@ export function registerTaskTools(
|
|
|
689
689
|
"opsee_bulk_delete_tasks",
|
|
690
690
|
"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.",
|
|
691
691
|
{
|
|
692
|
-
projectId: z.number().describe("Project ID - all tasks must belong to this project"),
|
|
693
|
-
taskIds: z.array(z.number()).min(1).describe("Task IDs to delete (at least one)"),
|
|
692
|
+
projectId: z.number().int().min(1).describe("Project ID - all tasks must belong to this project"),
|
|
693
|
+
taskIds: z.array(z.number().int().min(1)).min(1).describe("Task IDs to delete (at least one)"),
|
|
694
694
|
},
|
|
695
695
|
{ readOnlyHint: false, destructiveHint: true },
|
|
696
696
|
async ({ projectId, taskIds }) => {
|
package/src/tools/user.ts
CHANGED
|
@@ -38,8 +38,8 @@ export function registerUserTools(
|
|
|
38
38
|
"opsee_list_users",
|
|
39
39
|
"Lists users in the authenticated company. Opsee scopes users at the Company level — there is no project-scoped user listing because Users do not have a per-project relation. Use this to resolve assignee names to IDs.",
|
|
40
40
|
{
|
|
41
|
-
page: z.number().optional().describe("Page number (default: 1)"),
|
|
42
|
-
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
41
|
+
page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
|
|
42
|
+
pageSize: z.number().int().min(1).optional().describe("Items per page (default: 50)"),
|
|
43
43
|
},
|
|
44
44
|
{ readOnlyHint: true, destructiveHint: false },
|
|
45
45
|
async ({ page, pageSize }) => {
|
|
@@ -58,7 +58,7 @@ export function registerUserTools(
|
|
|
58
58
|
server.tool(
|
|
59
59
|
"opsee_get_user",
|
|
60
60
|
"Get full details of a specific user by ID.",
|
|
61
|
-
{ userId: z.number().describe("The user ID") },
|
|
61
|
+
{ userId: z.number().int().min(1).describe("The user ID") },
|
|
62
62
|
{ readOnlyHint: true, destructiveHint: false },
|
|
63
63
|
async ({ userId }) => {
|
|
64
64
|
try {
|
package/src/tools/work-logs.ts
CHANGED
|
@@ -14,7 +14,7 @@ export function registerWorkLogTools(
|
|
|
14
14
|
server.tool(
|
|
15
15
|
"opsee_list_work_logs",
|
|
16
16
|
"List all work-log entries for a task, oldest first. Includes the user who logged each entry, hours, and optional description.",
|
|
17
|
-
{ taskId: z.number().describe("The task ID") },
|
|
17
|
+
{ taskId: z.number().int().min(1).describe("The task ID") },
|
|
18
18
|
{ readOnlyHint: true, destructiveHint: false },
|
|
19
19
|
async ({ taskId }) => {
|
|
20
20
|
try {
|
|
@@ -52,7 +52,7 @@ export function registerWorkLogTools(
|
|
|
52
52
|
"opsee_log_work",
|
|
53
53
|
"Log time against a task. Posts as the authenticated user. Hours must be > 0.",
|
|
54
54
|
{
|
|
55
|
-
taskId: z.number().describe("The task ID"),
|
|
55
|
+
taskId: z.number().int().min(1).describe("The task ID"),
|
|
56
56
|
hours: z.number().min(0.1).describe("Hours worked (>= 0.1)"),
|
|
57
57
|
description: z.string().optional().describe("Optional note describing what was done"),
|
|
58
58
|
},
|
|
@@ -79,8 +79,8 @@ export function registerWorkLogTools(
|
|
|
79
79
|
"opsee_delete_work_log",
|
|
80
80
|
"Delete a work-log entry by ID. This is permanent.",
|
|
81
81
|
{
|
|
82
|
-
taskId: z.number().describe("The task ID the work log belongs to"),
|
|
83
|
-
workLogId: z.number().describe("The work log ID to delete"),
|
|
82
|
+
taskId: z.number().int().min(1).describe("The task ID the work log belongs to"),
|
|
83
|
+
workLogId: z.number().int().min(1).describe("The work log ID to delete"),
|
|
84
84
|
},
|
|
85
85
|
{ readOnlyHint: false, destructiveHint: true },
|
|
86
86
|
async ({ taskId, workLogId }) => {
|
|
@@ -97,7 +97,7 @@ export function registerWorkLogTools(
|
|
|
97
97
|
server.tool(
|
|
98
98
|
"opsee_get_task_actual_hours",
|
|
99
99
|
"Sum of all work_log hours on a task. Use this to compare against opsee_get_task's estimatedHours when assessing whether a task is over budget.",
|
|
100
|
-
{ taskId: z.number().describe("The task ID") },
|
|
100
|
+
{ taskId: z.number().int().min(1).describe("The task ID") },
|
|
101
101
|
{ readOnlyHint: true, destructiveHint: false },
|
|
102
102
|
async ({ taskId }) => {
|
|
103
103
|
try {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
3
|
+
import { formatError } from "../format.js";
|
|
4
|
+
|
|
5
|
+
describe("formatError classification", () => {
|
|
6
|
+
test("ConnectError Unauthenticated → login instruction (correct command)", () => {
|
|
7
|
+
const msg = formatError(new ConnectError("nope", Code.Unauthenticated));
|
|
8
|
+
expect(msg).toContain("npx opsee-mcp login");
|
|
9
|
+
expect(msg).toMatch(/Not authenticated/i);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("ConnectError Unavailable → reach-API message with raw detail", () => {
|
|
13
|
+
const msg = formatError(new ConnectError("backend down", Code.Unavailable));
|
|
14
|
+
expect(msg).toMatch(/Could not reach Opsee API/i);
|
|
15
|
+
expect(msg).toContain("backend down");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("other ConnectError codes pass through as [Code] rawMessage", () => {
|
|
19
|
+
const msg = formatError(new ConnectError("task missing", Code.NotFound));
|
|
20
|
+
expect(msg).toBe("[NotFound] task missing");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("does NOT misclassify a ConnectError whose text embeds 401/ECONNREFUSED", () => {
|
|
24
|
+
// The point of keying on the structured code: a NotFound whose message
|
|
25
|
+
// contains "401" must stay NotFound, not become an auth error.
|
|
26
|
+
const msg = formatError(new ConnectError('task "401 ECONNREFUSED" missing', Code.NotFound));
|
|
27
|
+
expect(msg).toBe('[NotFound] task "401 ECONNREFUSED" missing');
|
|
28
|
+
expect(msg).not.toMatch(/Not authenticated/i);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("raw ECONNREFUSED error (err.code) → reach-API message", () => {
|
|
32
|
+
const err = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), {
|
|
33
|
+
code: "ECONNREFUSED",
|
|
34
|
+
});
|
|
35
|
+
expect(formatError(err)).toMatch(/Could not reach Opsee API/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("raw ENOTFOUND error (err.code) → reach-API message", () => {
|
|
39
|
+
const err = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
|
|
40
|
+
code: "ENOTFOUND",
|
|
41
|
+
});
|
|
42
|
+
expect(formatError(err)).toMatch(/Could not reach Opsee API/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("plain Error without a code → its message verbatim", () => {
|
|
46
|
+
expect(formatError(new Error("something odd"))).toBe("something odd");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("non-Error value → String(value)", () => {
|
|
50
|
+
expect(formatError("boom")).toBe("boom");
|
|
51
|
+
expect(formatError(42)).toBe("42");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Task, Project, Cycle, DocPage, DocSpace, Milestone, MilestoneTask, User, Label, TaskLabel, Comment, TaskDependency, Initiative, InitiativeMemoryEntry, TaskPullRequest } from "../../gen/api/v1/models_pb.js";
|
|
2
2
|
import { TaskDependencyType } from "../../gen/api/v1/models_pb.js";
|
|
3
3
|
import type { InitiativeGraph, InitiativeSlice, ReconcileChangeset } from "../../gen/api/v1/initiative_pb.js";
|
|
4
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
4
5
|
|
|
5
6
|
export function formatProject(p: Project): string {
|
|
6
7
|
const lines = [
|
|
@@ -591,17 +592,26 @@ export function formatReconcileChangeset(changeset: ReconcileChangeset): string
|
|
|
591
592
|
}
|
|
592
593
|
|
|
593
594
|
export function formatError(error: unknown): string {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
595
|
+
// Prefer the structured gRPC/Connect code over substring-matching the
|
|
596
|
+
// message: a backend error whose text embeds a task title containing "401"
|
|
597
|
+
// or "ECONNREFUSED" must not be misclassified as auth/network.
|
|
598
|
+
if (error instanceof ConnectError) {
|
|
599
|
+
if (error.code === Code.Unauthenticated) {
|
|
600
|
+
return "Not authenticated. Run `npx opsee-mcp login` to connect your Opsee account.";
|
|
601
|
+
}
|
|
602
|
+
if (error.code === Code.Unavailable) {
|
|
603
|
+
return `Could not reach Opsee API. Check your connection and OPSEE_API_URL. (${error.rawMessage})`;
|
|
599
604
|
}
|
|
600
|
-
|
|
601
|
-
|
|
605
|
+
return `[${Code[error.code]}] ${error.rawMessage}`;
|
|
606
|
+
}
|
|
607
|
+
if (error instanceof Error) {
|
|
608
|
+
// Raw transport errors before a Connect response carry a machine-readable
|
|
609
|
+
// code property (Node sets err.code); match on that, not the message text.
|
|
610
|
+
const code = (error as { code?: unknown }).code;
|
|
611
|
+
if (code === "ECONNREFUSED" || code === "ENOTFOUND") {
|
|
612
|
+
return `Could not reach Opsee API. Check your connection and OPSEE_API_URL. (${error.message})`;
|
|
602
613
|
}
|
|
603
|
-
|
|
604
|
-
return code ? `[${code}] ${msg}` : msg;
|
|
614
|
+
return error.message;
|
|
605
615
|
}
|
|
606
616
|
return String(error);
|
|
607
617
|
}
|