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 +22 -0
- package/dist/api-client.d.ts +29 -0
- package/dist/api-client.js +71 -0
- package/dist/idempotency.d.ts +14 -0
- package/dist/idempotency.js +92 -0
- package/dist/index.js +90 -18
- package/dist/projection.d.ts +9 -0
- package/dist/projection.js +42 -1
- package/package.json +1 -1
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)
|
package/dist/api-client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/api-client.js
CHANGED
|
@@ -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', {
|
package/dist/projection.d.ts
CHANGED
|
@@ -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.
|
package/dist/projection.js
CHANGED
|
@@ -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
|
-
|
|
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