viviscape-mcp 2.3.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)
@@ -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, annotateTasks, 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);
@@ -338,7 +357,7 @@ server.tool('project_tasks', 'Get all tasks for a project. Returns a page of tri
338
357
  const rows = Array.isArray(result)
339
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
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)'),
@@ -352,7 +371,7 @@ server.tool('tasks_open', 'Get open tasks across projects. company_id and only_m
352
371
  team: params.team,
353
372
  });
354
373
  const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
355
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
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.', {
@@ -395,7 +415,7 @@ server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of tr
395
415
  }, async (params) => {
396
416
  const result = await requireClient().getPendingTasks(params.user_id);
397
417
  const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
398
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
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'),
@@ -404,7 +424,7 @@ server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of
404
424
  }, async (params) => {
405
425
  const result = await requireClient().getTasksByMilestone(params.milestone_id);
406
426
  const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
407
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
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'),
@@ -414,7 +434,7 @@ server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns
414
434
  }, async (params) => {
415
435
  const result = await requireClient().getTasksByGroupAndUser(params.group_id, params.user_id);
416
436
  const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
417
- return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
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'),
@@ -425,7 +445,7 @@ server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trim
425
445
  const rows = Array.isArray(result)
426
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', {
@@ -29,6 +29,8 @@ export declare const PROJECT_FIELDS: string[];
29
29
  export declare const NOTE_FIELDS: string[];
30
30
  /** Project/task file fields worth returning by default. */
31
31
  export declare const FILE_FIELDS: string[];
32
+ /** Service catalogue fields worth returning by default. */
33
+ export declare const SERVICE_FIELDS: string[];
32
34
  export declare const DEFAULT_LIMIT = 50;
33
35
  export interface ShapeOpts {
34
36
  fields?: string[];
@@ -58,6 +60,13 @@ export declare function filterTasks(rows: unknown[], f: TaskFilters): unknown[];
58
60
  * due_in_days is negative for overdue work, 0 for due today.
59
61
  */
60
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[];
61
70
  /**
62
71
  * Trim, page, and annotate a list response. Non-array payloads (a single record,
63
72
  * or an error object) pass through untouched.
@@ -48,6 +48,11 @@ export const FILE_FIELDS = [
48
48
  'file_size_friendly', 'author', 'user_id', 'post_date', 'url_path',
49
49
  'is_public', 'is_private',
50
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
+ ];
51
56
  export const DEFAULT_LIMIT = 50;
52
57
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
53
58
  /** Parse the staff_assignees blob, which arrives as JSON text or as an array. */
@@ -128,6 +133,39 @@ export function annotateTasks(rows, now = Date.now()) {
128
133
  };
129
134
  });
130
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
+ }
131
169
  function pick(row, fields) {
132
170
  if (!isRecord(row))
133
171
  return row;
@@ -151,7 +189,10 @@ export function shape(payload, defaults, opts = {}) {
151
189
  const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
152
190
  const page = payload.slice(offset, offset + limit);
153
191
  const all = opts.fields?.length === 1 && opts.fields[0] === 'all';
154
- 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);
155
196
  const items = fields ? page.map((r) => pick(r, fields)) : page;
156
197
  const available = isRecord(payload[0]) ? Object.keys(payload[0]).length : 0;
157
198
  const next = offset + page.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.3.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",