viviscape-mcp 2.2.0 → 2.4.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
@@ -110,6 +110,8 @@ The server exposes tools across these domains:
110
110
  - **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
111
111
  - **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
112
112
  - **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
113
+ - **Lookup** - `services` (resolve service_id to a name), `user_lookup` (id, email, team, groups)
114
+ - **Bulk** - `tasks_bulk_add` (many tasks in one request)
113
115
  - **Time logs** - `timelog_add`, `timelog_update`
114
116
  - **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`, `notes_account`, `note_revisions`
115
117
  - **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
@@ -134,6 +136,26 @@ Two traps: tasks complete as `completed` while projects complete as
134
136
  validate these with `z.enum`, so an invalid value is rejected before it reaches
135
137
  the API.
136
138
 
139
+ ### Repeat-safe creates
140
+
141
+ No create route accepts an idempotency key, so a retried agent step silently
142
+ creates a second record. `task_add`, `note_add`, `timelog_add`,
143
+ `prospect_add`, and `tasks_bulk_add` accept an optional
144
+ `idempotency_key`: calling again with the same key replays the first result
145
+ instead of writing again.
146
+
147
+ The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
148
+ stops the common case -- the same agent retrying the same step -- and does not
149
+ make the server idempotent. Two machines running the same plan will still
150
+ duplicate.
151
+
152
+ ### Delta queries
153
+
154
+ List tools accept `updated_since` (ISO 8601) and return only rows touched at
155
+ or after that time, so a recurring agent does not re-read the whole working set.
156
+ It is filtered in-process (no list route supports a modified-since filter), and
157
+ rows carrying no usable timestamp are kept rather than dropped.
158
+
137
159
  ### Paging and field selection
138
160
 
139
161
  List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
@@ -144,6 +166,12 @@ work, so each list tool accepts:
144
166
  - `fields` - columns to return; omit for a curated default, or pass `["all"]`
145
167
  - `limit` / `offset` - page window, default 50 rows
146
168
 
169
+ Task rows also carry computed `due_date`, `due_in_days` (negative when
170
+ overdue), and `overdue`, derived from the task's `end` date. Read those rather
171
+ than the API's `deadline` string, which does not track the due date -- it
172
+ reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
173
+ default field set; ask for it explicitly if you need to see what the UI shows.
174
+
147
175
  Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
148
176
  so a caller can tell when more rows exist. Task lists also accept
149
177
  `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
@@ -223,6 +223,35 @@ export declare class ViviScapeClient {
223
223
  getActiveServices(): Promise<unknown>;
224
224
  getAllServices(): Promise<unknown>;
225
225
  getUsers(): Promise<unknown>;
226
+ getServices(activeOnly?: boolean): Promise<unknown>;
227
+ /**
228
+ * service/id answers 500 for ids that plainly exist (356 is "Feature
229
+ * Request"), so fall back to picking the row out of the account catalogue,
230
+ * which works.
231
+ */
232
+ getService(serviceId: number): Promise<any>;
233
+ getCompanyServices(companyId: number): Promise<unknown>;
234
+ getUserById(userId: number): Promise<unknown>;
235
+ getUserByEmail(email: string): Promise<unknown>;
236
+ userExists(username: string): Promise<unknown>;
237
+ getUserTeam(userId?: number): Promise<unknown>;
238
+ getUserGroups(userId?: number): Promise<unknown>;
239
+ /**
240
+ * Create many tasks in one request. The route builds each Group_Task
241
+ * server-side from a compact row, so this is both faster and less
242
+ * error-prone than looping task_add.
243
+ */
244
+ bulkImportTasks(opts: {
245
+ project_id: number;
246
+ status?: string;
247
+ tasks: Array<{
248
+ task: string;
249
+ description?: string;
250
+ estimate?: string;
251
+ priority?: string;
252
+ service_id?: number;
253
+ }>;
254
+ }): Promise<unknown>;
226
255
  getHoursByPerson(data: Record<string, unknown>): Promise<unknown>;
227
256
  getTimeLogsByDateRange(data: Record<string, unknown>): Promise<unknown>;
228
257
  getPersonStats(data: Record<string, unknown>): Promise<unknown>;
@@ -908,6 +908,77 @@ export class ViviScapeClient {
908
908
  async getUsers() {
909
909
  return this.get(`account/users/${this.accountId}`);
910
910
  }
911
+ // -- Services (resolves the opaque service_id on tasks) ------
912
+ async getServices(activeOnly = false) {
913
+ return activeOnly
914
+ ? this.get(`services/${this.accountId}/active`)
915
+ : this.get(`services/${this.accountId}`);
916
+ }
917
+ /**
918
+ * service/id answers 500 for ids that plainly exist (356 is "Feature
919
+ * Request"), so fall back to picking the row out of the account catalogue,
920
+ * which works.
921
+ */
922
+ async getService(serviceId) {
923
+ try {
924
+ return await this.get(`service/id?serviceid=${serviceId}`);
925
+ }
926
+ catch (err) {
927
+ if (!(err instanceof ApiError))
928
+ throw err;
929
+ const all = await this.getServices(false);
930
+ const hit = Array.isArray(all)
931
+ ? all.find((s) => Number(s.intServiceID) === serviceId)
932
+ : undefined;
933
+ if (!hit)
934
+ throw err;
935
+ return hit;
936
+ }
937
+ }
938
+ async getCompanyServices(companyId) {
939
+ return this.get(`services/account?companyid=${companyId}`);
940
+ }
941
+ // -- Identity lookup -----------------------------------------
942
+ async getUserById(userId) {
943
+ return this.get(`user/id?userid=${userId}`);
944
+ }
945
+ async getUserByEmail(email) {
946
+ return this.get(`user/email?email=${encodeURIComponent(email)}`);
947
+ }
948
+ async userExists(username) {
949
+ return this.get(`user/exist?username=${encodeURIComponent(username)}`);
950
+ }
951
+ async getUserTeam(userId = 0) {
952
+ return this.get(`user/team?user_id=${userId || this.userId}`);
953
+ }
954
+ async getUserGroups(userId = 0) {
955
+ return this.get(`user/groups?user_id=${userId || this.userId}&account_id=${this.accountId}`);
956
+ }
957
+ // -- Bulk ----------------------------------------------------
958
+ /**
959
+ * Create many tasks in one request. The route builds each Group_Task
960
+ * server-side from a compact row, so this is both faster and less
961
+ * error-prone than looping task_add.
962
+ */
963
+ async bulkImportTasks(opts) {
964
+ return this.post('bulk/task/import', {
965
+ account_id: this.accountId,
966
+ project_id: opts.project_id,
967
+ user_id: this.userId,
968
+ status: opts.status || 'new',
969
+ tasks: opts.tasks.map((t) => ({
970
+ task: t.task,
971
+ description: t.description || '',
972
+ estimate: t.estimate || '',
973
+ priority: t.priority || 'low',
974
+ service_id: t.service_id ?? 0,
975
+ users: [],
976
+ user_id: this.userId,
977
+ name: '',
978
+ selected: false,
979
+ })),
980
+ });
981
+ }
911
982
  // -- Insights -----------------------------------------------
912
983
  async getHoursByPerson(data) {
913
984
  const userId = Number(data.user_id) || this.userId;
@@ -0,0 +1,14 @@
1
+ export declare class IdempotencyStore {
2
+ private path;
3
+ private cache;
4
+ constructor(path?: string);
5
+ private load;
6
+ private save;
7
+ get(tool: string, key: string): unknown | undefined;
8
+ put(tool: string, key: string, result: unknown): void;
9
+ }
10
+ /**
11
+ * Run a create exactly once per key. Without a key it just runs, so existing
12
+ * callers are unaffected.
13
+ */
14
+ export declare function once(tool: string, key: string | undefined, fn: () => Promise<unknown>): Promise<unknown>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Idempotency for create tools -- phase 5 of docs/AGENT-CAPABILITY-SPEC.md.
3
+ *
4
+ * None of the create routes accept an idempotency key, so a retried or
5
+ * re-planned agent step silently creates a second task, note, or time log. This
6
+ * keeps a small local record of (tool, key) -> result so a repeat call replays
7
+ * the first result instead of writing again.
8
+ *
9
+ * Scope and limits, stated plainly because they matter:
10
+ * - The record is local to this machine and this user, in ~/.viviscape.
11
+ * Two machines running the same plan will still duplicate.
12
+ * - It cannot detect a duplicate that was created without a key.
13
+ * - Entries expire after 7 days; the file is capped so it cannot grow without
14
+ * bound.
15
+ * - Nothing here makes the server idempotent. It stops the common failure --
16
+ * the same agent retrying the same step -- and no more.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
+ const MAX_ENTRIES = 500;
23
+ export class IdempotencyStore {
24
+ path;
25
+ cache = null;
26
+ constructor(path) {
27
+ this.path = path || join(homedir(), '.viviscape', 'mcp-idempotency.json');
28
+ }
29
+ load() {
30
+ if (this.cache)
31
+ return this.cache;
32
+ let raw = {};
33
+ try {
34
+ if (existsSync(this.path))
35
+ raw = JSON.parse(readFileSync(this.path, 'utf8'));
36
+ }
37
+ catch {
38
+ raw = {}; // a corrupt file must not break writes
39
+ }
40
+ const cutoff = Date.now() - TTL_MS;
41
+ for (const [k, v] of Object.entries(raw)) {
42
+ if (!v || typeof v.at !== 'number' || v.at < cutoff)
43
+ delete raw[k];
44
+ }
45
+ this.cache = raw;
46
+ return raw;
47
+ }
48
+ save() {
49
+ const data = this.load();
50
+ const keys = Object.keys(data);
51
+ if (keys.length > MAX_ENTRIES) {
52
+ // Drop oldest first.
53
+ keys.sort((a, b) => data[a].at - data[b].at);
54
+ for (const k of keys.slice(0, keys.length - MAX_ENTRIES))
55
+ delete data[k];
56
+ }
57
+ try {
58
+ mkdirSync(join(homedir(), '.viviscape'), { recursive: true });
59
+ writeFileSync(this.path, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
60
+ }
61
+ catch {
62
+ // A failed write costs deduplication, not correctness -- stay quiet.
63
+ }
64
+ }
65
+ get(tool, key) {
66
+ return this.load()[`${tool}:${key}`]?.result;
67
+ }
68
+ put(tool, key, result) {
69
+ this.load()[`${tool}:${key}`] = { at: Date.now(), result };
70
+ this.save();
71
+ }
72
+ }
73
+ const store = new IdempotencyStore();
74
+ /**
75
+ * Run a create exactly once per key. Without a key it just runs, so existing
76
+ * callers are unaffected.
77
+ */
78
+ export async function once(tool, key, fn) {
79
+ if (!key)
80
+ return fn();
81
+ const hit = store.get(tool, key);
82
+ if (hit !== undefined) {
83
+ return {
84
+ idempotent_replay: true,
85
+ note: `Replayed the result of an earlier ${tool} call with idempotency_key "${key}". Nothing was created.`,
86
+ result: hit,
87
+ };
88
+ }
89
+ const result = await fn();
90
+ store.put(tool, key, result);
91
+ return result;
92
+ }
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import { 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';
10
- import { FILE_FIELDS, NOTE_FIELDS, PROJECT_FIELDS, TASK_FIELDS, filterTasks, shape, } from './projection.js';
10
+ import { FILE_FIELDS, NOTE_FIELDS, PROJECT_FIELDS, TASK_FIELDS, SERVICE_FIELDS, annotateTasks, filterTasks, filterUpdatedSince, shape, } from './projection.js';
11
+ import { once } from './idempotency.js';
11
12
  import { baseUrl } from './config.js';
12
13
  // quiet: dotenv's banner goes to stdout, which is the MCP protocol channel.
13
14
  config({ quiet: true });
@@ -44,7 +45,17 @@ function json(data) {
44
45
  * list tool takes a page window and an optional field list. Defaults keep the
45
46
  * response inside a tool-result budget; fields:["all"] opts out.
46
47
  */
48
+ /**
49
+ * Phase 5: a key makes a create replay its first result instead of writing
50
+ * twice. Local to this machine -- see src/idempotency.ts for the limits.
51
+ */
52
+ const idempotencyArg = {
53
+ idempotency_key: z.string().optional()
54
+ .describe('Repeat-safety token. Calling again with the same key replays the first result instead of creating a second record.'),
55
+ };
47
56
  const pageArgs = {
57
+ updated_since: z.string().optional()
58
+ .describe('Only rows touched at or after this time (ISO 8601). Filtered in-process; rows with no timestamp are kept.'),
48
59
  fields: z.array(z.string()).optional()
49
60
  .describe('Columns to return. Omit for a PM-relevant default; ["all"] for every column.'),
50
61
  limit: z.number().optional().describe('Max rows to return (default 50)'),
@@ -72,6 +83,13 @@ function paging(p) {
72
83
  offset: p.offset,
73
84
  };
74
85
  }
86
+ /** Apply updated_since before paging, so the window is over matching rows. */
87
+ function since(rows, p) {
88
+ const s = p.updated_since;
89
+ if (!s || !Array.isArray(rows))
90
+ return rows;
91
+ return filterUpdatedSince(rows, s);
92
+ }
75
93
  // ============================================================
76
94
  // AUTH
77
95
  // ============================================================
@@ -148,8 +166,9 @@ server.tool('prospect_add', 'Add a new prospect/lead to the ViviScape CRM', {
148
166
  source_url: z.string().optional().describe('URL where the lead came from'),
149
167
  referred_by: z.string().optional().describe('Referral source'),
150
168
  status: z.enum(PROSPECT_STATUSES).optional().describe('Pipeline stage (platform values)'),
169
+ ...idempotencyArg,
151
170
  }, async (params) => {
152
- const result = await requireClient().addProspect(params);
171
+ const result = await once('prospect_add', params.idempotency_key, () => requireClient().addProspect(params));
153
172
  return { content: [{ type: 'text', text: json(result) }] };
154
173
  });
155
174
  server.tool('prospect_update', 'Update an existing prospect in the CRM', {
@@ -311,13 +330,13 @@ server.tool('project_list', 'Get all projects for current user. Returns a page o
311
330
  ...pageArgs,
312
331
  }, async (params) => {
313
332
  const result = await requireClient().getMyProjects();
314
- return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
333
+ return { content: [{ type: 'text', text: json(shape(since(result, params), PROJECT_FIELDS, paging(params))) }] };
315
334
  });
316
335
  server.tool('project_list_active', 'Get active projects for current user. Returns a page of trimmed rows; see fields/limit/offset.', {
317
336
  ...pageArgs,
318
337
  }, async (params) => {
319
338
  const result = await requireClient().getActiveProjects();
320
- return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
339
+ return { content: [{ type: 'text', text: json(shape(since(result, params), PROJECT_FIELDS, paging(params))) }] };
321
340
  });
322
341
  server.tool('project_get', 'Get a project by ID', { project_id: z.number().describe('Project ID') }, async ({ project_id }) => {
323
342
  const result = await requireClient().getProjectById(project_id);
@@ -336,11 +355,11 @@ server.tool('project_tasks', 'Get all tasks for a project. Returns a page of tri
336
355
  // project_id is the query, not a filter -- re-filtering on it would drop
337
356
  // every row if the response names the column differently.
338
357
  const rows = Array.isArray(result)
339
- ? filterTasks(result, { ...params, project_id: undefined })
358
+ ? annotateTasks(filterTasks(result, { ...params, project_id: undefined }))
340
359
  : result;
341
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
360
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
342
361
  });
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.', {
362
+ 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 with computed due_date / due_in_days / overdue -- read those, not the API deadline string, which is unreliable.', {
344
363
  only_mine: z.boolean().optional().describe('Only tasks assigned to the signed-in user (server-side)'),
345
364
  team: z.string().optional().describe('Team filter passed through to the API'),
346
365
  ...taskFilterArgs,
@@ -351,8 +370,8 @@ server.tool('tasks_open', 'Get open tasks across projects. company_id and only_m
351
370
  only_mine: params.only_mine,
352
371
  team: params.team,
353
372
  });
354
- const rows = Array.isArray(result) ? filterTasks(result, params) : result;
355
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
373
+ const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
374
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
356
375
  });
357
376
  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.', {
358
377
  project_id: z.number().describe('Project ID (group_id)'),
@@ -368,8 +387,9 @@ server.tool('task_add', 'Add a task to a project. The API requires a full Group_
368
387
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
369
388
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
370
389
  milestone_id: z.number().optional().describe('Milestone ID'),
390
+ ...idempotencyArg,
371
391
  }, async (params) => {
372
- const result = await requireClient().addTask(params);
392
+ const result = await once('task_add', params.idempotency_key, () => requireClient().addTask(params));
373
393
  return { content: [{ type: 'text', text: json(result) }] };
374
394
  });
375
395
  server.tool('task_update', 'Update a task. Fetches existing task first and merges changes.', {
@@ -394,8 +414,8 @@ server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of tr
394
414
  ...taskFilterArgs,
395
415
  }, async (params) => {
396
416
  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))) }] };
417
+ const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
418
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
399
419
  });
400
420
  server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of trimmed rows; see fields/limit/offset.', {
401
421
  milestone_id: z.number().describe('Milestone ID'),
@@ -403,8 +423,8 @@ server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of
403
423
  ...taskFilterArgs,
404
424
  }, async (params) => {
405
425
  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))) }] };
426
+ const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
427
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
408
428
  });
409
429
  server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns a page of trimmed rows; see fields/limit/offset.', {
410
430
  group_id: z.number().describe('Project group ID'),
@@ -413,8 +433,8 @@ server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns
413
433
  ...taskFilterArgs,
414
434
  }, async (params) => {
415
435
  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))) }] };
436
+ const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
437
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
418
438
  });
419
439
  server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trimmed rows; see fields/limit/offset.', {
420
440
  company_id: z.number().describe('Company ID'),
@@ -423,9 +443,9 @@ server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trim
423
443
  }, async (params) => {
424
444
  const result = await requireClient().getCompanyTasks(params.company_id);
425
445
  const rows = Array.isArray(result)
426
- ? filterTasks(result, { ...params, company_id: undefined })
446
+ ? annotateTasks(filterTasks(result, { ...params, company_id: undefined }))
427
447
  : result;
428
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
448
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
429
449
  });
430
450
  server.tool('projects_by_company', 'Get user projects filtered by company. Returns a page of trimmed rows; see fields/limit/offset.', {
431
451
  user_id: z.number().describe('User ID'),
@@ -433,14 +453,14 @@ server.tool('projects_by_company', 'Get user projects filtered by company. Retur
433
453
  ...pageArgs,
434
454
  }, async (params) => {
435
455
  const result = await requireClient().getProjectsByCompany(params.user_id, params.company_id);
436
- return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
456
+ return { content: [{ type: 'text', text: json(shape(since(result, params), PROJECT_FIELDS, paging(params))) }] };
437
457
  });
438
458
  server.tool('projects_active_by_user', 'Get active projects for a specific user. Returns a page of trimmed rows; see fields/limit/offset.', {
439
459
  user_id: z.number().describe('User ID'),
440
460
  ...pageArgs,
441
461
  }, async (params) => {
442
462
  const result = await requireClient().getActiveProjectsByUser(params.user_id);
443
- return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
463
+ return { content: [{ type: 'text', text: json(shape(since(result, params), PROJECT_FIELDS, paging(params))) }] };
444
464
  });
445
465
  // ============================================================
446
466
  // DOCUMENTATION: NOTE RELATIONS, TAGS, REVISIONS, ATTACHMENTS
@@ -463,7 +483,7 @@ server.tool('notes_account', 'List notes across the account, newest-first as the
463
483
  ...pageArgs,
464
484
  }, async (params) => {
465
485
  const result = await requireClient().getAccountNotes(params.company_id ?? 0, params.user_id ?? 0);
466
- return { content: [{ type: 'text', text: json(shape(result, NOTE_FIELDS, paging(params))) }] };
486
+ return { content: [{ type: 'text', text: json(shape(since(result, params), NOTE_FIELDS, paging(params))) }] };
467
487
  });
468
488
  server.tool('note_revisions', 'Get the edit history of a note. Use before rewriting a shared note so human edits are not silently clobbered.', { note_id: z.string().describe('Note ID (GUID)') }, async ({ note_id }) => {
469
489
  const result = await requireClient().getNoteRevisions(note_id);
@@ -577,7 +597,7 @@ server.tool('project_files', 'List, upload, update, delete, or resolve the downl
577
597
  const { action } = params;
578
598
  if (action === 'list') {
579
599
  const result = await c.getProjectFiles(need(action, 'project_id', params.project_id));
580
- return { content: [{ type: 'text', text: json(shape(result, FILE_FIELDS, paging(params))) }] };
600
+ return { content: [{ type: 'text', text: json(shape(since(result, params), FILE_FIELDS, paging(params))) }] };
581
601
  }
582
602
  const result = action === 'upload' ? await c.uploadProjectFile({
583
603
  project_id: need(action, 'project_id', params.project_id),
@@ -596,7 +616,57 @@ server.tool('task_files', 'List files attached to a task. Use this to read conte
596
616
  ...pageArgs,
597
617
  }, async (params) => {
598
618
  const result = await requireClient().getTaskFiles(params.task_id);
599
- return { content: [{ type: 'text', text: json(shape(result, FILE_FIELDS, paging(params))) }] };
619
+ return { content: [{ type: 'text', text: json(shape(since(result, params), FILE_FIELDS, paging(params))) }] };
620
+ });
621
+ // ============================================================
622
+ // LOOKUP AND BULK
623
+ // ============================================================
624
+ server.tool('services', 'Resolve service ids to names. Task rows carry a service_id and frequently a null service_name, so this is how a caller turns 356 into "Feature Request". Actions: list (all), active, get (one by id), by_company.', {
625
+ action: z.enum(['list', 'active', 'get', 'by_company']).describe('What to look up'),
626
+ service_id: z.number().optional().describe('Service ID (get)'),
627
+ company_id: z.number().optional().describe('Company ID (by_company)'),
628
+ ...pageArgs,
629
+ }, async (params) => {
630
+ const c = requireClient();
631
+ const { action } = params;
632
+ const result = action === 'list' ? await c.getServices(false)
633
+ : action === 'active' ? await c.getServices(true)
634
+ : action === 'get' ? await c.getService(need(action, 'service_id', params.service_id))
635
+ : await c.getCompanyServices(need(action, 'company_id', params.company_id));
636
+ return { content: [{ type: 'text', text: json(shape(since(result, params), SERVICE_FIELDS, paging(params))) }] };
637
+ });
638
+ server.tool('user_lookup', 'Resolve a user id, email, or team. Assignees arrive as bare ids (210, 230, 264), so use this rather than guessing who they are. Actions: by_id, by_email, exists, team (companies a user belongs to), groups (their projects).', {
639
+ action: z.enum(['by_id', 'by_email', 'exists', 'team', 'groups']).describe('What to look up'),
640
+ user_id: z.number().optional().describe('User ID (by_id; optional for team and groups, defaults to signed-in user)'),
641
+ email: z.string().optional().describe('Email address (by_email)'),
642
+ username: z.string().optional().describe('Username or email to test (exists)'),
643
+ }, async ({ action, user_id, email, username }) => {
644
+ const c = requireClient();
645
+ const result = action === 'by_id' ? await c.getUserById(need(action, 'user_id', user_id))
646
+ : action === 'by_email' ? await c.getUserByEmail(need(action, 'email', email))
647
+ : action === 'exists' ? await c.userExists(need(action, 'username', username))
648
+ : action === 'team' ? await c.getUserTeam(user_id ?? 0)
649
+ : await c.getUserGroups(user_id ?? 0);
650
+ return { content: [{ type: 'text', text: json(result) }] };
651
+ });
652
+ server.tool('tasks_bulk_add', 'Create many tasks in one request. Prefer this over looping task_add: the API builds each task server-side, so it is one round trip and one failure surface. All tasks land in the same project with the same starting status.', {
653
+ project_id: z.number().describe('Project ID all tasks belong to'),
654
+ status: z.enum(TASK_STATUSES).optional().describe('Starting status for every task (default new)'),
655
+ tasks: z.array(z.object({
656
+ task: z.string().describe('Task title'),
657
+ description: z.string().optional(),
658
+ estimate: z.string().optional().describe('Estimate in shorthand, e.g. 4h'),
659
+ priority: z.enum(PRIORITIES).optional().describe('Defaults to low'),
660
+ service_id: z.number().optional(),
661
+ })).describe('Tasks to create'),
662
+ ...idempotencyArg,
663
+ }, async (params) => {
664
+ const result = await once('tasks_bulk_add', params.idempotency_key, () => requireClient().bulkImportTasks({
665
+ project_id: params.project_id,
666
+ status: params.status,
667
+ tasks: params.tasks,
668
+ }));
669
+ return { content: [{ type: 'text', text: json(result) }] };
600
670
  });
601
671
  // ============================================================
602
672
  // REFERENCE
@@ -801,8 +871,9 @@ server.tool('timelog_add', 'Log time against a task. Duration uses shorthand for
801
871
  log_date: z.string().optional().describe('Date of the work (ISO 8601)'),
802
872
  description: z.string().optional().describe('Description of work performed'),
803
873
  billable: z.boolean().optional().describe('Whether this time is billable'),
874
+ ...idempotencyArg,
804
875
  }, async (params) => {
805
- const result = await requireClient().addTimeLog(params);
876
+ const result = await once('timelog_add', params.idempotency_key, () => requireClient().addTimeLog(params));
806
877
  return { content: [{ type: 'text', text: json(result) }] };
807
878
  });
808
879
  server.tool('timelog_update', 'Update an existing time log entry. Builds full LOG_Time payload.', {
@@ -834,8 +905,9 @@ server.tool('note_get', 'Get a note by ID', { note_id: z.string().describe('Note
834
905
  server.tool('note_add', 'Create a new note', {
835
906
  note: z.string().describe('Note content'),
836
907
  title: z.string().optional().describe('Note title'),
908
+ ...idempotencyArg,
837
909
  }, async (params) => {
838
- const result = await requireClient().addNote(params);
910
+ const result = await once('note_add', params.idempotency_key, () => requireClient().addNote(params));
839
911
  return { content: [{ type: 'text', text: json(result) }] };
840
912
  });
841
913
  server.tool('note_update', 'Update a note', {
@@ -8,7 +8,16 @@
8
8
  * read. These helpers trim to a PM-relevant default, page the result, and tell
9
9
  * the caller what was left out so it can ask for more deliberately.
10
10
  */
11
- /** Task fields worth returning by default, of roughly 90 available. */
11
+ /**
12
+ * Task fields worth returning by default, of roughly 90 available.
13
+ *
14
+ * `deadline` is deliberately excluded. It is a server-rendered string that does
15
+ * not track the task's own due date: measured against six live tasks it read
16
+ * "In 2 Days" for one 39 days overdue, and gave "In 4 Days" for tasks at both
17
+ * -9 and +2 days. `end` is the real due date, and `pastdue` agrees with it on
18
+ * every row, so annotateTasks() derives due_date / due_in_days / overdue from
19
+ * `end` and those are what callers should read.
20
+ */
12
21
  export declare const TASK_FIELDS: string[];
13
22
  /** Project (group) fields worth returning by default, of 87 available. */
14
23
  export declare const PROJECT_FIELDS: string[];
@@ -20,6 +29,8 @@ export declare const PROJECT_FIELDS: string[];
20
29
  export declare const NOTE_FIELDS: string[];
21
30
  /** Project/task file fields worth returning by default. */
22
31
  export declare const FILE_FIELDS: string[];
32
+ /** Service catalogue fields worth returning by default. */
33
+ export declare const SERVICE_FIELDS: string[];
23
34
  export declare const DEFAULT_LIMIT = 50;
24
35
  export interface ShapeOpts {
25
36
  fields?: string[];
@@ -38,6 +49,24 @@ export interface TaskFilters {
38
49
  }
39
50
  /** Apply the filters the backend does not support, in-process. */
40
51
  export declare function filterTasks(rows: unknown[], f: TaskFilters): unknown[];
52
+ /**
53
+ * Add an unambiguous due-date signal to task rows.
54
+ *
55
+ * The API ships three overlapping fields: `end` (the real due date), `pastdue`
56
+ * (a boolean that matches it), and `deadline` (a human string that does not).
57
+ * Rather than make every caller know which to trust, derive the answer from
58
+ * `end` and pass `deadline` through only when explicitly requested.
59
+ *
60
+ * due_in_days is negative for overdue work, 0 for due today.
61
+ */
62
+ export declare function annotateTasks(rows: unknown[], now?: number): unknown[];
63
+ /**
64
+ * Keep rows touched at or after `since`. Applied in-process: no list route
65
+ * accepts a modified-since filter, so this saves the caller's context but not
66
+ * the transfer. Rows with no usable timestamp are kept, on the grounds that
67
+ * dropping work because its metadata is thin is the worse failure.
68
+ */
69
+ export declare function filterUpdatedSince(rows: unknown[], since: string): unknown[];
41
70
  /**
42
71
  * Trim, page, and annotate a list response. Non-array payloads (a single record,
43
72
  * or an error object) pass through untouched.
@@ -8,12 +8,22 @@
8
8
  * read. These helpers trim to a PM-relevant default, page the result, and tell
9
9
  * the caller what was left out so it can ask for more deliberately.
10
10
  */
11
- /** Task fields worth returning by default, of roughly 90 available. */
11
+ /**
12
+ * Task fields worth returning by default, of roughly 90 available.
13
+ *
14
+ * `deadline` is deliberately excluded. It is a server-rendered string that does
15
+ * not track the task's own due date: measured against six live tasks it read
16
+ * "In 2 Days" for one 39 days overdue, and gave "In 4 Days" for tasks at both
17
+ * -9 and +2 days. `end` is the real due date, and `pastdue` agrees with it on
18
+ * every row, so annotateTasks() derives due_date / due_in_days / overdue from
19
+ * `end` and those are what callers should read.
20
+ */
12
21
  export const TASK_FIELDS = [
13
22
  'task_id', 'task', 'status', 'percentage', 'priority',
14
23
  'company_id', 'company_name', 'group_id', 'group_name',
15
24
  'milestone_id', 'milestone', 'estimated_time', 'total_duration_str',
16
- 'deadline', 'start', 'end', 'staff_assignees', 'total_comments', 'unread_for_me',
25
+ 'start', 'due_date', 'due_in_days', 'overdue', 'pastdue',
26
+ 'staff_assignees', 'total_comments', 'unread_for_me',
17
27
  ];
18
28
  /** Project (group) fields worth returning by default, of 87 available. */
19
29
  export const PROJECT_FIELDS = [
@@ -38,6 +48,11 @@ export const FILE_FIELDS = [
38
48
  'file_size_friendly', 'author', 'user_id', 'post_date', 'url_path',
39
49
  'is_public', 'is_private',
40
50
  ];
51
+ /** Service catalogue fields worth returning by default. */
52
+ export const SERVICE_FIELDS = [
53
+ 'intServiceID', 'strService', 'bitActive', 'strDesc',
54
+ 'decCost', 'decListPrice', 'intType', 'intSort',
55
+ ];
41
56
  export const DEFAULT_LIMIT = 50;
42
57
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
43
58
  /** Parse the staff_assignees blob, which arrives as JSON text or as an array. */
@@ -92,6 +107,65 @@ export function filterTasks(rows, f) {
92
107
  return true;
93
108
  });
94
109
  }
110
+ /**
111
+ * Add an unambiguous due-date signal to task rows.
112
+ *
113
+ * The API ships three overlapping fields: `end` (the real due date), `pastdue`
114
+ * (a boolean that matches it), and `deadline` (a human string that does not).
115
+ * Rather than make every caller know which to trust, derive the answer from
116
+ * `end` and pass `deadline` through only when explicitly requested.
117
+ *
118
+ * due_in_days is negative for overdue work, 0 for due today.
119
+ */
120
+ export function annotateTasks(rows, now = Date.now()) {
121
+ return rows.map((row) => {
122
+ if (!isRecord(row))
123
+ return row;
124
+ const due = Date.parse(String(row.end ?? ''));
125
+ if (!Number.isFinite(due)) {
126
+ return { ...row, due_date: null, due_in_days: null, overdue: null };
127
+ }
128
+ return {
129
+ ...row,
130
+ due_date: new Date(due).toISOString(),
131
+ due_in_days: Math.round((due - now) / 86_400_000),
132
+ overdue: due < now,
133
+ };
134
+ });
135
+ }
136
+ /**
137
+ * Timestamp fields the API uses across row types, newest-meaning first. There is
138
+ * no single modified column, so take the latest of whatever the row carries.
139
+ */
140
+ const STAMP_FIELDS = [
141
+ 'last_update', 'last_log_date', 'complete_date', 'post_date',
142
+ 'creation_date', 'create_date',
143
+ ];
144
+ /**
145
+ * Keep rows touched at or after `since`. Applied in-process: no list route
146
+ * accepts a modified-since filter, so this saves the caller's context but not
147
+ * the transfer. Rows with no usable timestamp are kept, on the grounds that
148
+ * dropping work because its metadata is thin is the worse failure.
149
+ */
150
+ export function filterUpdatedSince(rows, since) {
151
+ const cutoff = Date.parse(since);
152
+ if (!Number.isFinite(cutoff))
153
+ return rows;
154
+ return rows.filter((row) => {
155
+ if (!isRecord(row))
156
+ return true;
157
+ let newest = NaN;
158
+ for (const f of STAMP_FIELDS) {
159
+ const t = Date.parse(String(row[f] ?? ''));
160
+ // 0001-01-01 sentinels parse fine but mean "never".
161
+ if (Number.isFinite(t) && t > -6e13 && (!Number.isFinite(newest) || t > newest))
162
+ newest = t;
163
+ }
164
+ if (!Number.isFinite(newest))
165
+ return true;
166
+ return newest >= cutoff;
167
+ });
168
+ }
95
169
  function pick(row, fields) {
96
170
  if (!isRecord(row))
97
171
  return row;
@@ -115,7 +189,10 @@ export function shape(payload, defaults, opts = {}) {
115
189
  const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
116
190
  const page = payload.slice(offset, offset + limit);
117
191
  const all = opts.fields?.length === 1 && opts.fields[0] === 'all';
118
- const fields = all ? null : (opts.fields?.length ? opts.fields : defaults);
192
+ // An empty defaults list means the caller has no curated subset for this row
193
+ // type -- return everything rather than projecting every row down to {}.
194
+ const fallback = defaults.length ? defaults : null;
195
+ const fields = all ? null : (opts.fields?.length ? opts.fields : fallback);
119
196
  const items = fields ? page.map((r) => pick(r, fields)) : page;
120
197
  const available = isRecord(payload[0]) ? Object.keys(payload[0]).length : 0;
121
198
  const next = offset + page.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",