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