viviscape-mcp 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,12 +105,52 @@ The server exposes tools across these domains:
105
105
  - **Companies** - `company_add`, `company_list`, `company_update`
106
106
  - **Projects** - `project_get`, `project_list`, `project_list_active`, `project_staff`, `project_tasks`, `projects_by_company`
107
107
  - **Tasks** - `task_add`, `task_get`, `task_update`, `tasks_open`, `tasks_pending`, `tasks_by_company`, `tasks_by_milestone`
108
+ - **Task comments** - `task_comments`, `task_comment_get`, `task_comment_add`, `task_comment_update`, `task_comment_remove`, `task_mark_read`
109
+ - **Task assignment** - `task_assignee_add`, `task_assignee_remove`, `task_set_leader`, `task_set_attention`, `task_delete`, `task_merge`
110
+ - **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
111
+ - **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
112
+ - **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
108
113
  - **Time logs** - `timelog_add`, `timelog_update`
109
114
  - **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`
110
115
  - **Insights** - hours by person/service/project, AI summary, person stats, time totals
111
116
  - **Account** - `account_info`, `account_services`, `account_users`
112
117
  - **Auth** - `auth_login`, `auth_status`, `auth_logout`
113
118
 
119
+ ### Status and priority values
120
+
121
+ Use the `enums` tool rather than guessing. The platform's vocabularies are:
122
+
123
+ | field | values |
124
+ |---|---|
125
+ | task status | backlog, new, research, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
126
+ | priority | low, moderate, important, urgent, critical |
127
+ | project status | new, inprogress, complete |
128
+ | prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
129
+
130
+ Two traps: tasks complete as `completed` while projects complete as
131
+ `complete`, and priority is *not* low/medium/high. Task and project tools
132
+ validate these with `z.enum`, so an invalid value is rejected before it reaches
133
+ the API.
134
+
135
+ ### Paging and field selection
136
+
137
+ List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
138
+ return a page of trimmed rows rather than every column of every row. A task row
139
+ carries ~94 columns and a project row 87, most of them irrelevant to project
140
+ work, so each list tool accepts:
141
+
142
+ - `fields` - columns to return; omit for a curated default, or pass `["all"]`
143
+ - `limit` / `offset` - page window, default 50 rows
144
+
145
+ Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
146
+ so a caller can tell when more rows exist. Task lists also accept
147
+ `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
148
+ `due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
149
+ and `only_mine` to the server and filters the rest in-process.
150
+
151
+ In practice this took `tasks_open` from 97 KB to 6.4 KB and
152
+ `project_list_active` from 375 KB to 19 KB.
153
+
114
154
  ## Development
115
155
 
116
156
  ```bash
@@ -52,15 +52,108 @@ export declare class ViviScapeClient {
52
52
  getProjectStaff(projectId: number): Promise<unknown>;
53
53
  getProjectsByCompany(userId: number, companyId: number): Promise<unknown>;
54
54
  getActiveProjectsByUser(userId: number): Promise<unknown>;
55
+ /**
56
+ * Create a project (group). GEN_Group has ~40 fields; live rows for this
57
+ * tenant show account_id 0, platform_account_id = pid, and group type 5
58
+ * (PROJECT), so those are the defaults here.
59
+ *
60
+ * There is no delete route for projects -- a created project can only be
61
+ * archived or renamed afterward.
62
+ */
63
+ addProject(data: {
64
+ company_id: number;
65
+ name: string;
66
+ short_description?: string;
67
+ description?: string;
68
+ status?: string;
69
+ is_public?: boolean;
70
+ staff?: number[];
71
+ }): Promise<unknown>;
72
+ /** Update a project by merging changes onto the row the API already has. */
73
+ updateProject(projectId: number, changes: Record<string, unknown>): Promise<unknown>;
74
+ /** Status-only update; leaves budget and approved time untouched. */
75
+ setProjectStatus(projectId: number, status: string, billable?: boolean): Promise<unknown>;
76
+ addProjectUser(projectId: number, userId: number, opts?: {
77
+ is_contact?: boolean;
78
+ is_lead?: boolean;
79
+ }): Promise<unknown>;
80
+ removeProjectUser(projectId: number, userId: number): Promise<unknown>;
55
81
  addTask(data: Record<string, unknown>): Promise<unknown>;
56
82
  updateTask(data: Record<string, unknown>): Promise<unknown>;
57
83
  getProjectTasks(projectId: number): Promise<unknown>;
58
- getOpenTasks(): Promise<unknown>;
84
+ /**
85
+ * The account/tasks/open route filters server-side on company, ownership, and
86
+ * a date window; pass them through instead of hardcoding "everything".
87
+ */
88
+ getOpenTasks(opts?: {
89
+ company_id?: number;
90
+ only_mine?: boolean;
91
+ team?: string;
92
+ start?: string;
93
+ end?: string;
94
+ }): Promise<unknown>;
59
95
  getTask(taskId: number): Promise<unknown>;
60
96
  getPendingTasks(userId: number): Promise<unknown>;
61
97
  getTasksByMilestone(milestoneId: number): Promise<unknown>;
62
98
  getTasksByGroupAndUser(groupId: number, userId: number): Promise<unknown>;
63
99
  getCompanyTasks(companyId: number): Promise<unknown>;
100
+ getTaskComments(taskId: number): Promise<unknown>;
101
+ getTaskComment(commentId: string): Promise<unknown>;
102
+ /**
103
+ * NOTE: on platform account 1, the backend fires a client-notification webhook
104
+ * for comments on tasks whose client_id > 0. Adding a comment can therefore
105
+ * reach the customer -- the tool description says so too.
106
+ */
107
+ addTaskComment(data: {
108
+ task_id: number;
109
+ comment: string;
110
+ client_id?: number;
111
+ source?: string;
112
+ }): Promise<unknown>;
113
+ updateTaskComment(data: {
114
+ comment_id: string;
115
+ task_id: number;
116
+ comment: string;
117
+ }): Promise<unknown>;
118
+ /**
119
+ * The backend's remove proxy answers 500 even on success: it returns 400 when
120
+ * the upstream delete fails, so a 500 means it crashed deserializing an empty
121
+ * success body (res.data.ToString() on null). Verify by re-reading the thread
122
+ * rather than reporting a failure that did not happen.
123
+ */
124
+ removeTaskComment(commentId: string, taskId?: number): Promise<unknown>;
125
+ markTaskRead(taskId: number): Promise<unknown>;
126
+ addTaskAssignee(data: {
127
+ task_id: number;
128
+ user_id: number;
129
+ leader?: boolean;
130
+ attention?: boolean;
131
+ notify?: boolean;
132
+ }): Promise<unknown>;
133
+ /** Takes the assignee_id from the task's staff_assignees, not a user_id. */
134
+ removeTaskAssignee(assigneeId: number): Promise<unknown>;
135
+ setTaskLeader(taskId: number, userId: number): Promise<unknown>;
136
+ setTaskAttention(taskId: number, userId: number, flag: boolean): Promise<unknown>;
137
+ deleteTask(taskId: number): Promise<unknown>;
138
+ /** Fold one task into another; the source task is consumed. */
139
+ mergeTasks(fromTaskId: number, toTaskId: number): Promise<unknown>;
140
+ getMilestones(projectId: number): Promise<unknown>;
141
+ getActiveMilestones(): Promise<unknown>;
142
+ getMilestonesByCompany(companyId: number): Promise<unknown>;
143
+ getMilestonesByUser(userId: number): Promise<unknown>;
144
+ getMilestone(milestoneId: number): Promise<unknown>;
145
+ addMilestone(data: Record<string, unknown>): Promise<unknown>;
146
+ updateMilestone(data: Record<string, unknown>): Promise<unknown>;
147
+ /** Copy a milestone (and its task template) into a project. */
148
+ cloneMilestone(milestoneId: number, projectId: number, title?: string): Promise<unknown>;
149
+ /**
150
+ * Known broken upstream: milestone/remove answers 500 and the milestone
151
+ * survives. The proxy returns 400 with detail when the upstream delete fails,
152
+ * so the 500 is a crash in its own success branch (Convert.ToBoolean on a
153
+ * non-boolean data payload) -- but unlike the comment-remove case the record
154
+ * is genuinely still there. Verify and say so plainly instead of pretending.
155
+ */
156
+ removeMilestone(milestoneId: number): Promise<unknown>;
64
157
  addTimeLog(data: Record<string, unknown>): Promise<unknown>;
65
158
  updateTimeLog(data: Record<string, unknown>): Promise<unknown>;
66
159
  getMyNotes(): Promise<unknown>;
@@ -1,4 +1,5 @@
1
- import { API_PREFIX, baseUrl } from './config.js';
1
+ import { API_PREFIX, APP_NAME, baseUrl } from './config.js';
2
+ import { GROUP_TYPES } from './enums.js';
2
3
  import { featureForPath, requireFeature } from './auth/permissions.js';
3
4
  /** Raised when the stored session is rejected or bounced to the login page. */
4
5
  export class SessionExpiredError extends Error {
@@ -13,6 +14,25 @@ export class ApiError extends Error {
13
14
  this.status = status;
14
15
  }
15
16
  }
17
+ /**
18
+ * GroupMilestone payload. The API wants the whole record, so fill the fields it
19
+ * computes with neutral values and let an update merge over the existing row.
20
+ */
21
+ function buildMilestonePayload(data, ctx, existing) {
22
+ const est = data.estimated_hours ? parseShorthandDuration(String(data.estimated_hours)).duration : '';
23
+ return {
24
+ milestone_id: Number(data.milestone_id ?? existing?.milestone_id ?? 0),
25
+ group_id: Number(data.project_id ?? existing?.group_id ?? 0),
26
+ user_id: ctx.userId,
27
+ milestone: (data.title ?? existing?.milestone ?? ''),
28
+ sequence: Number(data.sequence ?? existing?.sequence ?? 0),
29
+ start_date: isoDate(data.start_date, String(existing?.start_date ?? nowIso())),
30
+ due_date: isoDate(data.due_date, String(existing?.due_date ?? nowIso())),
31
+ estimated_time: est || existing?.estimated_time || '',
32
+ complete: Boolean(data.complete ?? existing?.complete ?? false),
33
+ progress: Number(data.progress ?? existing?.progress ?? 0),
34
+ };
35
+ }
16
36
  /**
17
37
  * Parse shorthand duration (e.g., "3h", "1h30m", "45m") to HH:MM:SS TimeSpan format.
18
38
  */
@@ -33,74 +53,47 @@ function parseShorthandDuration(input) {
33
53
  /**
34
54
  * Build a full Group_Task payload from simplified MCP tool params.
35
55
  */
56
+ /**
57
+ * Group_Task create payload.
58
+ *
59
+ * Modelled on what the ViviScape Work UI actually posts to tasks/add
60
+ * (ClockWyzWeb/Scripts/app/tasks.js, \$scope.nt): fourteen fields, ids as
61
+ * strings, estimated_time as shorthand text, and assignment expressed as
62
+ * staff_assignees [{user_id, attention}].
63
+ *
64
+ * The previous version sent a 37-field row with estimated_ticks and a
65
+ * hand-computed priority_rank, which the API rejected outright ("Invalid Server
66
+ * Request"). priority_rank is server-computed -- the UI never sends it, and the
67
+ * live values are not ordinal (low=3, moderate=27, important=86).
68
+ */
36
69
  function buildTaskPayload(data, ctx) {
37
- const now = new Date().toISOString();
38
- const assignees = [];
39
- const userId = data.assigned_to ?? ctx.userId;
40
- assignees.push({
41
- assignee_id: 0,
42
- task_id: 0,
43
- user_id: userId,
44
- client_id: 0,
45
- leader: true,
46
- attention: false,
47
- promoting: false,
48
- pinned: false,
49
- notify: true,
50
- active: false,
51
- activitys: [],
52
- duration: '00:00:00',
53
- duration_friendly: '-',
54
- });
55
- // Convert estimated_hours shorthand to ticks (1 tick = 100ns, 1h = 36_000_000_000 ticks)
56
- let estimatedTicks = 0;
57
- if (data.estimated_hours) {
58
- const parsed = parseShorthandDuration(String(data.estimated_hours));
59
- const [h, m] = parsed.duration.split(':').map(Number);
60
- estimatedTicks = ((h * 60 + m) * 60) * 10_000_000;
61
- }
62
- const priorityMap = { low: 1, medium: 2, high: 3, critical: 4 };
63
- const priorityStr = String(data.priority || 'medium');
64
- const priorityRank = priorityMap[priorityStr] ?? 2;
65
- return {
66
- task_id: 0,
67
- group_id: data.project_id ?? 0,
68
- company_id: data.company_id ?? 0,
69
- service_id: data.service_id ?? 0,
70
+ const start = isoDate(data.start_date, nowIso());
71
+ const payload = {
72
+ status: data.status || 'new',
73
+ account_id: ctx.accountId,
74
+ service_id: String(data.service_id ?? 0),
75
+ estimated_time: data.estimated_hours ? String(data.estimated_hours) : '8h',
76
+ group_id: String(data.project_id ?? 0),
77
+ milestone_id: String(data.milestone_id ?? 0),
70
78
  task: data.title ?? '',
79
+ review_required: '0',
71
80
  description: data.description ?? '',
72
- notes: data.notes ?? '',
73
- status: data.status ?? 'new',
74
- priority: priorityStr,
75
- priority_rank: priorityRank,
76
- start: data.start_date ?? now,
77
- end: data.due_date ?? now,
78
- creation_date: now,
79
- complete_date: '1901-01-01T00:00:00+00:00',
80
- estimated_ticks: estimatedTicks,
81
- task_type: data.task_type ?? 'task',
82
- isbillable: data.isbillable ?? false,
83
- creator_id: userId,
84
- milestone_id: data.milestone_id ?? 0,
85
- client_id: 0,
86
- event_id: 0,
87
- account_id: ctx.accountId,
88
- percentage: 0,
89
- attention: false,
90
- pinned: false,
91
- assign_all_staff: false,
92
- agg_cost: 0,
93
- agg_rate: 0,
94
- predecessor_id: 0,
95
- predecessor_complete: false,
96
- predecessor_name: '',
97
- review_required: false,
98
- parts: '',
99
- staff_assignees: assignees,
100
- client_assignees: [],
101
- task_files: [],
102
- logs: [],
81
+ priority: data.priority || 'low',
82
+ // The signed-in user creates the task. Assignment is separate -- see
83
+ // staff_assignees below and the task_assignee_* tools.
84
+ creator_id: ctx.userId,
85
+ start,
86
+ end: isoDate(data.due_date, start),
87
+ focus: false,
103
88
  };
89
+ if (data.notes)
90
+ payload.notes = data.notes;
91
+ if (data.company_id)
92
+ payload.company_id = data.company_id;
93
+ if (data.assigned_to) {
94
+ payload.staff_assignees = [{ user_id: Number(data.assigned_to), attention: false }];
95
+ }
96
+ return payload;
104
97
  }
105
98
  /**
106
99
  * Build update fields for a Group_Task, mapping friendly names to API names.
@@ -187,6 +180,7 @@ function buildTimeLogPayload(data, ctx) {
187
180
  };
188
181
  }
189
182
  const EPOCH = '2000-01-01T00:00:00';
183
+ const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
190
184
  function isoDate(value, fallback) {
191
185
  if (typeof value === 'string' && value.trim())
192
186
  return value;
@@ -371,6 +365,68 @@ export class ViviScapeClient {
371
365
  async getActiveProjectsByUser(userId) {
372
366
  return this.get(`user/groups/active?user_id=${userId || this.userId}&account_id=${this.accountId}`);
373
367
  }
368
+ /**
369
+ * Create a project (group). GEN_Group has ~40 fields; live rows for this
370
+ * tenant show account_id 0, platform_account_id = pid, and group type 5
371
+ * (PROJECT), so those are the defaults here.
372
+ *
373
+ * There is no delete route for projects -- a created project can only be
374
+ * archived or renamed afterward.
375
+ */
376
+ async addProject(data) {
377
+ return this.post('groups/add', {
378
+ intGroupId: 0,
379
+ intGroupTypeId: GROUP_TYPES.PROJECT,
380
+ intCompanyId: data.company_id,
381
+ intPostPermission: 0,
382
+ intEventId: 0,
383
+ intGroupBeacon: 0,
384
+ decBalance: 0,
385
+ strImage: '',
386
+ strImageThumb: '',
387
+ strGroupName: data.name,
388
+ strGroupShortDescription: data.short_description || '',
389
+ strGroupDescription: data.description || '',
390
+ strGroupLink: '',
391
+ bitActive: true,
392
+ bitPublic: data.is_public ?? false,
393
+ intCapacity: 0,
394
+ intStaff: 0,
395
+ intMembers: 0,
396
+ intPayoutAccountId: 0,
397
+ Clients: [],
398
+ Staff: data.staff && data.staff.length ? data.staff : [this.userId],
399
+ account_id: 0,
400
+ platform_account_id: this.accountId,
401
+ user_id: this.userId,
402
+ status: data.status || 'new',
403
+ enabledWelcome: false,
404
+ enabledGoodbye: false,
405
+ showClients: false,
406
+ welcomeMessage: '',
407
+ goodbyeMessage: '',
408
+ });
409
+ }
410
+ /** Update a project by merging changes onto the row the API already has. */
411
+ async updateProject(projectId, changes) {
412
+ const existing = await this.getProjectById(projectId);
413
+ const row = Array.isArray(existing) ? existing[0] : existing;
414
+ if (!row || typeof row !== 'object') {
415
+ throw new ApiError(404, `Project ${projectId} not found, cannot update.`);
416
+ }
417
+ return this.post('groups/update', { ...row, ...changes });
418
+ }
419
+ /** Status-only update; leaves budget and approved time untouched. */
420
+ async setProjectStatus(projectId, status, billable = true) {
421
+ return this.get(`groups/update/status?groupid=${projectId}&status=${encodeURIComponent(status)}&billable=${billable}`);
422
+ }
423
+ async addProjectUser(projectId, userId, opts = {}) {
424
+ return this.get(`groups/user/add?userid=${userId}&groupid=${projectId}` +
425
+ `&iscontact=${opts.is_contact ?? false}&islead=${opts.is_lead ?? false}`);
426
+ }
427
+ async removeProjectUser(projectId, userId) {
428
+ return this.get(`groups/user/remove?groupid=${projectId}&userid=${userId}`);
429
+ }
374
430
  // -- Tasks --------------------------------------------------
375
431
  async addTask(data) {
376
432
  return this.post('tasks/add', buildTaskPayload(data, this.ctx));
@@ -385,15 +441,19 @@ export class ViviScapeClient {
385
441
  async getProjectTasks(projectId) {
386
442
  return this.get(`tasks/group?group_id=${projectId}`);
387
443
  }
388
- async getOpenTasks() {
444
+ /**
445
+ * The account/tasks/open route filters server-side on company, ownership, and
446
+ * a date window; pass them through instead of hardcoding "everything".
447
+ */
448
+ async getOpenTasks(opts = {}) {
389
449
  return this.post('account/tasks/open', {
390
450
  account_id: this.accountId,
391
451
  user_id: this.userId,
392
- start: EPOCH,
393
- end: nowIso(),
394
- company_id: 0,
395
- teamfilter: '',
396
- onlymytasks: false,
452
+ start: opts.start || EPOCH,
453
+ end: opts.end || nowIso(),
454
+ company_id: opts.company_id ?? 0,
455
+ teamfilter: opts.team || '',
456
+ onlymytasks: opts.only_mine ?? false,
397
457
  });
398
458
  }
399
459
  async getTask(taskId) {
@@ -411,6 +471,178 @@ export class ViviScapeClient {
411
471
  async getCompanyTasks(companyId) {
412
472
  return this.get(`company/tasks?company_id=${companyId}&user_id=${this.userId}`);
413
473
  }
474
+ // -- Task comments ------------------------------------------
475
+ async getTaskComments(taskId) {
476
+ return this.get(`task/comments/${taskId}`);
477
+ }
478
+ async getTaskComment(commentId) {
479
+ return this.get(`task/comment/id/${commentId}`);
480
+ }
481
+ /**
482
+ * NOTE: on platform account 1, the backend fires a client-notification webhook
483
+ * for comments on tasks whose client_id > 0. Adding a comment can therefore
484
+ * reach the customer -- the tool description says so too.
485
+ */
486
+ async addTaskComment(data) {
487
+ return this.post('task/comment/add', {
488
+ comment_id: EMPTY_GUID,
489
+ task_id: data.task_id,
490
+ user_id: this.userId,
491
+ client_id: data.client_id ?? 0,
492
+ creator: this.creds.name || this.creds.email || String(this.userId),
493
+ comment: data.comment,
494
+ attachment_file: '',
495
+ attachment_url: '',
496
+ source: data.source || APP_NAME,
497
+ comment_date: nowIso(),
498
+ });
499
+ }
500
+ async updateTaskComment(data) {
501
+ return this.post('task/comment/update', {
502
+ comment_id: data.comment_id,
503
+ task_id: data.task_id,
504
+ user_id: this.userId,
505
+ client_id: 0,
506
+ creator: this.creds.name || this.creds.email || String(this.userId),
507
+ comment: data.comment,
508
+ attachment_file: '',
509
+ attachment_url: '',
510
+ source: APP_NAME,
511
+ comment_date: nowIso(),
512
+ });
513
+ }
514
+ /**
515
+ * The backend's remove proxy answers 500 even on success: it returns 400 when
516
+ * the upstream delete fails, so a 500 means it crashed deserializing an empty
517
+ * success body (res.data.ToString() on null). Verify by re-reading the thread
518
+ * rather than reporting a failure that did not happen.
519
+ */
520
+ async removeTaskComment(commentId, taskId) {
521
+ try {
522
+ return await this.get(`task/comment/remove/${commentId}`);
523
+ }
524
+ catch (err) {
525
+ if (!(err instanceof ApiError) || err.status !== 500)
526
+ throw err;
527
+ if (!taskId) {
528
+ throw new ApiError(500, `API returned 500 removing comment ${commentId}. This backend answers 500 even when the ` +
529
+ 'delete succeeds; pass task_id so the removal can be verified by re-reading the thread.');
530
+ }
531
+ const rows = await this.getTaskComments(taskId);
532
+ const present = Array.isArray(rows)
533
+ && rows.some((r) => String(r.comment_id).toLowerCase() === commentId.toLowerCase());
534
+ if (present)
535
+ throw err;
536
+ return {
537
+ removed: true,
538
+ comment_id: commentId,
539
+ task_id: taskId,
540
+ note: 'Backend returned 500 while serializing its response; verified removed by re-reading the thread.',
541
+ };
542
+ }
543
+ }
544
+ async markTaskRead(taskId) {
545
+ return this.post('task/read/mark', { task_id: taskId, user_id: this.userId });
546
+ }
547
+ // -- Task assignment and lifecycle --------------------------
548
+ async addTaskAssignee(data) {
549
+ return this.post('tasks/assignee/add', {
550
+ assignee_id: 0,
551
+ task_id: data.task_id,
552
+ user_id: data.user_id,
553
+ client_id: 0,
554
+ active: true,
555
+ leader: data.leader ?? false,
556
+ attention: data.attention ?? false,
557
+ promoting: false,
558
+ pinned: false,
559
+ notify: data.notify ?? true,
560
+ });
561
+ }
562
+ /** Takes the assignee_id from the task's staff_assignees, not a user_id. */
563
+ async removeTaskAssignee(assigneeId) {
564
+ return this.get(`tasks/assignee/remove?assignee_id=${assigneeId}`);
565
+ }
566
+ async setTaskLeader(taskId, userId) {
567
+ return this.get(`task/assign/leader/${taskId}/${userId}`);
568
+ }
569
+ async setTaskAttention(taskId, userId, flag) {
570
+ return this.get(`task/assign/attention/${taskId}/${userId}/${flag}`);
571
+ }
572
+ async deleteTask(taskId) {
573
+ return this.get(`tasks/delete?task_id=${taskId}`);
574
+ }
575
+ /** Fold one task into another; the source task is consumed. */
576
+ async mergeTasks(fromTaskId, toTaskId) {
577
+ return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
578
+ }
579
+ // -- Milestones ---------------------------------------------
580
+ async getMilestones(projectId) {
581
+ return this.get(`milestones/list/${projectId}`);
582
+ }
583
+ async getActiveMilestones() {
584
+ return this.get(`milestones/active/account/${this.accountId}`);
585
+ }
586
+ async getMilestonesByCompany(companyId) {
587
+ return this.get(`milestones/account/${this.accountId}/${companyId}`);
588
+ }
589
+ async getMilestonesByUser(userId) {
590
+ return this.get(`milestones/user/active/account/${this.accountId}/${userId || this.userId}`);
591
+ }
592
+ async getMilestone(milestoneId) {
593
+ return this.get(`milestone/id/${milestoneId}`);
594
+ }
595
+ async addMilestone(data) {
596
+ return this.post('milestone/add', buildMilestonePayload(data, this.ctx));
597
+ }
598
+ async updateMilestone(data) {
599
+ if (data.milestone_id) {
600
+ const existing = await this.getMilestone(Number(data.milestone_id));
601
+ const row = Array.isArray(existing) ? existing[0] : existing;
602
+ if (row && typeof row === 'object') {
603
+ return this.post('milestone/update', { ...row, ...buildMilestonePayload(data, this.ctx, row) });
604
+ }
605
+ }
606
+ return this.post('milestone/update', buildMilestonePayload(data, this.ctx));
607
+ }
608
+ /** Copy a milestone (and its task template) into a project. */
609
+ async cloneMilestone(milestoneId, projectId, title) {
610
+ const existing = await this.getMilestone(milestoneId);
611
+ const row = (Array.isArray(existing) ? existing[0] : existing);
612
+ if (!row || typeof row !== 'object') {
613
+ throw new ApiError(404, `Milestone ${milestoneId} not found, cannot clone.`);
614
+ }
615
+ return this.post('milestone/clone', {
616
+ ...row,
617
+ milestone_id: milestoneId,
618
+ group_id: projectId,
619
+ user_id: this.userId,
620
+ ...(title ? { milestone: title } : {}),
621
+ });
622
+ }
623
+ /**
624
+ * Known broken upstream: milestone/remove answers 500 and the milestone
625
+ * survives. The proxy returns 400 with detail when the upstream delete fails,
626
+ * so the 500 is a crash in its own success branch (Convert.ToBoolean on a
627
+ * non-boolean data payload) -- but unlike the comment-remove case the record
628
+ * is genuinely still there. Verify and say so plainly instead of pretending.
629
+ */
630
+ async removeMilestone(milestoneId) {
631
+ try {
632
+ return await this.get(`milestone/remove/${milestoneId}`);
633
+ }
634
+ catch (err) {
635
+ if (!(err instanceof ApiError) || err.status !== 500)
636
+ throw err;
637
+ const still = await this.getMilestone(milestoneId).catch(() => null);
638
+ const row = Array.isArray(still) ? still[0] : still;
639
+ if (row && typeof row === 'object' && row.milestone_id) {
640
+ throw new ApiError(500, `milestone/remove/${milestoneId} returned 500 and the milestone still exists. ` +
641
+ 'This route is broken server-side; delete the milestone in the ViviScape Work UI.');
642
+ }
643
+ return { removed: true, milestone_id: milestoneId, note: 'Backend returned 500 but the milestone is gone.' };
644
+ }
645
+ }
414
646
  // -- Time logs ----------------------------------------------
415
647
  async addTimeLog(data) {
416
648
  return this.post('logs/add', buildTimeLogPayload(data, this.ctx));
@@ -62,6 +62,8 @@ export function featureForPath(path) {
62
62
  return 'logs';
63
63
  if (p.startsWith('prospect'))
64
64
  return 'crm';
65
+ if (p.startsWith('task/comment'))
66
+ return 'comment';
65
67
  if (p.startsWith('task'))
66
68
  return 'tasks';
67
69
  return 'auth';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Status and priority vocabularies as the platform actually defines them.
3
+ *
4
+ * Source of truth is the ViviScape Work UI, not guesswork:
5
+ * ClockWyzWeb/Views/App/Tasks.cshtml -- task status + priority selects
6
+ * ClockWyzWeb/Views/App/Projects.cshtml -- task editor and project status selects
7
+ * Cross-checked against live rows from account 1 (211 projects, 6 open tasks).
8
+ *
9
+ * Two traps worth knowing:
10
+ * - Tasks complete as "completed"; projects complete as "complete".
11
+ * - Priority is low/moderate/important/urgent/critical. It is NOT
12
+ * low/medium/high, which is what this server's task_add used to claim.
13
+ */
14
+ /** Task status values, in board order. */
15
+ export declare const TASK_STATUSES: readonly ["backlog", "new", "research", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
16
+ /** Task and project priority values, ascending. */
17
+ export declare const PRIORITIES: readonly ["low", "moderate", "important", "urgent", "critical"];
18
+ /**
19
+ * Project (group) status values. Narrower than task status, and note "complete"
20
+ * rather than "completed". An empty status reads as "Undetermined" in the UI.
21
+ */
22
+ export declare const PROJECT_STATUSES: readonly ["new", "inprogress", "complete"];
23
+ /** Prospect pipeline stages, from the CRM tools. */
24
+ export declare const PROSPECT_STATUSES: readonly ["new", "firstcontact", "negotiation", "pending", "won", "lost", "spam"];
25
+ /** Group type ids; 5 (PROJECT) is what project tools create and expect. */
26
+ export declare const GROUP_TYPES: {
27
+ readonly GENERAL: 1;
28
+ readonly TEAM: 3;
29
+ readonly SUPPORT: 4;
30
+ readonly PROJECT: 5;
31
+ };
32
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
33
+ export type Priority = (typeof PRIORITIES)[number];
34
+ export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
35
+ /** Payload for the enums tool, so callers can discover these without guessing. */
36
+ export declare function enumReference(): {
37
+ task_status: readonly ["backlog", "new", "research", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
38
+ priority: readonly ["low", "moderate", "important", "urgent", "critical"];
39
+ project_status: readonly ["new", "inprogress", "complete"];
40
+ prospect_status: readonly ["new", "firstcontact", "negotiation", "pending", "won", "lost", "spam"];
41
+ group_types: {
42
+ readonly GENERAL: 1;
43
+ readonly TEAM: 3;
44
+ readonly SUPPORT: 4;
45
+ readonly PROJECT: 5;
46
+ };
47
+ notes: string[];
48
+ };
package/dist/enums.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Status and priority vocabularies as the platform actually defines them.
3
+ *
4
+ * Source of truth is the ViviScape Work UI, not guesswork:
5
+ * ClockWyzWeb/Views/App/Tasks.cshtml -- task status + priority selects
6
+ * ClockWyzWeb/Views/App/Projects.cshtml -- task editor and project status selects
7
+ * Cross-checked against live rows from account 1 (211 projects, 6 open tasks).
8
+ *
9
+ * Two traps worth knowing:
10
+ * - Tasks complete as "completed"; projects complete as "complete".
11
+ * - Priority is low/moderate/important/urgent/critical. It is NOT
12
+ * low/medium/high, which is what this server's task_add used to claim.
13
+ */
14
+ /** Task status values, in board order. */
15
+ export const TASK_STATUSES = [
16
+ 'backlog',
17
+ 'new',
18
+ 'research',
19
+ 'inprogress',
20
+ 'pendingreview',
21
+ 'inreview',
22
+ 'testing',
23
+ 'waitingcustomer',
24
+ 'waitingteammember',
25
+ 'onhold',
26
+ 'cancelled',
27
+ 'completed',
28
+ ];
29
+ /** Task and project priority values, ascending. */
30
+ export const PRIORITIES = ['low', 'moderate', 'important', 'urgent', 'critical'];
31
+ /**
32
+ * Project (group) status values. Narrower than task status, and note "complete"
33
+ * rather than "completed". An empty status reads as "Undetermined" in the UI.
34
+ */
35
+ export const PROJECT_STATUSES = ['new', 'inprogress', 'complete'];
36
+ /** Prospect pipeline stages, from the CRM tools. */
37
+ export const PROSPECT_STATUSES = [
38
+ 'new',
39
+ 'firstcontact',
40
+ 'negotiation',
41
+ 'pending',
42
+ 'won',
43
+ 'lost',
44
+ 'spam',
45
+ ];
46
+ /** Group type ids; 5 (PROJECT) is what project tools create and expect. */
47
+ export const GROUP_TYPES = {
48
+ GENERAL: 1,
49
+ TEAM: 3,
50
+ SUPPORT: 4,
51
+ PROJECT: 5,
52
+ };
53
+ /** Payload for the enums tool, so callers can discover these without guessing. */
54
+ export function enumReference() {
55
+ return {
56
+ task_status: TASK_STATUSES,
57
+ priority: PRIORITIES,
58
+ project_status: PROJECT_STATUSES,
59
+ prospect_status: PROSPECT_STATUSES,
60
+ group_types: GROUP_TYPES,
61
+ notes: [
62
+ 'Tasks use "completed"; projects use "complete".',
63
+ 'Priority is low/moderate/important/urgent/critical -- not low/medium/high.',
64
+ 'An empty project status displays as "Undetermined".',
65
+ ],
66
+ };
67
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { config } from 'dotenv';
6
6
  import { ViviScapeClient } from './api-client.js';
7
7
  import { AuthService } from './auth/auth-service.js';
8
8
  import { describe } from './auth/credentials.js';
9
+ import { PRIORITIES, PROJECT_STATUSES, PROSPECT_STATUSES, TASK_STATUSES, enumReference, } from './enums.js';
10
+ import { PROJECT_FIELDS, TASK_FIELDS, filterTasks, shape, } from './projection.js';
9
11
  import { baseUrl } from './config.js';
10
12
  // quiet: dotenv's banner goes to stdout, which is the MCP protocol channel.
11
13
  config({ quiet: true });
@@ -37,6 +39,39 @@ const server = new McpServer({
37
39
  function json(data) {
38
40
  return JSON.stringify(data, null, 2);
39
41
  }
42
+ /**
43
+ * Phase 2 (docs/AGENT-CAPABILITY-SPEC.md): list rows carry ~90 columns, so every
44
+ * list tool takes a page window and an optional field list. Defaults keep the
45
+ * response inside a tool-result budget; fields:["all"] opts out.
46
+ */
47
+ const pageArgs = {
48
+ fields: z.array(z.string()).optional()
49
+ .describe('Columns to return. Omit for a PM-relevant default; ["all"] for every column.'),
50
+ limit: z.number().optional().describe('Max rows to return (default 50)'),
51
+ offset: z.number().optional().describe('Rows to skip, for paging (default 0)'),
52
+ };
53
+ const taskFilterArgs = {
54
+ company_id: z.number().optional().describe('Only tasks for this company'),
55
+ project_id: z.number().optional().describe('Only tasks in this project (group_id)'),
56
+ assignee_id: z.number().optional().describe('Only tasks assigned to this user id'),
57
+ status: z.string().optional().describe('Exact status match, e.g. new, inprogress'),
58
+ priority: z.string().optional().describe('Exact priority match, e.g. low, moderate, important, critical'),
59
+ due_before: z.string().optional().describe('Only tasks due on or before this date (ISO 8601)'),
60
+ due_after: z.string().optional().describe('Only tasks due on or after this date (ISO 8601)'),
61
+ search: z.string().optional().describe('Case-insensitive substring match on title, description, notes'),
62
+ };
63
+ // Variants that drop the key a given tool already takes as a required arg,
64
+ // so the spread cannot overwrite it with an optional one.
65
+ const { project_id: _omitProject, ...taskFilterArgsNoProject } = taskFilterArgs;
66
+ const { company_id: _omitCompany, ...taskFilterArgsNoCompany } = taskFilterArgs;
67
+ /** Split a tool's params into page options and everything else. */
68
+ function paging(p) {
69
+ return {
70
+ fields: p.fields,
71
+ limit: p.limit,
72
+ offset: p.offset,
73
+ };
74
+ }
40
75
  // ============================================================
41
76
  // AUTH
42
77
  // ============================================================
@@ -112,7 +147,7 @@ server.tool('prospect_add', 'Add a new prospect/lead to the ViviScape CRM', {
112
147
  source: z.string().optional().describe('Lead source (e.g., "Website", "ROI Calculator")'),
113
148
  source_url: z.string().optional().describe('URL where the lead came from'),
114
149
  referred_by: z.string().optional().describe('Referral source'),
115
- status: z.string().optional().describe('Status: new, firstcontact, negotiation, pending, won, lost, spam'),
150
+ status: z.enum(PROSPECT_STATUSES).optional().describe('Pipeline stage (platform values)'),
116
151
  }, async (params) => {
117
152
  const result = await requireClient().addProspect(params);
118
153
  return { content: [{ type: 'text', text: json(result) }] };
@@ -127,7 +162,7 @@ server.tool('prospect_update', 'Update an existing prospect in the CRM', {
127
162
  org: z.string().optional(),
128
163
  note: z.string().optional(),
129
164
  source: z.string().optional(),
130
- status: z.string().optional().describe('Status: new, firstcontact, negotiation, pending, won, lost, spam'),
165
+ status: z.enum(PROSPECT_STATUSES).optional().describe('Pipeline stage (platform values)'),
131
166
  priority: z.number().optional().describe('Priority level'),
132
167
  }, async (params) => {
133
168
  const result = await requireClient().updateProspect(params);
@@ -272,13 +307,17 @@ server.tool('clients_by_company', 'Get all clients for a company', { company_id:
272
307
  // ============================================================
273
308
  // PROJECTS & TASKS
274
309
  // ============================================================
275
- server.tool('project_list', 'Get all projects for current user', {}, async () => {
310
+ server.tool('project_list', 'Get all projects for current user. Returns a page of trimmed rows; see fields/limit/offset.', {
311
+ ...pageArgs,
312
+ }, async (params) => {
276
313
  const result = await requireClient().getMyProjects();
277
- return { content: [{ type: 'text', text: json(result) }] };
314
+ return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
278
315
  });
279
- server.tool('project_list_active', 'Get active projects for current user', {}, async () => {
316
+ server.tool('project_list_active', 'Get active projects for current user. Returns a page of trimmed rows; see fields/limit/offset.', {
317
+ ...pageArgs,
318
+ }, async (params) => {
280
319
  const result = await requireClient().getActiveProjects();
281
- return { content: [{ type: 'text', text: json(result) }] };
320
+ return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
282
321
  });
283
322
  server.tool('project_get', 'Get a project by ID', { project_id: z.number().describe('Project ID') }, async ({ project_id }) => {
284
323
  const result = await requireClient().getProjectById(project_id);
@@ -288,23 +327,43 @@ server.tool('project_staff', 'Get staff members assigned to a project', { projec
288
327
  const result = await requireClient().getProjectStaff(project_id);
289
328
  return { content: [{ type: 'text', text: json(result) }] };
290
329
  });
291
- server.tool('project_tasks', 'Get all tasks for a project', { project_id: z.number().describe('Project ID') }, async ({ project_id }) => {
292
- const result = await requireClient().getProjectTasks(project_id);
293
- return { content: [{ type: 'text', text: json(result) }] };
294
- });
295
- server.tool('tasks_open', 'Get all open tasks across projects', {}, async () => {
296
- const result = await requireClient().getOpenTasks();
297
- return { content: [{ type: 'text', text: json(result) }] };
330
+ server.tool('project_tasks', 'Get all tasks for a project. Returns a page of trimmed rows; see fields/limit/offset.', {
331
+ project_id: z.number().describe('Project ID'),
332
+ ...pageArgs,
333
+ ...taskFilterArgsNoProject,
334
+ }, async (params) => {
335
+ const result = await requireClient().getProjectTasks(params.project_id);
336
+ // project_id is the query, not a filter -- re-filtering on it would drop
337
+ // every row if the response names the column differently.
338
+ const rows = Array.isArray(result)
339
+ ? filterTasks(result, { ...params, project_id: undefined })
340
+ : result;
341
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
342
+ });
343
+ server.tool('tasks_open', 'Get open tasks across projects. company_id and only_mine filter server-side; the rest filter in-process. Returns a page of trimmed rows.', {
344
+ only_mine: z.boolean().optional().describe('Only tasks assigned to the signed-in user (server-side)'),
345
+ team: z.string().optional().describe('Team filter passed through to the API'),
346
+ ...taskFilterArgs,
347
+ ...pageArgs,
348
+ }, async (params) => {
349
+ const result = await requireClient().getOpenTasks({
350
+ company_id: params.company_id,
351
+ only_mine: params.only_mine,
352
+ team: params.team,
353
+ });
354
+ const rows = Array.isArray(result) ? filterTasks(result, params) : result;
355
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
298
356
  });
299
357
  server.tool('task_add', 'Add a task to a project. The API requires a full Group_Task payload; this tool builds it from simplified params.', {
300
358
  project_id: z.number().describe('Project ID (group_id)'),
301
359
  title: z.string().describe('Task title'),
302
360
  description: z.string().optional().describe('Task description'),
303
361
  notes: z.string().optional().describe('Task notes'),
304
- assigned_to: z.number().optional().describe('User ID to assign to (also sets creator). Defaults to Art Hicks (210)'),
362
+ assigned_to: z.number().optional().describe('User ID to assign the task to. The signed-in user is always recorded as creator.'),
305
363
  company_id: z.number().optional().describe('Company ID associated with the project'),
306
364
  service_id: z.number().optional().describe('Service ID classifying the work type'),
307
- priority: z.string().optional().describe('Priority: low, medium, high, critical'),
365
+ priority: z.enum(PRIORITIES).optional().describe('Priority (platform values)'),
366
+ status: z.enum(TASK_STATUSES).optional().describe('Task status (platform values)'),
308
367
  estimated_hours: z.string().optional().describe('Estimated time in shorthand (e.g., 6h, 2h30m)'),
309
368
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
310
369
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
@@ -318,8 +377,8 @@ server.tool('task_update', 'Update a task. Fetches existing task first and merge
318
377
  title: z.string().optional().describe('Task title'),
319
378
  description: z.string().optional(),
320
379
  assigned_to: z.number().optional().describe('User ID to assign to'),
321
- priority: z.string().optional().describe('Priority: low, medium, high, critical'),
322
- status: z.string().optional().describe('Status: new, inprogress, complete, onhold'),
380
+ priority: z.enum(PRIORITIES).optional().describe('Priority (platform values)'),
381
+ status: z.enum(TASK_STATUSES).optional().describe('Task status (platform values)'),
323
382
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
324
383
  }, async (params) => {
325
384
  const result = await requireClient().updateTask(params);
@@ -329,34 +388,248 @@ server.tool('task_get', 'Get a single task by ID', { task_id: z.number().describ
329
388
  const result = await requireClient().getTask(task_id);
330
389
  return { content: [{ type: 'text', text: json(result) }] };
331
390
  });
332
- server.tool('tasks_pending', 'Get pending tasks for a user', { user_id: z.number().describe('User ID') }, async ({ user_id }) => {
333
- const result = await requireClient().getPendingTasks(user_id);
391
+ server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of trimmed rows; see fields/limit/offset.', {
392
+ user_id: z.number().describe('User ID'),
393
+ ...pageArgs,
394
+ ...taskFilterArgs,
395
+ }, async (params) => {
396
+ const result = await requireClient().getPendingTasks(params.user_id);
397
+ const rows = Array.isArray(result) ? filterTasks(result, params) : result;
398
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
399
+ });
400
+ server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of trimmed rows; see fields/limit/offset.', {
401
+ milestone_id: z.number().describe('Milestone ID'),
402
+ ...pageArgs,
403
+ ...taskFilterArgs,
404
+ }, async (params) => {
405
+ const result = await requireClient().getTasksByMilestone(params.milestone_id);
406
+ const rows = Array.isArray(result) ? filterTasks(result, params) : result;
407
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
408
+ });
409
+ server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns a page of trimmed rows; see fields/limit/offset.', {
410
+ group_id: z.number().describe('Project group ID'),
411
+ user_id: z.number().describe('User ID'),
412
+ ...pageArgs,
413
+ ...taskFilterArgs,
414
+ }, async (params) => {
415
+ const result = await requireClient().getTasksByGroupAndUser(params.group_id, params.user_id);
416
+ const rows = Array.isArray(result) ? filterTasks(result, params) : result;
417
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
418
+ });
419
+ server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trimmed rows; see fields/limit/offset.', {
420
+ company_id: z.number().describe('Company ID'),
421
+ ...pageArgs,
422
+ ...taskFilterArgsNoCompany,
423
+ }, async (params) => {
424
+ const result = await requireClient().getCompanyTasks(params.company_id);
425
+ const rows = Array.isArray(result)
426
+ ? filterTasks(result, { ...params, company_id: undefined })
427
+ : result;
428
+ return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
429
+ });
430
+ server.tool('projects_by_company', 'Get user projects filtered by company. Returns a page of trimmed rows; see fields/limit/offset.', {
431
+ user_id: z.number().describe('User ID'),
432
+ company_id: z.number().describe('Company ID'),
433
+ ...pageArgs,
434
+ }, async (params) => {
435
+ const result = await requireClient().getProjectsByCompany(params.user_id, params.company_id);
436
+ return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
437
+ });
438
+ server.tool('projects_active_by_user', 'Get active projects for a specific user. Returns a page of trimmed rows; see fields/limit/offset.', {
439
+ user_id: z.number().describe('User ID'),
440
+ ...pageArgs,
441
+ }, async (params) => {
442
+ const result = await requireClient().getActiveProjectsByUser(params.user_id);
443
+ return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
444
+ });
445
+ // ============================================================
446
+ // REFERENCE
447
+ // ============================================================
448
+ server.tool('enums', 'List the status, priority, and group-type values the platform actually accepts. Read this before setting a status or priority; tasks complete as "completed" while projects complete as "complete", and priority is low/moderate/important/urgent/critical.', {}, async () => ({ content: [{ type: 'text', text: json(enumReference()) }] }));
449
+ // ============================================================
450
+ // PROJECT WRITES
451
+ // ============================================================
452
+ server.tool('project_add', 'Create a project for a company. NOTE: the API has no project-delete route, so a project created here can only be archived or renamed afterward.', {
453
+ company_id: z.number().describe('Company ID the project belongs to'),
454
+ name: z.string().describe('Project name'),
455
+ short_description: z.string().optional().describe('One-line summary'),
456
+ description: z.string().optional().describe('Full description'),
457
+ status: z.enum(PROJECT_STATUSES).optional().describe('Initial status (default new)'),
458
+ is_public: z.boolean().optional().describe('Visible to clients (default false)'),
459
+ staff: z.array(z.number()).optional().describe('User ids to staff; defaults to the signed-in user'),
460
+ }, async (params) => {
461
+ const result = await requireClient().addProject(params);
334
462
  return { content: [{ type: 'text', text: json(result) }] };
335
463
  });
336
- server.tool('tasks_by_milestone', 'Get tasks for a milestone', { milestone_id: z.number().describe('Milestone ID') }, async ({ milestone_id }) => {
337
- const result = await requireClient().getTasksByMilestone(milestone_id);
464
+ server.tool('project_update', 'Update a project. Reads the current row and merges your changes onto it, so only pass what changes. Field names are the raw GEN_Group ones (strGroupName, strGroupDescription, bitPublic, target_date, ...).', {
465
+ project_id: z.number().describe('Project ID (intGroupId)'),
466
+ changes: z.record(z.string(), z.unknown()).describe('Fields to overwrite, e.g. {"strGroupName":"New name"}'),
467
+ }, async ({ project_id, changes }) => {
468
+ const result = await requireClient().updateProject(project_id, changes);
338
469
  return { content: [{ type: 'text', text: json(result) }] };
339
470
  });
340
- server.tool('tasks_by_group_user', 'Get tasks by project group and user', {
341
- group_id: z.number().describe('Project group ID'),
342
- user_id: z.number().describe('User ID'),
343
- }, async ({ group_id, user_id }) => {
344
- const result = await requireClient().getTasksByGroupAndUser(group_id, user_id);
471
+ server.tool('project_set_status', 'Set a project status without touching its budget or approved time. Project status values are new, inprogress, complete -- note "complete", not "completed".', {
472
+ project_id: z.number().describe('Project ID'),
473
+ status: z.enum(PROJECT_STATUSES).describe('New status'),
474
+ billable: z.boolean().optional().describe('Billable flag (default true)'),
475
+ }, async ({ project_id, status, billable }) => {
476
+ const result = await requireClient().setProjectStatus(project_id, status, billable ?? true);
477
+ return { content: [{ type: 'text', text: json(result) }] };
478
+ });
479
+ server.tool('project_user_add', 'Add a staff member to a project, optionally as lead or client contact', {
480
+ project_id: z.number().describe('Project ID'),
481
+ user_id: z.number().describe('User ID to add'),
482
+ is_lead: z.boolean().optional().describe('Make this user the project lead'),
483
+ is_contact: z.boolean().optional().describe('Mark as the client contact'),
484
+ }, async ({ project_id, user_id, is_lead, is_contact }) => {
485
+ const result = await requireClient().addProjectUser(project_id, user_id, { is_lead, is_contact });
486
+ return { content: [{ type: 'text', text: json(result) }] };
487
+ });
488
+ server.tool('project_user_remove', 'Remove a staff member from a project', {
489
+ project_id: z.number().describe('Project ID'),
490
+ user_id: z.number().describe('User ID to remove'),
491
+ }, async ({ project_id, user_id }) => {
492
+ const result = await requireClient().removeProjectUser(project_id, user_id);
493
+ return { content: [{ type: 'text', text: json(result) }] };
494
+ });
495
+ // ============================================================
496
+ // MILESTONES
497
+ // ============================================================
498
+ server.tool('milestone_list', 'List milestones for a project', { project_id: z.number().describe('Project ID') }, async ({ project_id }) => {
499
+ const result = await requireClient().getMilestones(project_id);
345
500
  return { content: [{ type: 'text', text: json(result) }] };
346
501
  });
347
- server.tool('tasks_by_company', 'Get tasks for a company', { company_id: z.number().describe('Company ID') }, async ({ company_id }) => {
348
- const result = await requireClient().getCompanyTasks(company_id);
502
+ server.tool('milestones_active', 'List active milestones across the account', {}, async () => {
503
+ const result = await requireClient().getActiveMilestones();
349
504
  return { content: [{ type: 'text', text: json(result) }] };
350
505
  });
351
- server.tool('projects_by_company', 'Get user projects filtered by company', {
506
+ server.tool('milestones_by_company', 'List milestones for a company', { company_id: z.number().describe('Company ID') }, async ({ company_id }) => {
507
+ const result = await requireClient().getMilestonesByCompany(company_id);
508
+ return { content: [{ type: 'text', text: json(result) }] };
509
+ });
510
+ server.tool('milestones_by_user', 'List active milestones for a user (defaults to the signed-in user)', { user_id: z.number().optional().describe('User ID') }, async ({ user_id }) => {
511
+ const result = await requireClient().getMilestonesByUser(user_id ?? 0);
512
+ return { content: [{ type: 'text', text: json(result) }] };
513
+ });
514
+ server.tool('milestone_get', 'Get a milestone by ID', { milestone_id: z.number().describe('Milestone ID') }, async ({ milestone_id }) => {
515
+ const result = await requireClient().getMilestone(milestone_id);
516
+ return { content: [{ type: 'text', text: json(result) }] };
517
+ });
518
+ server.tool('milestone_add', 'Create a milestone in a project. Use this to give tasks a phase to hang off; tasks reference it via milestone_id.', {
519
+ project_id: z.number().describe('Project ID the milestone belongs to'),
520
+ title: z.string().describe('Milestone name'),
521
+ sequence: z.number().optional().describe('Order within the project'),
522
+ start_date: z.string().optional().describe('Start date (ISO 8601)'),
523
+ due_date: z.string().optional().describe('Due date (ISO 8601)'),
524
+ estimated_hours: z.string().optional().describe('Estimate in shorthand (e.g., 40h, 2h30m)'),
525
+ }, async (params) => {
526
+ const result = await requireClient().addMilestone(params);
527
+ return { content: [{ type: 'text', text: json(result) }] };
528
+ });
529
+ server.tool('milestone_update', 'Update a milestone. Reads the current record and merges your changes onto it.', {
530
+ milestone_id: z.number().describe('Milestone ID'),
531
+ title: z.string().optional().describe('New name'),
532
+ sequence: z.number().optional().describe('Order within the project'),
533
+ start_date: z.string().optional().describe('Start date (ISO 8601)'),
534
+ due_date: z.string().optional().describe('Due date (ISO 8601)'),
535
+ estimated_hours: z.string().optional().describe('Estimate in shorthand'),
536
+ progress: z.number().optional().describe('Percent complete (0-100)'),
537
+ complete: z.boolean().optional().describe('Mark complete'),
538
+ }, async (params) => {
539
+ const result = await requireClient().updateMilestone(params);
540
+ return { content: [{ type: 'text', text: json(result) }] };
541
+ });
542
+ server.tool('milestone_clone', 'Copy an existing milestone into a project. Useful for repeatable delivery templates.', {
543
+ milestone_id: z.number().describe('Milestone ID to copy'),
544
+ project_id: z.number().describe('Project ID to copy it into'),
545
+ title: z.string().optional().describe('Name for the copy (defaults to the original name)'),
546
+ }, async ({ milestone_id, project_id, title }) => {
547
+ const result = await requireClient().cloneMilestone(milestone_id, project_id, title);
548
+ return { content: [{ type: 'text', text: json(result) }] };
549
+ });
550
+ server.tool('milestone_remove', 'Delete a milestone', { milestone_id: z.number().describe('Milestone ID') }, async ({ milestone_id }) => {
551
+ const result = await requireClient().removeMilestone(milestone_id);
552
+ return { content: [{ type: 'text', text: json(result) }] };
553
+ });
554
+ // ============================================================
555
+ // TASK ASSIGNMENT AND LIFECYCLE
556
+ // ============================================================
557
+ server.tool('task_assignee_add', 'Assign a user to a task', {
558
+ task_id: z.number().describe('Task ID'),
559
+ user_id: z.number().describe('User ID to assign'),
560
+ leader: z.boolean().optional().describe('Make this user the task leader'),
561
+ attention: z.boolean().optional().describe('Flag the task for this user\'s attention'),
562
+ notify: z.boolean().optional().describe('Notify the user (default true)'),
563
+ }, async (params) => {
564
+ const result = await requireClient().addTaskAssignee(params);
565
+ return { content: [{ type: 'text', text: json(result) }] };
566
+ });
567
+ server.tool('task_assignee_remove', 'Unassign a user from a task. Takes the assignee_id from the task staff_assignees list, NOT a user_id.', { assignee_id: z.number().describe('Assignee record ID (from staff_assignees)') }, async ({ assignee_id }) => {
568
+ const result = await requireClient().removeTaskAssignee(assignee_id);
569
+ return { content: [{ type: 'text', text: json(result) }] };
570
+ });
571
+ server.tool('task_set_leader', 'Make a user the leader on a task', {
572
+ task_id: z.number().describe('Task ID'),
573
+ user_id: z.number().describe('User ID to make leader'),
574
+ }, async ({ task_id, user_id }) => {
575
+ const result = await requireClient().setTaskLeader(task_id, user_id);
576
+ return { content: [{ type: 'text', text: json(result) }] };
577
+ });
578
+ server.tool('task_set_attention', 'Set or clear the attention flag for a user on a task', {
579
+ task_id: z.number().describe('Task ID'),
352
580
  user_id: z.number().describe('User ID'),
353
- company_id: z.number().describe('Company ID'),
354
- }, async ({ user_id, company_id }) => {
355
- const result = await requireClient().getProjectsByCompany(user_id, company_id);
581
+ flag: z.boolean().describe('true to flag, false to clear'),
582
+ }, async ({ task_id, user_id, flag }) => {
583
+ const result = await requireClient().setTaskAttention(task_id, user_id, flag);
584
+ return { content: [{ type: 'text', text: json(result) }] };
585
+ });
586
+ server.tool('task_delete', 'Delete a task permanently. There is no undo; prefer setting status to cancelled unless the task was created in error.', { task_id: z.number().describe('Task ID') }, async ({ task_id }) => {
587
+ const result = await requireClient().deleteTask(task_id);
588
+ return { content: [{ type: 'text', text: json(result) }] };
589
+ });
590
+ server.tool('task_merge', 'Merge one task into another. The source task is consumed by the target; use this to clean up duplicates.', {
591
+ from_task_id: z.number().describe('Task to merge from (consumed)'),
592
+ to_task_id: z.number().describe('Task to merge into (kept)'),
593
+ }, async ({ from_task_id, to_task_id }) => {
594
+ const result = await requireClient().mergeTasks(from_task_id, to_task_id);
595
+ return { content: [{ type: 'text', text: json(result) }] };
596
+ });
597
+ // ============================================================
598
+ // TASK COMMENTS
599
+ // ============================================================
600
+ server.tool('task_comments', 'Get all comments on a task. Task rows report total_comments and unread_for_me; this reads the thread itself.', { task_id: z.number().describe('Task ID') }, async ({ task_id }) => {
601
+ const result = await requireClient().getTaskComments(task_id);
602
+ return { content: [{ type: 'text', text: json(result) }] };
603
+ });
604
+ server.tool('task_comment_get', 'Get a single task comment by ID', { comment_id: z.string().describe('Comment ID (GUID)') }, async ({ comment_id }) => {
605
+ const result = await requireClient().getTaskComment(comment_id);
606
+ return { content: [{ type: 'text', text: json(result) }] };
607
+ });
608
+ server.tool('task_comment_add', 'Add a comment to a task. WARNING: on this platform account the backend fires a client-notification webhook for comments on tasks whose client_id > 0, so a comment can reach the customer. Check the task client_id first if that matters.', {
609
+ task_id: z.number().describe('Task ID'),
610
+ comment: z.string().describe('Comment text'),
611
+ client_id: z.number().optional().describe('Client ID to attribute the comment to (default 0 = staff comment)'),
612
+ }, async (params) => {
613
+ const result = await requireClient().addTaskComment(params);
614
+ return { content: [{ type: 'text', text: json(result) }] };
615
+ });
616
+ server.tool('task_comment_update', 'Update the text of an existing task comment', {
617
+ comment_id: z.string().describe('Comment ID (GUID)'),
618
+ task_id: z.number().describe('Task ID the comment belongs to'),
619
+ comment: z.string().describe('Replacement comment text'),
620
+ }, async (params) => {
621
+ const result = await requireClient().updateTaskComment(params);
622
+ return { content: [{ type: 'text', text: json(result) }] };
623
+ });
624
+ server.tool('task_comment_remove', 'Remove a task comment by ID. Pass task_id: the backend answers 500 even when the delete succeeds, and task_id lets the removal be verified instead of reported as a failure.', {
625
+ comment_id: z.string().describe('Comment ID (GUID)'),
626
+ task_id: z.number().optional().describe('Task the comment belongs to, used to verify removal'),
627
+ }, async ({ comment_id, task_id }) => {
628
+ const result = await requireClient().removeTaskComment(comment_id, task_id);
356
629
  return { content: [{ type: 'text', text: json(result) }] };
357
630
  });
358
- server.tool('projects_active_by_user', 'Get active projects for a specific user', { user_id: z.number().describe('User ID') }, async ({ user_id }) => {
359
- const result = await requireClient().getActiveProjectsByUser(user_id);
631
+ server.tool('task_mark_read', 'Mark a task read for the signed-in user, clearing unread_for_me', { task_id: z.number().describe('Task ID') }, async ({ task_id }) => {
632
+ const result = await requireClient().markTaskRead(task_id);
360
633
  return { content: [{ type: 'text', text: json(result) }] };
361
634
  });
362
635
  // ============================================================
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Response shaping for list tools -- phase 2 of docs/AGENT-CAPABILITY-SPEC.md.
3
+ *
4
+ * The core API returns every column it has: a task row carries ~90 fields
5
+ * (including fleet columns like vin and unit_class) and a project row carries 87
6
+ * (including video-conference config and welcome messages). Six open tasks came
7
+ * back as 102 KB; 211 active projects as 487 KB, which no caller can afford to
8
+ * read. These helpers trim to a PM-relevant default, page the result, and tell
9
+ * the caller what was left out so it can ask for more deliberately.
10
+ */
11
+ /** Task fields worth returning by default, of roughly 90 available. */
12
+ export declare const TASK_FIELDS: string[];
13
+ /** Project (group) fields worth returning by default, of 87 available. */
14
+ export declare const PROJECT_FIELDS: string[];
15
+ export declare const DEFAULT_LIMIT = 50;
16
+ export interface ShapeOpts {
17
+ fields?: string[];
18
+ limit?: number;
19
+ offset?: number;
20
+ }
21
+ export interface TaskFilters {
22
+ company_id?: number;
23
+ project_id?: number;
24
+ assignee_id?: number;
25
+ status?: string;
26
+ priority?: string;
27
+ due_before?: string;
28
+ due_after?: string;
29
+ search?: string;
30
+ }
31
+ /** Apply the filters the backend does not support, in-process. */
32
+ export declare function filterTasks(rows: unknown[], f: TaskFilters): unknown[];
33
+ /**
34
+ * Trim, page, and annotate a list response. Non-array payloads (a single record,
35
+ * or an error object) pass through untouched.
36
+ *
37
+ * Pass fields: ['all'] to opt out of projection and get every column.
38
+ */
39
+ export declare function shape(payload: unknown, defaults: string[], opts?: ShapeOpts): unknown;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Response shaping for list tools -- phase 2 of docs/AGENT-CAPABILITY-SPEC.md.
3
+ *
4
+ * The core API returns every column it has: a task row carries ~90 fields
5
+ * (including fleet columns like vin and unit_class) and a project row carries 87
6
+ * (including video-conference config and welcome messages). Six open tasks came
7
+ * back as 102 KB; 211 active projects as 487 KB, which no caller can afford to
8
+ * read. These helpers trim to a PM-relevant default, page the result, and tell
9
+ * the caller what was left out so it can ask for more deliberately.
10
+ */
11
+ /** Task fields worth returning by default, of roughly 90 available. */
12
+ export const TASK_FIELDS = [
13
+ 'task_id', 'task', 'status', 'percentage', 'priority',
14
+ 'company_id', 'company_name', 'group_id', 'group_name',
15
+ 'milestone_id', 'milestone', 'estimated_time', 'total_duration_str',
16
+ 'deadline', 'start', 'end', 'staff_assignees', 'total_comments', 'unread_for_me',
17
+ ];
18
+ /** Project (group) fields worth returning by default, of 87 available. */
19
+ export const PROJECT_FIELDS = [
20
+ 'intGroupId', 'strGroupName', 'intCompanyId', 'strCompany',
21
+ 'status', 'bitActive', 'group_type', 'percentage',
22
+ 'total_tasks', 'total_tasks_open', 'total_tasks_completed',
23
+ 'target_date', 'complete_date', 'total_estimated_str', 'total_duration_str',
24
+ ];
25
+ export const DEFAULT_LIMIT = 50;
26
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ /** Parse the staff_assignees blob, which arrives as JSON text or as an array. */
28
+ function assigneeIds(row) {
29
+ const raw = row.staff_assignees;
30
+ let arr = raw;
31
+ if (typeof raw === 'string') {
32
+ try {
33
+ arr = JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ if (!Array.isArray(arr))
40
+ return [];
41
+ return arr
42
+ .map((u) => (isRecord(u) ? Number(u.user_id) : Number(u)))
43
+ .filter((n) => Number.isFinite(n));
44
+ }
45
+ const lower = (v) => String(v ?? '').toLowerCase();
46
+ /** Apply the filters the backend does not support, in-process. */
47
+ export function filterTasks(rows, f) {
48
+ return rows.filter((row) => {
49
+ if (!isRecord(row))
50
+ return true;
51
+ if (f.company_id && Number(row.company_id) !== f.company_id)
52
+ return false;
53
+ if (f.project_id && Number(row.group_id) !== f.project_id)
54
+ return false;
55
+ if (f.status && lower(row.status) !== f.status.toLowerCase())
56
+ return false;
57
+ if (f.priority && lower(row.priority) !== f.priority.toLowerCase())
58
+ return false;
59
+ if (f.assignee_id && !assigneeIds(row).includes(f.assignee_id))
60
+ return false;
61
+ if (f.search) {
62
+ const hay = `${lower(row.task)} ${lower(row.description)} ${lower(row.notes)}`;
63
+ if (!hay.includes(f.search.toLowerCase()))
64
+ return false;
65
+ }
66
+ // `end` is the task's own due date; `deadline` is a human string ("In 2 Days").
67
+ if (f.due_before || f.due_after) {
68
+ const due = Date.parse(String(row.end ?? ''));
69
+ if (!Number.isFinite(due))
70
+ return false;
71
+ if (f.due_before && due > Date.parse(f.due_before))
72
+ return false;
73
+ if (f.due_after && due < Date.parse(f.due_after))
74
+ return false;
75
+ }
76
+ return true;
77
+ });
78
+ }
79
+ function pick(row, fields) {
80
+ if (!isRecord(row))
81
+ return row;
82
+ const out = {};
83
+ for (const f of fields)
84
+ if (f in row)
85
+ out[f] = row[f];
86
+ return out;
87
+ }
88
+ /**
89
+ * Trim, page, and annotate a list response. Non-array payloads (a single record,
90
+ * or an error object) pass through untouched.
91
+ *
92
+ * Pass fields: ['all'] to opt out of projection and get every column.
93
+ */
94
+ export function shape(payload, defaults, opts = {}) {
95
+ if (!Array.isArray(payload))
96
+ return payload;
97
+ const total = payload.length;
98
+ const offset = Math.max(0, opts.offset ?? 0);
99
+ const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
100
+ const page = payload.slice(offset, offset + limit);
101
+ const all = opts.fields?.length === 1 && opts.fields[0] === 'all';
102
+ const fields = all ? null : (opts.fields?.length ? opts.fields : defaults);
103
+ const items = fields ? page.map((r) => pick(r, fields)) : page;
104
+ const available = isRecord(payload[0]) ? Object.keys(payload[0]).length : 0;
105
+ const next = offset + page.length;
106
+ return {
107
+ total,
108
+ returned: page.length,
109
+ offset,
110
+ ...(next < total ? { next_offset: next } : {}),
111
+ fields: fields
112
+ ? `${fields.length} of ${available} columns; pass fields:["all"] or a specific list for more`
113
+ : `all ${available} columns`,
114
+ items,
115
+ };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server for the ViviScape API — CRM, projects, companies, notes, and insights, authenticated as the signed-in ViviScape user",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",