@opsee/mcp-server 0.9.16 → 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.
@@ -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 {
@@ -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
+ });
@@ -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
- if (error instanceof Error) {
595
- const msg = error.message;
596
- const code = (error as any).code;
597
- if (msg.includes("401") || msg.includes("Unauthenticated") || code === "unauthenticated") {
598
- return "Not authenticated. Run `npx @opsee/mcp-server login` to connect your Opsee account.";
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
- if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
601
- return `Could not reach Opsee API. Check your connection and OPSEE_API_URL. (${msg})`;
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
- // Show full error details for debugging
604
- return code ? `[${code}] ${msg}` : msg;
614
+ return error.message;
605
615
  }
606
616
  return String(error);
607
617
  }