mcp-connectwise-psa 0.2.0 → 0.3.1
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 +28 -17
- package/dist/config.js +12 -0
- package/dist/cw/client.js +4 -0
- package/dist/http/app.js +10 -0
- package/dist/index.js +6 -1
- package/dist/server.js +19 -8
- package/dist/tools/companies.js +62 -0
- package/dist/tools/finance.js +202 -0
- package/dist/tools/schedule.js +346 -0
- package/dist/tools/tickets.js +164 -0
- package/dist/tools/time.js +143 -0
- package/dist/tools/toolsets.js +83 -0
- package/package.json +1 -1
- package/dist/auth/roles.js +0 -74
- package/dist/auth/tokens.js +0 -93
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/** Dispatch tools: schedule entries — list, my schedule, schedule a ticket. */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { allOf, q } from "../cw/client.js";
|
|
4
|
+
import { clip, failure, json, pageFooter, pageNumberField, pageSizeField, responseFormatField, text, UNKNOWN_MEMBER_MESSAGE, } from "./shared.js";
|
|
5
|
+
const ENTRY_FIELDS = "id,objectId,name,member/identifier,member/name,type/name,type/identifier,status/name,dateStart,dateEnd,hours,doneFlag";
|
|
6
|
+
function entryLine(e) {
|
|
7
|
+
const who = e.member?.identifier ?? e.member?.name ?? "?";
|
|
8
|
+
const when = `${e.dateStart ?? "?"} → ${e.dateEnd ?? "?"}`;
|
|
9
|
+
const what = e.name ?? (e.objectId ? `object #${e.objectId}` : "(unnamed)");
|
|
10
|
+
const tag = [e.type?.name ?? e.type?.identifier, e.status?.name].filter(Boolean).join(" / ");
|
|
11
|
+
return [
|
|
12
|
+
`#${e.id} ${what}`,
|
|
13
|
+
` ${who} | ${when}${e.hours != null ? ` | ${e.hours}h` : ""}${tag ? ` | ${tag}` : ""}${e.doneFlag ? " | done" : ""}`,
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
function entryList(items, page, hasMore) {
|
|
17
|
+
const lines = [`# Schedule entries (${items.length} on this page)`, ""];
|
|
18
|
+
for (const e of items)
|
|
19
|
+
lines.push(entryLine(e), "");
|
|
20
|
+
lines.push(pageFooter(page, hasMore));
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
/** Build the conditions for a schedule-entry query. Exported for tests. */
|
|
24
|
+
export function scheduleConditions(args) {
|
|
25
|
+
return allOf(args.member && `member/identifier=${q(args.member)}`, args.ticket_id !== undefined && `objectId=${args.ticket_id}`, args.on_or_after && `dateStart>=[${args.on_or_after}]`, args.on_or_before && `dateStart<=[${args.on_or_before}]`);
|
|
26
|
+
}
|
|
27
|
+
const MEMBER_LIST_FIELDS = "id,identifier,firstName,lastName,inactiveFlag,timeZone/name,calendar/name,workRole/name,defaultLocation/name,dailyCapacity,scheduleCapacity,restrictScheduleFlag,hideMemberInDispatchPortalFlag";
|
|
28
|
+
const MEMBER_DETAIL_FIELDS = "id,identifier,firstName,lastName,title,inactiveFlag,timeZone/name,calendar/id,calendar/name,workRole/name,defaultLocation/name,securityLocation/name,dailyCapacity,scheduleCapacity,restrictScheduleFlag,hideMemberInDispatchPortalFlag,officeEmail,primaryEmail";
|
|
29
|
+
const CALENDAR_FIELDS = "id,name,holidayList/name,mondayStartTime,mondayEndTime,tuesdayStartTime,tuesdayEndTime,wednesdayStartTime,wednesdayEndTime,thursdayStartTime,thursdayEndTime,fridayStartTime,fridayEndTime,saturdayStartTime,saturdayEndTime,sundayStartTime,sundayEndTime";
|
|
30
|
+
function memberLine(m) {
|
|
31
|
+
const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || "?";
|
|
32
|
+
const cap = m.scheduleCapacity ?? m.dailyCapacity;
|
|
33
|
+
const bits = [
|
|
34
|
+
`TZ: ${m.timeZone?.name ?? "—"}`,
|
|
35
|
+
`hours: ${m.calendar?.name ?? "—"}`,
|
|
36
|
+
m.workRole?.name && `role: ${m.workRole.name}`,
|
|
37
|
+
cap != null && `cap: ${cap}h/day`,
|
|
38
|
+
].filter(Boolean);
|
|
39
|
+
const flags = [
|
|
40
|
+
m.restrictScheduleFlag && "schedule-restricted",
|
|
41
|
+
m.hideMemberInDispatchPortalFlag && "hidden-in-dispatch",
|
|
42
|
+
m.inactiveFlag && "INACTIVE",
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
return [
|
|
45
|
+
`#${m.id} ${m.identifier} — ${name}`,
|
|
46
|
+
` ${bits.join(" | ")}${flags.length ? ` | ${flags.join(", ")}` : ""}`,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
|
50
|
+
function calendarHours(cal) {
|
|
51
|
+
return WEEKDAYS.map((d) => {
|
|
52
|
+
const start = cal[`${d}StartTime`];
|
|
53
|
+
const end = cal[`${d}EndTime`];
|
|
54
|
+
const label = d.charAt(0).toUpperCase() + d.slice(1);
|
|
55
|
+
return ` ${label}: ${start && end ? `${start}–${end}` : "off"}`;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function registerScheduleTools(reg, client) {
|
|
59
|
+
reg.register({
|
|
60
|
+
name: "cw_list_schedule_entries",
|
|
61
|
+
title: "List ConnectWise Schedule Entries",
|
|
62
|
+
description: "List dispatch/schedule entries, optionally filtered by member, ticket, or date range " +
|
|
63
|
+
"(dates as YYYY-MM-DD). Use to see who is scheduled for what and when.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
member: z.string().optional().describe("Member identifier the entry is assigned to"),
|
|
66
|
+
ticket_id: z.number().int().positive().optional().describe("Only entries scheduled for this ticket"),
|
|
67
|
+
on_or_after: z.string().optional().describe("Only entries starting on/after this date (YYYY-MM-DD)"),
|
|
68
|
+
on_or_before: z.string().optional().describe("Only entries starting on/before this date (YYYY-MM-DD)"),
|
|
69
|
+
page_number: pageNumberField,
|
|
70
|
+
page_size: pageSizeField,
|
|
71
|
+
response_format: responseFormatField,
|
|
72
|
+
},
|
|
73
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
74
|
+
}, async (args) => {
|
|
75
|
+
try {
|
|
76
|
+
const page = await client.getList("/schedule/entries", {
|
|
77
|
+
conditions: scheduleConditions(args),
|
|
78
|
+
orderBy: "dateStart desc",
|
|
79
|
+
fields: ENTRY_FIELDS,
|
|
80
|
+
page: args.page_number,
|
|
81
|
+
pageSize: args.page_size,
|
|
82
|
+
});
|
|
83
|
+
if (page.items.length === 0)
|
|
84
|
+
return text("No schedule entries found.");
|
|
85
|
+
if (args.response_format === "json")
|
|
86
|
+
return text(clip(json(page)));
|
|
87
|
+
return text(clip(entryList(page.items, page.page, page.hasMore)));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return failure(error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
reg.register({
|
|
94
|
+
name: "cw_my_schedule",
|
|
95
|
+
title: "My ConnectWise Schedule",
|
|
96
|
+
description: "List schedule entries assigned to the member this session acts as, optionally within a date range.",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
on_or_after: z.string().optional().describe("Only entries starting on/after this date (YYYY-MM-DD)"),
|
|
99
|
+
on_or_before: z.string().optional().describe("Only entries starting on/before this date (YYYY-MM-DD)"),
|
|
100
|
+
page_number: pageNumberField,
|
|
101
|
+
page_size: pageSizeField,
|
|
102
|
+
response_format: responseFormatField,
|
|
103
|
+
},
|
|
104
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
105
|
+
}, async (args) => {
|
|
106
|
+
try {
|
|
107
|
+
const memberId = await client.me();
|
|
108
|
+
if (!memberId)
|
|
109
|
+
return { ...text(UNKNOWN_MEMBER_MESSAGE), isError: true };
|
|
110
|
+
const page = await client.getList("/schedule/entries", {
|
|
111
|
+
conditions: scheduleConditions({ member: memberId, ...args }),
|
|
112
|
+
orderBy: "dateStart desc",
|
|
113
|
+
fields: ENTRY_FIELDS,
|
|
114
|
+
page: args.page_number,
|
|
115
|
+
pageSize: args.page_size,
|
|
116
|
+
});
|
|
117
|
+
if (page.items.length === 0)
|
|
118
|
+
return text(`No schedule entries for ${memberId}.`);
|
|
119
|
+
if (args.response_format === "json")
|
|
120
|
+
return text(clip(json(page)));
|
|
121
|
+
return text(clip(`Acting as **${memberId}**\n\n` + entryList(page.items, page.page, page.hasMore)));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return failure(error);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
reg.register({
|
|
128
|
+
name: "cw_schedule_ticket",
|
|
129
|
+
title: "Schedule a ConnectWise Ticket",
|
|
130
|
+
description: "Schedule a service ticket to a member for a time window. Times are ISO-8601 " +
|
|
131
|
+
"(e.g. 2026-07-06T13:00:00Z). Creates a schedule entry on the dispatch board.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
ticket_id: z.number().int().positive().describe("The service ticket to schedule"),
|
|
134
|
+
member: z.string().describe("Member identifier to schedule the ticket to"),
|
|
135
|
+
time_start: z.string().describe("Start time, ISO-8601"),
|
|
136
|
+
time_end: z.string().describe("End time, ISO-8601"),
|
|
137
|
+
response_format: responseFormatField,
|
|
138
|
+
},
|
|
139
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
140
|
+
}, async (args) => {
|
|
141
|
+
try {
|
|
142
|
+
const entry = await client.post("/schedule/entries", {
|
|
143
|
+
objectId: args.ticket_id,
|
|
144
|
+
member: { identifier: args.member },
|
|
145
|
+
type: { identifier: "S" }, // "S" = Service Ticket schedule type in ConnectWise
|
|
146
|
+
dateStart: args.time_start,
|
|
147
|
+
dateEnd: args.time_end,
|
|
148
|
+
});
|
|
149
|
+
if (args.response_format === "json")
|
|
150
|
+
return text(json(entry));
|
|
151
|
+
return text(`Scheduled ticket #${args.ticket_id} to ${args.member} (${args.time_start} → ${args.time_end}) — entry #${entry.id}.`);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return failure(error);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
reg.register({
|
|
158
|
+
name: "cw_list_members",
|
|
159
|
+
title: "List ConnectWise Members",
|
|
160
|
+
description: "List members (technicians) for dispatch — each with timezone, working-hours calendar name, " +
|
|
161
|
+
"work role, and daily/schedule capacity. Filter by name or identifier; excludes inactive by default.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
name_contains: z.string().optional().describe("Match first or last name (substring)"),
|
|
164
|
+
identifier: z.string().optional().describe("Exact member identifier"),
|
|
165
|
+
include_inactive: z.boolean().default(false).describe("Include inactive members (default false)"),
|
|
166
|
+
schedulable_only: z
|
|
167
|
+
.boolean()
|
|
168
|
+
.default(false)
|
|
169
|
+
.describe("Only members that are not schedule-restricted (default false)"),
|
|
170
|
+
page_number: pageNumberField,
|
|
171
|
+
page_size: pageSizeField,
|
|
172
|
+
response_format: responseFormatField,
|
|
173
|
+
},
|
|
174
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
175
|
+
}, async (args) => {
|
|
176
|
+
try {
|
|
177
|
+
const conditions = allOf(!args.include_inactive && "inactiveFlag=false", args.identifier && `identifier=${q(args.identifier)}`, args.name_contains &&
|
|
178
|
+
`(firstName contains ${q(args.name_contains)} OR lastName contains ${q(args.name_contains)})`, args.schedulable_only && "restrictScheduleFlag=false");
|
|
179
|
+
const page = await client.getList("/system/members", {
|
|
180
|
+
conditions,
|
|
181
|
+
orderBy: "identifier asc",
|
|
182
|
+
fields: MEMBER_LIST_FIELDS,
|
|
183
|
+
page: args.page_number,
|
|
184
|
+
pageSize: args.page_size,
|
|
185
|
+
});
|
|
186
|
+
if (page.items.length === 0)
|
|
187
|
+
return text("No members found.");
|
|
188
|
+
if (args.response_format === "json")
|
|
189
|
+
return text(clip(json(page)));
|
|
190
|
+
const lines = [`# Members (${page.items.length} on this page)`, ""];
|
|
191
|
+
for (const m of page.items)
|
|
192
|
+
lines.push(memberLine(m), "");
|
|
193
|
+
lines.push(pageFooter(page.page, page.hasMore));
|
|
194
|
+
return text(clip(lines.join("\n")));
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return failure(error);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
reg.register({
|
|
201
|
+
name: "cw_get_member",
|
|
202
|
+
title: "Get ConnectWise Member",
|
|
203
|
+
description: "Get one member's dispatch profile: timezone, work role, location, capacity, and their working " +
|
|
204
|
+
"hours (resolved from the member's calendar, including the holiday list).",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
member_id: z.number().int().positive().describe("The member ID (from cw_list_members)"),
|
|
207
|
+
response_format: responseFormatField,
|
|
208
|
+
},
|
|
209
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
210
|
+
}, async (args) => {
|
|
211
|
+
try {
|
|
212
|
+
const m = await client.getOne(`/system/members/${args.member_id}`, MEMBER_DETAIL_FIELDS);
|
|
213
|
+
let calendar;
|
|
214
|
+
if (m.calendar?.id) {
|
|
215
|
+
try {
|
|
216
|
+
calendar = await client.getOne(`/schedule/calendars/${m.calendar.id}`, CALENDAR_FIELDS);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* calendar is best-effort; member profile is still useful without it */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (args.response_format === "json")
|
|
223
|
+
return text(clip(json({ ...m, calendarDetail: calendar })));
|
|
224
|
+
const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || m.identifier;
|
|
225
|
+
const cap = m.scheduleCapacity ?? m.dailyCapacity;
|
|
226
|
+
const lines = [
|
|
227
|
+
`# ${name} (${m.identifier})${m.inactiveFlag ? " — INACTIVE" : ""}`,
|
|
228
|
+
"",
|
|
229
|
+
`- **Title**: ${m.title ?? "—"} | **Work role**: ${m.workRole?.name ?? "—"}`,
|
|
230
|
+
`- **Timezone**: ${m.timeZone?.name ?? "—"}`,
|
|
231
|
+
`- **Location**: ${m.defaultLocation?.name ?? m.securityLocation?.name ?? "—"}`,
|
|
232
|
+
`- **Capacity**: ${cap != null ? `${cap}h/day` : "—"}${m.restrictScheduleFlag ? " | schedule-restricted" : ""}${m.hideMemberInDispatchPortalFlag ? " | hidden in dispatch" : ""}`,
|
|
233
|
+
];
|
|
234
|
+
if (calendar) {
|
|
235
|
+
lines.push("", `## Working hours — ${calendar.name ?? "calendar"}`, ...calendarHours(calendar));
|
|
236
|
+
if (calendar.holidayList?.name)
|
|
237
|
+
lines.push(` Holidays: ${calendar.holidayList.name}`);
|
|
238
|
+
}
|
|
239
|
+
else if (m.calendar?.name) {
|
|
240
|
+
lines.push("", `Working-hours calendar: ${m.calendar.name} (hours unavailable)`);
|
|
241
|
+
}
|
|
242
|
+
return text(clip(lines.join("\n")));
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return failure(error);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
reg.register({
|
|
249
|
+
name: "cw_update_schedule_entry",
|
|
250
|
+
title: "Update a ConnectWise Schedule Entry",
|
|
251
|
+
description: "Reschedule or reassign a schedule entry, or mark it done. Times are ISO-8601. Only provided fields change.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
entry_id: z.number().int().positive().describe("The schedule entry ID (from cw_list_schedule_entries)"),
|
|
254
|
+
member: z.string().optional().describe("Reassign to this member identifier"),
|
|
255
|
+
time_start: z.string().optional().describe("New start time, ISO-8601"),
|
|
256
|
+
time_end: z.string().optional().describe("New end time, ISO-8601"),
|
|
257
|
+
done: z.boolean().optional().describe("Mark the entry done (true) or not done (false)"),
|
|
258
|
+
response_format: responseFormatField,
|
|
259
|
+
},
|
|
260
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
261
|
+
}, async (args) => {
|
|
262
|
+
try {
|
|
263
|
+
const ops = [];
|
|
264
|
+
if (args.member)
|
|
265
|
+
ops.push({ op: "replace", path: "member", value: { identifier: args.member } });
|
|
266
|
+
if (args.time_start)
|
|
267
|
+
ops.push({ op: "replace", path: "dateStart", value: args.time_start });
|
|
268
|
+
if (args.time_end)
|
|
269
|
+
ops.push({ op: "replace", path: "dateEnd", value: args.time_end });
|
|
270
|
+
if (args.done !== undefined)
|
|
271
|
+
ops.push({ op: "replace", path: "doneFlag", value: args.done });
|
|
272
|
+
if (ops.length === 0)
|
|
273
|
+
return text("Nothing to update — provide at least one field.");
|
|
274
|
+
const entry = await client.patch(`/schedule/entries/${args.entry_id}`, ops);
|
|
275
|
+
if (args.response_format === "json")
|
|
276
|
+
return text(json(entry));
|
|
277
|
+
return text(`Schedule entry #${entry.id} updated — ${entry.member?.identifier ?? "?"} | ${entry.dateStart ?? "?"} → ${entry.dateEnd ?? "?"}${entry.doneFlag ? " | done" : ""}.`);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return failure(error);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
reg.register({
|
|
284
|
+
name: "cw_delete_schedule_entry",
|
|
285
|
+
title: "Delete a ConnectWise Schedule Entry",
|
|
286
|
+
description: "Remove a schedule entry from the dispatch board (e.g. to cancel a scheduled visit).",
|
|
287
|
+
inputSchema: {
|
|
288
|
+
entry_id: z.number().int().positive().describe("The schedule entry ID to delete"),
|
|
289
|
+
},
|
|
290
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
291
|
+
}, async (args) => {
|
|
292
|
+
try {
|
|
293
|
+
await client.del(`/schedule/entries/${args.entry_id}`);
|
|
294
|
+
return text(`Schedule entry #${args.entry_id} deleted.`);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return failure(error);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
reg.register({
|
|
301
|
+
name: "cw_member_availability",
|
|
302
|
+
title: "ConnectWise Member Availability",
|
|
303
|
+
description: "Show a member's booked vs free hours on a given day (YYYY-MM-DD): their schedule capacity " +
|
|
304
|
+
"minus the hours already scheduled that day.",
|
|
305
|
+
inputSchema: {
|
|
306
|
+
member: z.string().describe("Member identifier"),
|
|
307
|
+
date: z.string().describe("Day to check, YYYY-MM-DD"),
|
|
308
|
+
response_format: responseFormatField,
|
|
309
|
+
},
|
|
310
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
311
|
+
}, async (args) => {
|
|
312
|
+
try {
|
|
313
|
+
const members = await client.getList("/system/members", {
|
|
314
|
+
conditions: `identifier=${q(args.member)}`,
|
|
315
|
+
fields: "id,identifier,firstName,lastName,scheduleCapacity,dailyCapacity,timeZone/name",
|
|
316
|
+
pageSize: 1,
|
|
317
|
+
});
|
|
318
|
+
const m = members.items[0];
|
|
319
|
+
if (!m)
|
|
320
|
+
return text(`No member "${args.member}" found.`);
|
|
321
|
+
const entries = await client.getList("/schedule/entries", {
|
|
322
|
+
conditions: allOf(`member/identifier=${q(args.member)}`, `dateStart>=[${args.date}T00:00:00Z]`, `dateStart<=[${args.date}T23:59:59Z]`),
|
|
323
|
+
orderBy: "dateStart asc",
|
|
324
|
+
fields: "id,name,objectId,dateStart,dateEnd,hours,doneFlag",
|
|
325
|
+
pageSize: 200,
|
|
326
|
+
});
|
|
327
|
+
const booked = entries.items.reduce((s, e) => s + (e.hours ?? 0), 0);
|
|
328
|
+
const capacity = m.scheduleCapacity ?? m.dailyCapacity ?? 0;
|
|
329
|
+
const free = capacity - booked;
|
|
330
|
+
if (args.response_format === "json")
|
|
331
|
+
return text(json({ member: m, date: args.date, capacity, booked, free, entries: entries.items }));
|
|
332
|
+
const lines = [
|
|
333
|
+
`# ${args.member} — ${args.date}`,
|
|
334
|
+
`TZ: ${m.timeZone?.name ?? "—"} | capacity ${capacity}h | booked ${booked}h | **free ${free}h**`,
|
|
335
|
+
"",
|
|
336
|
+
entries.items.length ? "## Booked" : "Nothing scheduled.",
|
|
337
|
+
];
|
|
338
|
+
for (const e of entries.items)
|
|
339
|
+
lines.push(`- ${e.dateStart ?? "?"} → ${e.dateEnd ?? "?"} | ${e.hours ?? 0}h | ${e.name ?? (e.objectId ? `ticket #${e.objectId}` : "?")}${e.doneFlag ? " (done)" : ""}`);
|
|
340
|
+
return text(clip(lines.join("\n")));
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return failure(error);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
package/dist/tools/tickets.js
CHANGED
|
@@ -231,4 +231,168 @@ export function registerTicketTools(reg, client) {
|
|
|
231
231
|
return failure(error);
|
|
232
232
|
}
|
|
233
233
|
});
|
|
234
|
+
reg.register({
|
|
235
|
+
name: "cw_list_boards",
|
|
236
|
+
title: "List ConnectWise Service Boards",
|
|
237
|
+
description: "List service boards. Use to discover exact board names for cw_search_tickets / cw_create_ticket, " +
|
|
238
|
+
"and board ids for cw_get_board (statuses and types).",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
name_contains: z.string().optional().describe("Filter by board name (substring)"),
|
|
241
|
+
include_inactive: z.boolean().default(false).describe("Include inactive boards (default false)"),
|
|
242
|
+
page_number: pageNumberField,
|
|
243
|
+
page_size: pageSizeField,
|
|
244
|
+
response_format: responseFormatField,
|
|
245
|
+
},
|
|
246
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
247
|
+
}, async (args) => {
|
|
248
|
+
try {
|
|
249
|
+
const page = await client.getList("/service/boards", {
|
|
250
|
+
conditions: allOf(!args.include_inactive && "inactiveFlag=false", args.name_contains && `name contains ${q(args.name_contains)}`),
|
|
251
|
+
orderBy: "name asc",
|
|
252
|
+
fields: "id,name,inactiveFlag,department/name,location/name",
|
|
253
|
+
page: args.page_number,
|
|
254
|
+
pageSize: args.page_size,
|
|
255
|
+
});
|
|
256
|
+
if (page.items.length === 0)
|
|
257
|
+
return text("No boards found.");
|
|
258
|
+
if (args.response_format === "json")
|
|
259
|
+
return text(clip(json(page)));
|
|
260
|
+
const lines = [`# Service boards (${page.items.length})`, ""];
|
|
261
|
+
for (const b of page.items)
|
|
262
|
+
lines.push(`- #${b.id} **${b.name ?? "?"}**${b.inactiveFlag ? " (inactive)" : ""}`);
|
|
263
|
+
lines.push("", pageFooter(page.page, page.hasMore));
|
|
264
|
+
return text(clip(lines.join("\n")));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
return failure(error);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
reg.register({
|
|
271
|
+
name: "cw_get_board",
|
|
272
|
+
title: "Get ConnectWise Board Statuses and Types",
|
|
273
|
+
description: "List the statuses and types available on a service board — the exact names cw_update_ticket " +
|
|
274
|
+
"and cw_create_ticket require. Get the board id from cw_list_boards.",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
board_id: z.number().int().positive().describe("The board ID (from cw_list_boards)"),
|
|
277
|
+
response_format: responseFormatField,
|
|
278
|
+
},
|
|
279
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
280
|
+
}, async (args) => {
|
|
281
|
+
try {
|
|
282
|
+
const [statuses, types] = await Promise.all([
|
|
283
|
+
client.getList(`/service/boards/${args.board_id}/statuses`, {
|
|
284
|
+
orderBy: "sortOrder asc",
|
|
285
|
+
fields: "id,name,closedStatus,defaultFlag,inactive",
|
|
286
|
+
pageSize: 200,
|
|
287
|
+
}),
|
|
288
|
+
client.getList(`/service/boards/${args.board_id}/types`, {
|
|
289
|
+
orderBy: "name asc",
|
|
290
|
+
fields: "id,name,defaultFlag,inactiveFlag",
|
|
291
|
+
pageSize: 200,
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
if (args.response_format === "json")
|
|
295
|
+
return text(clip(json({ statuses: statuses.items, types: types.items })));
|
|
296
|
+
const lines = [`# Board #${args.board_id}`, "", "## Statuses"];
|
|
297
|
+
for (const s of statuses.items)
|
|
298
|
+
if (!s.inactive)
|
|
299
|
+
lines.push(`- ${s.name}${s.closedStatus ? " (closed)" : ""}${s.defaultFlag ? " (default)" : ""}`);
|
|
300
|
+
lines.push("", "## Types");
|
|
301
|
+
for (const t of types.items)
|
|
302
|
+
if (!t.inactiveFlag)
|
|
303
|
+
lines.push(`- ${t.name}${t.defaultFlag ? " (default)" : ""}`);
|
|
304
|
+
return text(clip(lines.join("\n")));
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return failure(error);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
reg.register({
|
|
311
|
+
name: "cw_list_priorities",
|
|
312
|
+
title: "List ConnectWise Priorities",
|
|
313
|
+
description: "List ticket priorities (exact names for cw_create_ticket / cw_update_ticket).",
|
|
314
|
+
inputSchema: { response_format: responseFormatField },
|
|
315
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
316
|
+
}, async (args) => {
|
|
317
|
+
try {
|
|
318
|
+
const page = await client.getList("/service/priorities", {
|
|
319
|
+
orderBy: "sortOrder asc",
|
|
320
|
+
fields: "id,name,sortOrder,defaultFlag",
|
|
321
|
+
pageSize: 200,
|
|
322
|
+
});
|
|
323
|
+
if (args.response_format === "json")
|
|
324
|
+
return text(clip(json(page.items)));
|
|
325
|
+
const lines = ["# Priorities", ""];
|
|
326
|
+
for (const p of page.items)
|
|
327
|
+
lines.push(`- ${p.name}${p.defaultFlag ? " (default)" : ""}`);
|
|
328
|
+
return text(clip(lines.join("\n")));
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
return failure(error);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
reg.register({
|
|
335
|
+
name: "cw_list_ticket_time",
|
|
336
|
+
title: "List Time Entries on a Ticket",
|
|
337
|
+
description: "List all time entries logged against a ticket (by any member), newest first.",
|
|
338
|
+
inputSchema: {
|
|
339
|
+
ticket_id: z.number().int().positive().describe("The ticket ID"),
|
|
340
|
+
page_number: pageNumberField,
|
|
341
|
+
page_size: pageSizeField,
|
|
342
|
+
response_format: responseFormatField,
|
|
343
|
+
},
|
|
344
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
345
|
+
}, async (args) => {
|
|
346
|
+
try {
|
|
347
|
+
const page = await client.getList("/time/entries", {
|
|
348
|
+
conditions: `chargeToId=${args.ticket_id} AND chargeToType="ServiceTicket"`,
|
|
349
|
+
orderBy: "timeStart desc",
|
|
350
|
+
fields: "id,member/identifier,timeStart,timeEnd,actualHours,billableOption,workType/name,notes",
|
|
351
|
+
page: args.page_number,
|
|
352
|
+
pageSize: args.page_size,
|
|
353
|
+
});
|
|
354
|
+
if (page.items.length === 0)
|
|
355
|
+
return text(`No time entries on ticket #${args.ticket_id}.`);
|
|
356
|
+
if (args.response_format === "json")
|
|
357
|
+
return text(clip(json(page)));
|
|
358
|
+
const total = page.items.reduce((s, e) => s + (e.actualHours ?? 0), 0);
|
|
359
|
+
const lines = [`# Time on ticket #${args.ticket_id} (${page.items.length} entries, ${total}h shown)`, ""];
|
|
360
|
+
for (const e of page.items)
|
|
361
|
+
lines.push(`- ${e.member?.identifier ?? "?"} | ${e.actualHours ?? 0}h | ${e.billableOption ?? "?"} | ${e.timeStart ?? "?"}${e.notes ? ` — ${e.notes.slice(0, 80)}` : ""}`);
|
|
362
|
+
lines.push("", pageFooter(page.page, page.hasMore));
|
|
363
|
+
return text(clip(lines.join("\n")));
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
return failure(error);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
reg.register({
|
|
370
|
+
name: "cw_list_ticket_tasks",
|
|
371
|
+
title: "List ConnectWise Ticket Tasks",
|
|
372
|
+
description: "List the task/checklist items on a ticket, with their done state.",
|
|
373
|
+
inputSchema: {
|
|
374
|
+
ticket_id: z.number().int().positive().describe("The ticket ID"),
|
|
375
|
+
response_format: responseFormatField,
|
|
376
|
+
},
|
|
377
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
378
|
+
}, async (args) => {
|
|
379
|
+
try {
|
|
380
|
+
const page = await client.getList(`/service/tickets/${args.ticket_id}/tasks`, {
|
|
381
|
+
fields: "id,notes,closedFlag,priority,resolution",
|
|
382
|
+
pageSize: 200,
|
|
383
|
+
});
|
|
384
|
+
if (page.items.length === 0)
|
|
385
|
+
return text(`No tasks on ticket #${args.ticket_id}.`);
|
|
386
|
+
if (args.response_format === "json")
|
|
387
|
+
return text(clip(json(page.items)));
|
|
388
|
+
const done = page.items.filter((t) => t.closedFlag).length;
|
|
389
|
+
const lines = [`# Tasks on ticket #${args.ticket_id} (${done}/${page.items.length} done)`, ""];
|
|
390
|
+
for (const t of page.items)
|
|
391
|
+
lines.push(`- [${t.closedFlag ? "x" : " "}] ${t.notes ?? "(no text)"}${t.resolution ? ` → ${t.resolution}` : ""}`);
|
|
392
|
+
return text(clip(lines.join("\n")));
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return failure(error);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
234
398
|
}
|
package/dist/tools/time.js
CHANGED
|
@@ -57,6 +57,60 @@ export function registerTimeTools(reg, client) {
|
|
|
57
57
|
return failure(error);
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
|
+
reg.register({
|
|
61
|
+
name: "cw_update_time_entry",
|
|
62
|
+
title: "Update ConnectWise Time Entry",
|
|
63
|
+
description: "Edit an existing time entry — its notes, start/end, billable flag, or work role. Only provided " +
|
|
64
|
+
"fields change. Duration is set by time_start/time_end (hours are derived, not edited directly). " +
|
|
65
|
+
"Governed by your ConnectWise security role (typically your own entries).",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
entry_id: z.number().int().positive().describe("The time entry ID (from cw_list_my_time)"),
|
|
68
|
+
notes: z.string().min(1).optional().describe("New work-performed notes"),
|
|
69
|
+
time_start: z.string().optional().describe("New start time, ISO 8601"),
|
|
70
|
+
time_end: z.string().optional().describe("New end time, ISO 8601 (changes the billed hours)"),
|
|
71
|
+
billable: z.boolean().optional().describe("Billable (true) or do-not-bill (false)"),
|
|
72
|
+
work_role: z.string().optional().describe("Work role name"),
|
|
73
|
+
response_format: responseFormatField,
|
|
74
|
+
},
|
|
75
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
76
|
+
}, async (args) => {
|
|
77
|
+
try {
|
|
78
|
+
let start;
|
|
79
|
+
let end;
|
|
80
|
+
if (args.time_start !== undefined) {
|
|
81
|
+
start = new Date(args.time_start);
|
|
82
|
+
if (Number.isNaN(start.getTime()))
|
|
83
|
+
return { ...text("Error: time_start is not a valid ISO timestamp."), isError: true };
|
|
84
|
+
}
|
|
85
|
+
if (args.time_end !== undefined) {
|
|
86
|
+
end = new Date(args.time_end);
|
|
87
|
+
if (Number.isNaN(end.getTime()))
|
|
88
|
+
return { ...text("Error: time_end is not a valid ISO timestamp."), isError: true };
|
|
89
|
+
}
|
|
90
|
+
if (start && end && end <= start)
|
|
91
|
+
return { ...text("Error: time_end must be after time_start."), isError: true };
|
|
92
|
+
const ops = [];
|
|
93
|
+
if (args.notes !== undefined)
|
|
94
|
+
ops.push({ op: "replace", path: "notes", value: args.notes });
|
|
95
|
+
if (start)
|
|
96
|
+
ops.push({ op: "replace", path: "timeStart", value: cwTimestamp(start) });
|
|
97
|
+
if (end)
|
|
98
|
+
ops.push({ op: "replace", path: "timeEnd", value: cwTimestamp(end) });
|
|
99
|
+
if (args.billable !== undefined)
|
|
100
|
+
ops.push({ op: "replace", path: "billableOption", value: args.billable ? "Billable" : "DoNotBill" });
|
|
101
|
+
if (args.work_role !== undefined)
|
|
102
|
+
ops.push({ op: "replace", path: "workType", value: { name: args.work_role } });
|
|
103
|
+
if (ops.length === 0)
|
|
104
|
+
return text("Nothing to update — provide at least one field to change.");
|
|
105
|
+
const entry = await client.patch(`/time/entries/${args.entry_id}`, ops);
|
|
106
|
+
if (args.response_format === "json")
|
|
107
|
+
return text(json(entry));
|
|
108
|
+
return text(`Time entry ${entry.id} updated — ${entry.actualHours ?? "?"}h | ${entry.billableOption ?? "?"} | ${entry.timeStart ?? "?"}${entry.notes ? ` — ${entry.notes.slice(0, 80)}` : ""}.`);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return failure(error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
60
114
|
reg.register({
|
|
61
115
|
name: "cw_list_my_time",
|
|
62
116
|
title: "My ConnectWise Time Entries",
|
|
@@ -98,4 +152,93 @@ export function registerTimeTools(reg, client) {
|
|
|
98
152
|
return failure(error);
|
|
99
153
|
}
|
|
100
154
|
});
|
|
155
|
+
reg.register({
|
|
156
|
+
name: "cw_list_work_roles",
|
|
157
|
+
title: "List ConnectWise Work Roles",
|
|
158
|
+
description: "List work roles (used when logging time). Excludes inactive by default.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
name_contains: z.string().optional().describe("Filter by work role name (substring)"),
|
|
161
|
+
include_inactive: z.boolean().default(false).describe("Include inactive roles (default false)"),
|
|
162
|
+
response_format: responseFormatField,
|
|
163
|
+
},
|
|
164
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
165
|
+
}, async (args) => {
|
|
166
|
+
try {
|
|
167
|
+
const page = await client.getList("/time/workRoles", {
|
|
168
|
+
conditions: allOf(!args.include_inactive && "inactiveFlag=false", args.name_contains && `name contains ${q(args.name_contains)}`),
|
|
169
|
+
orderBy: "name asc",
|
|
170
|
+
fields: "id,name,hourlyRate,inactiveFlag",
|
|
171
|
+
pageSize: 200,
|
|
172
|
+
});
|
|
173
|
+
if (page.items.length === 0)
|
|
174
|
+
return text("No work roles found.");
|
|
175
|
+
if (args.response_format === "json")
|
|
176
|
+
return text(clip(json(page.items)));
|
|
177
|
+
const lines = ["# Work roles", ""];
|
|
178
|
+
for (const w of page.items)
|
|
179
|
+
lines.push(`- ${w.name}${w.inactiveFlag ? " (inactive)" : ""}`);
|
|
180
|
+
return text(clip(lines.join("\n")));
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return failure(error);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
reg.register({
|
|
187
|
+
name: "cw_list_my_timesheets",
|
|
188
|
+
title: "List My ConnectWise Timesheets",
|
|
189
|
+
description: "List timesheets for the member this session acts as, newest first, with their status " +
|
|
190
|
+
"(Open, Submitted, Approved…). Use the id with cw_submit_timesheet.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
page_number: pageNumberField,
|
|
193
|
+
page_size: pageSizeField,
|
|
194
|
+
response_format: responseFormatField,
|
|
195
|
+
},
|
|
196
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
197
|
+
}, async (args) => {
|
|
198
|
+
try {
|
|
199
|
+
const memberId = await client.me();
|
|
200
|
+
if (!memberId)
|
|
201
|
+
return { ...text(UNKNOWN_MEMBER_MESSAGE), isError: true };
|
|
202
|
+
const page = await client.getList("/time/sheets", {
|
|
203
|
+
conditions: `member/identifier=${q(memberId)}`,
|
|
204
|
+
orderBy: "dateStart desc",
|
|
205
|
+
fields: "id,year,period,dateStart,dateEnd,status,hours",
|
|
206
|
+
page: args.page_number,
|
|
207
|
+
pageSize: args.page_size,
|
|
208
|
+
});
|
|
209
|
+
if (page.items.length === 0)
|
|
210
|
+
return text(`No timesheets for ${memberId}.`);
|
|
211
|
+
if (args.response_format === "json")
|
|
212
|
+
return text(clip(json(page)));
|
|
213
|
+
const lines = [`# Timesheets for ${memberId} (${page.items.length})`, ""];
|
|
214
|
+
for (const s of page.items)
|
|
215
|
+
lines.push(`- #${s.id} | ${s.dateStart?.slice(0, 10) ?? "?"} → ${s.dateEnd?.slice(0, 10) ?? "?"} | ${s.hours ?? 0}h | **${s.status ?? "?"}**`);
|
|
216
|
+
lines.push("", pageFooter(page.page, page.hasMore));
|
|
217
|
+
return text(clip(lines.join("\n")));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
return failure(error);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
reg.register({
|
|
224
|
+
name: "cw_submit_timesheet",
|
|
225
|
+
title: "Submit ConnectWise Timesheet",
|
|
226
|
+
description: "Submit a timesheet for approval. Get the timesheet id from cw_list_my_timesheets; " +
|
|
227
|
+
"only Open timesheets can be submitted.",
|
|
228
|
+
inputSchema: {
|
|
229
|
+
timesheet_id: z.number().int().positive().describe("The timesheet ID (from cw_list_my_timesheets)"),
|
|
230
|
+
response_format: responseFormatField,
|
|
231
|
+
},
|
|
232
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
233
|
+
}, async (args) => {
|
|
234
|
+
try {
|
|
235
|
+
const sheet = await client.post(`/time/sheets/${args.timesheet_id}/submit`, {});
|
|
236
|
+
if (args.response_format === "json")
|
|
237
|
+
return text(json(sheet));
|
|
238
|
+
return text(`Timesheet #${args.timesheet_id} submitted${sheet?.status ? ` — status: ${sheet.status}` : ""}.`);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return failure(error);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
101
244
|
}
|