viviscape-mcp 2.4.0 → 2.6.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
@@ -107,6 +107,8 @@ The server exposes tools across these domains:
107
107
  - **Tasks** - `task_add`, `task_get`, `task_update`, `tasks_open`, `tasks_pending`, `tasks_by_company`, `tasks_by_milestone`
108
108
  - **Task comments** - `task_comments`, `task_comment_get`, `task_comment_add`, `task_comment_update`, `task_comment_remove`, `task_mark_read`
109
109
  - **Task assignment** - `task_assignee_add`, `task_assignee_remove`, `task_set_leader`, `task_set_attention`, `task_delete`, `task_merge`
110
+ - **Task tags** - `task_tags` (list/add/remove), and a `tags` list on `task_add`
111
+ - **Task references** - `task_references` (list/add/remove), `task_search` (find a task by keyword or id)
110
112
  - **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
111
113
  - **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
112
114
  - **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
@@ -126,7 +128,7 @@ Use the `enums` tool rather than guessing. The platform's vocabularies are:
126
128
 
127
129
  | field | values |
128
130
  |---|---|
129
- | task status | backlog, new, research, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
131
+ | task status | backlog, new, research, discussion, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
130
132
  | priority | low, moderate, important, urgent, critical |
131
133
  | project status | new, inprogress, complete |
132
134
  | prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
@@ -136,6 +138,56 @@ Two traps: tasks complete as `completed` while projects complete as
136
138
  validate these with `z.enum`, so an invalid value is rejected before it reaches
137
139
  the API.
138
140
 
141
+ ### Task tags
142
+
143
+ `task_tags` lists, adds, and removes the tags shown on the task board, and
144
+ `task_add` takes a `tags` list so a generated task lands already categorised
145
+ (handy for marking agent-written work).
146
+
147
+ Verified behaviour, so callers do not have to discover it:
148
+
149
+ - Labels are stored upper-case, and `add` is idempotent per label - re-adding
150
+ one returns the existing tag, not a duplicate.
151
+ - `add` returns the persisted tag with its `tag_id`; `remove` takes that
152
+ `tag_id`, not the label, and answers `true` (unlike `note_tags`, whose remove
153
+ answers `false` even on success).
154
+ - `color` is any hex string, defaulting to the board palette's first entry
155
+ (`#00325e`).
156
+ - Tags are per task. There is no tag-search route, and list rows from
157
+ `tasks_open` / `project_tasks` carry `tags: null`, so read a task's tags with
158
+ `task_tags` or `task_get`.
159
+ - A tag failure inside `task_add` never fails the create: the task is returned
160
+ with the per-tag error recorded in its `tags` array.
161
+
162
+ ### Task references
163
+
164
+ `task_references` links related tasks to each other - a duplicate, a blocker,
165
+ the ticket a task came from - and `task_search` finds the id to link when only
166
+ a keyword is known.
167
+
168
+ Verified behaviour:
169
+
170
+ - Links are **symmetric**. Adding A to B makes the pair visible from both
171
+ tasks, so link once; mirroring it by hand just returns the same row.
172
+ - `list` rows describe the *other* task: `related_task_id` plus its title,
173
+ status, project and company, so a reference list needs no follow-up
174
+ `task_get`.
175
+ - `add` is idempotent per pair (in either direction) and returns the persisted
176
+ link with its `reference_id`; `remove` takes that `reference_id`, not a task
177
+ id, and answers `true`.
178
+ - Both tasks must belong to the signed-in account; a cross-account id comes
179
+ back as an empty link rather than an error.
180
+ - `task_search` matches the task title, the description, and - for a numeric
181
+ query - the task id itself. It covers tickets too, so it is the one keyword
182
+ route that reaches ticket rows `tasks_open` never returns.
183
+
184
+ ### Task notes
185
+
186
+ `task_update` takes a `notes` field: the free-form working notes on the task's
187
+ Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
188
+ outright, so read the current value with `task_get` and send the merged text
189
+ rather than only the new lines.
190
+
139
191
  ### Repeat-safe creates
140
192
 
141
193
  No create route accepts an idempotency key, so a retried agent step silently
@@ -7,6 +7,12 @@ export declare class ApiError extends Error {
7
7
  status: number;
8
8
  constructor(status: number, message: string);
9
9
  }
10
+ /**
11
+ * Tag colours the ViviScape Work task board offers (tasks.js \$scope.tagPalette).
12
+ * The API takes any hex string; staying inside the palette keeps agent-written
13
+ * tags visually consistent with the ones people add by hand. First is default.
14
+ */
15
+ export declare const TAG_PALETTE: readonly ["#00325e", "#13B955", "#EFA31D", "#FF5757", "#009CDC", "#6610f2", "#fd7e14", "#e83e8c", "#20c997", "#868e96"];
10
16
  /**
11
17
  * Client for the ViviScape Work core API. Authenticates exactly like the
12
18
  * ViviScape CLI: the signed-in user's bearer token plus the user_id / pid
@@ -147,6 +153,30 @@ export declare class ViviScapeClient {
147
153
  deleteTask(taskId: number): Promise<unknown>;
148
154
  /** Fold one task into another; the source task is consumed. */
149
155
  mergeTasks(fromTaskId: number, toTaskId: number): Promise<unknown>;
156
+ getTaskTags(taskId: number): Promise<unknown>;
157
+ /**
158
+ * Tag a task. The web app upper-cases the label and always sends a colour, so
159
+ * match it: tags written by an agent then look and sort like the human ones.
160
+ * Returns the persisted Group_Task_Tag, including its tag_id.
161
+ */
162
+ addTaskTag(taskId: number, tag: string, color?: string): Promise<unknown>;
163
+ /** Takes the tag_id from getTaskTags, not the label. */
164
+ removeTaskTag(tagId: string): Promise<unknown>;
165
+ /**
166
+ * Cross references between tasks. The link is symmetric: adding A -> B makes
167
+ * the pair visible from both tasks, and each row describes the OTHER task
168
+ * (related_task_id, its title and status), so no second fetch is needed.
169
+ */
170
+ getTaskReferences(taskId: number): Promise<unknown>;
171
+ /** Idempotent per pair: re-adding an existing link returns that link. */
172
+ addTaskReference(taskId: number, relatedTaskId: number, note?: string): Promise<unknown>;
173
+ /** Takes the reference_id from getTaskReferences, not a task id. */
174
+ removeTaskReference(referenceId: string): Promise<unknown>;
175
+ /** Keyword search over the account's tasks -- matches title, description and task id. */
176
+ searchTasks(query: string, opts?: {
177
+ exclude_task_id?: number;
178
+ max?: number;
179
+ }): Promise<unknown>;
150
180
  getMilestones(projectId: number): Promise<unknown>;
151
181
  getActiveMilestones(): Promise<unknown>;
152
182
  getMilestonesByCompany(companyId: number): Promise<unknown>;
@@ -106,6 +106,8 @@ function buildTaskUpdatePayload(data) {
106
106
  mapped.task = data.title;
107
107
  if (data.description !== undefined)
108
108
  mapped.description = data.description;
109
+ if (data.notes !== undefined)
110
+ mapped.notes = data.notes;
109
111
  if (data.status !== undefined)
110
112
  mapped.status = data.status;
111
113
  if (data.priority !== undefined) {
@@ -181,6 +183,16 @@ function buildTimeLogPayload(data, ctx) {
181
183
  }
182
184
  const EPOCH = '2000-01-01T00:00:00';
183
185
  const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
186
+ /**
187
+ * Tag colours the ViviScape Work task board offers (tasks.js \$scope.tagPalette).
188
+ * The API takes any hex string; staying inside the palette keeps agent-written
189
+ * tags visually consistent with the ones people add by hand. First is default.
190
+ */
191
+ export const TAG_PALETTE = [
192
+ '#00325e', '#13B955', '#EFA31D', '#FF5757',
193
+ '#009CDC', '#6610f2', '#fd7e14', '#e83e8c',
194
+ '#20c997', '#868e96',
195
+ ];
184
196
  function isoDate(value, fallback) {
185
197
  if (typeof value === 'string' && value.trim())
186
198
  return value;
@@ -621,6 +633,57 @@ export class ViviScapeClient {
621
633
  async mergeTasks(fromTaskId, toTaskId) {
622
634
  return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
623
635
  }
636
+ // -- Task tags ----------------------------------------------
637
+ async getTaskTags(taskId) {
638
+ return this.get(`grouptask/tags/${taskId}`);
639
+ }
640
+ /**
641
+ * Tag a task. The web app upper-cases the label and always sends a colour, so
642
+ * match it: tags written by an agent then look and sort like the human ones.
643
+ * Returns the persisted Group_Task_Tag, including its tag_id.
644
+ */
645
+ async addTaskTag(taskId, tag, color) {
646
+ return this.post('grouptask/tags/add', {
647
+ tag_id: EMPTY_GUID,
648
+ task_id: taskId,
649
+ tag: tag.trim().toUpperCase(),
650
+ color: color || TAG_PALETTE[0],
651
+ });
652
+ }
653
+ /** Takes the tag_id from getTaskTags, not the label. */
654
+ async removeTaskTag(tagId) {
655
+ return this.get(`grouptask/tag/remove/${tagId}`);
656
+ }
657
+ // -- Task references ----------------------------------------
658
+ /**
659
+ * Cross references between tasks. The link is symmetric: adding A -> B makes
660
+ * the pair visible from both tasks, and each row describes the OTHER task
661
+ * (related_task_id, its title and status), so no second fetch is needed.
662
+ */
663
+ async getTaskReferences(taskId) {
664
+ return this.get(`grouptask/references/${taskId}`);
665
+ }
666
+ /** Idempotent per pair: re-adding an existing link returns that link. */
667
+ async addTaskReference(taskId, relatedTaskId, note) {
668
+ return this.post('grouptask/references/add', {
669
+ task_id: taskId,
670
+ related_task_id: relatedTaskId,
671
+ note: note || '',
672
+ });
673
+ }
674
+ /** Takes the reference_id from getTaskReferences, not a task id. */
675
+ async removeTaskReference(referenceId) {
676
+ return this.get(`grouptask/reference/remove/${referenceId}`);
677
+ }
678
+ /** Keyword search over the account's tasks -- matches title, description and task id. */
679
+ async searchTasks(query, opts = {}) {
680
+ const params = new URLSearchParams({
681
+ query,
682
+ exclude_task_id: String(opts.exclude_task_id ?? 0),
683
+ max: String(opts.max ?? 25),
684
+ });
685
+ return this.get(`grouptask/search?${params.toString()}`);
686
+ }
624
687
  // -- Milestones ---------------------------------------------
625
688
  async getMilestones(projectId) {
626
689
  return this.get(`milestones/list/${projectId}`);
@@ -66,5 +66,8 @@ export function featureForPath(path) {
66
66
  return 'comment';
67
67
  if (p.startsWith('task'))
68
68
  return 'tasks';
69
+ // Task tags live under grouptask/*, which would otherwise fall through to auth.
70
+ if (p.startsWith('grouptask'))
71
+ return 'tasks';
69
72
  return 'auth';
70
73
  }
package/dist/enums.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * low/medium/high, which is what this server's task_add used to claim.
13
13
  */
14
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"];
15
+ export declare const TASK_STATUSES: readonly ["backlog", "new", "research", "discussion", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
16
16
  /** Task and project priority values, ascending. */
17
17
  export declare const PRIORITIES: readonly ["low", "moderate", "important", "urgent", "critical"];
18
18
  /**
@@ -34,7 +34,7 @@ export type Priority = (typeof PRIORITIES)[number];
34
34
  export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
35
35
  /** Payload for the enums tool, so callers can discover these without guessing. */
36
36
  export declare function enumReference(): {
37
- task_status: readonly ["backlog", "new", "research", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
37
+ task_status: readonly ["backlog", "new", "research", "discussion", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
38
38
  priority: readonly ["low", "moderate", "important", "urgent", "critical"];
39
39
  project_status: readonly ["new", "inprogress", "complete"];
40
40
  prospect_status: readonly ["new", "firstcontact", "negotiation", "pending", "won", "lost", "spam"];
package/dist/enums.js CHANGED
@@ -16,6 +16,7 @@ export const TASK_STATUSES = [
16
16
  'backlog',
17
17
  'new',
18
18
  'research',
19
+ 'discussion',
19
20
  'inprogress',
20
21
  'pendingreview',
21
22
  'inreview',
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
  import { config } from 'dotenv';
6
- import { ViviScapeClient } from './api-client.js';
6
+ import { TAG_PALETTE, ViviScapeClient } from './api-client.js';
7
7
  import { AuthService } from './auth/auth-service.js';
8
8
  import { describe } from './auth/credentials.js';
9
9
  import { PRIORITIES, PROJECT_STATUSES, PROSPECT_STATUSES, TASK_STATUSES, enumReference, } from './enums.js';
@@ -387,15 +387,40 @@ server.tool('task_add', 'Add a task to a project. The API requires a full Group_
387
387
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
388
388
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
389
389
  milestone_id: z.number().optional().describe('Milestone ID'),
390
+ tags: z.array(z.string()).optional().describe('Tag labels to apply to the new task (stored upper-case). Applied after creation, in the board default colour; use task_tags to pick a colour.'),
390
391
  ...idempotencyArg,
391
392
  }, async (params) => {
392
- const result = await once('task_add', params.idempotency_key, () => requireClient().addTask(params));
393
+ const result = await once('task_add', params.idempotency_key, async () => {
394
+ const c = requireClient();
395
+ const task = await c.addTask(params);
396
+ if (!params.tags?.length)
397
+ return task;
398
+ const row = task && typeof task === 'object' ? task : null;
399
+ const taskId = Number(row?.task_id ?? 0);
400
+ if (!taskId) {
401
+ return { task, tags_skipped: 'The create response carried no task_id; tag the task with task_tags.' };
402
+ }
403
+ // A tag failure must not throw: the task already exists, and an error here
404
+ // would leave the idempotency key unrecorded, so a retry would create a
405
+ // second task. Report per-tag outcomes as data instead.
406
+ const tags = [];
407
+ for (const label of params.tags) {
408
+ try {
409
+ tags.push(await c.addTaskTag(taskId, label));
410
+ }
411
+ catch (err) {
412
+ tags.push({ tag: label, error: err instanceof Error ? err.message : String(err) });
413
+ }
414
+ }
415
+ return { ...row, tags };
416
+ });
393
417
  return { content: [{ type: 'text', text: json(result) }] };
394
418
  });
395
419
  server.tool('task_update', 'Update a task. Fetches existing task first and merges changes.', {
396
420
  task_id: z.number().describe('Task ID to update'),
397
421
  title: z.string().optional().describe('Task title'),
398
422
  description: z.string().optional(),
423
+ notes: z.string().optional().describe('Task notes -- the free-form working notes on the task Overview tab, held in HTML by the editor. Replaces the field outright, so read the current value with task_get and send the merged text rather than only the new lines.'),
399
424
  assigned_to: z.number().optional().describe('User ID to assign to'),
400
425
  priority: z.enum(PRIORITIES).optional().describe('Priority (platform values)'),
401
426
  status: z.enum(TASK_STATUSES).optional().describe('Task status (platform values)'),
@@ -408,6 +433,40 @@ server.tool('task_get', 'Get a single task by ID', { task_id: z.number().describ
408
433
  const result = await requireClient().getTask(task_id);
409
434
  return { content: [{ type: 'text', text: json(result) }] };
410
435
  });
436
+ server.tool('task_tags', 'List, add, or remove tags on a task -- the categorisation primitive of the task board, and the way to mark work an agent created or touched. Labels are stored upper-case, and add is idempotent per label: re-adding one returns the existing tag rather than a duplicate. add returns the persisted tag including its tag_id; remove takes that tag_id, not the label, and answers true. Tags are per task -- there is no tag search route, and list rows from tasks_open / project_tasks carry tags: null, so read a task tag set here or with task_get.', {
437
+ action: z.enum(['list', 'add', 'remove']).describe('What to do'),
438
+ task_id: z.number().optional().describe('Task ID (list, add)'),
439
+ tag: z.string().optional().describe('Tag label (add). Stored upper-case.'),
440
+ color: z.string().optional().describe(`Hex colour for the tag (add). Defaults to ${TAG_PALETTE[0]}; the board palette is ${TAG_PALETTE.join(' ')}.`),
441
+ tag_id: z.string().optional().describe('Tag ID from list (remove)'),
442
+ }, async ({ action, task_id, tag, color, tag_id }) => {
443
+ const c = requireClient();
444
+ const result = action === 'list' ? await c.getTaskTags(need(action, 'task_id', task_id))
445
+ : action === 'add' ? await c.addTaskTag(need(action, 'task_id', task_id), need(action, 'tag', tag), color)
446
+ : await c.removeTaskTag(need(action, 'tag_id', tag_id));
447
+ return { content: [{ type: 'text', text: json(result) }] };
448
+ });
449
+ server.tool('task_references', 'List, add, or remove cross references between tasks -- the way related work is linked so one task points at another (a duplicate, a blocker, the ticket a task came from). Links are SYMMETRIC: adding A -> B makes the pair visible from both tasks, so link once and never mirror it by hand. list rows describe the OTHER task -- related_task_id plus its title, status and project -- so no second task_get is needed to report them. add is idempotent per pair and returns the persisted link including its reference_id; remove takes that reference_id, not a task id, and answers true. Both tasks must belong to the signed-in account. Use task_search to find the id to link when only a keyword is known.', {
450
+ action: z.enum(['list', 'add', 'remove']).describe('What to do'),
451
+ task_id: z.number().optional().describe('Task ID whose references are read or extended (list, add)'),
452
+ related_task_id: z.number().optional().describe('Task ID to link to task_id (add)'),
453
+ note: z.string().optional().describe('Optional note describing why the two are linked (add)'),
454
+ reference_id: z.string().optional().describe('Reference ID from list (remove)'),
455
+ }, async ({ action, task_id, related_task_id, note, reference_id }) => {
456
+ const c = requireClient();
457
+ const result = action === 'list' ? await c.getTaskReferences(need(action, 'task_id', task_id))
458
+ : action === 'add' ? await c.addTaskReference(need(action, 'task_id', task_id), need(action, 'related_task_id', related_task_id), note)
459
+ : await c.removeTaskReference(need(action, 'reference_id', reference_id));
460
+ return { content: [{ type: 'text', text: json(result) }] };
461
+ });
462
+ server.tool('task_search', 'Keyword search over the account tasks and tickets -- matches the title, the description, and, for a numeric query, the task id itself. Returns light rows (task_id, title, status, project, company), enough to pick an id for task_get or task_references without pulling a full task feed.', {
463
+ query: z.string().describe('Keyword or task id to search for'),
464
+ exclude_task_id: z.number().optional().describe('Task ID to leave out of the results (e.g. the task being linked from)'),
465
+ max: z.number().optional().describe('Maximum rows to return (default 25, cap 100)'),
466
+ }, async ({ query, exclude_task_id, max }) => {
467
+ const result = await requireClient().searchTasks(query, { exclude_task_id, max });
468
+ return { content: [{ type: 'text', text: json(result) }] };
469
+ });
411
470
  server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of trimmed rows; see fields/limit/offset.', {
412
471
  user_id: z.number().describe('User ID'),
413
472
  ...pageArgs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.6.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",