mcp-connectwise-psa 0.1.0 → 0.3.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 +33 -36
- package/dist/config.js +20 -18
- package/dist/cw/client.js +4 -0
- package/dist/http/app.js +39 -67
- package/dist/index.js +16 -20
- package/dist/server.js +22 -10
- package/dist/tools/companies.js +62 -0
- package/dist/tools/finance.js +202 -0
- package/dist/tools/registrar.js +24 -0
- package/dist/tools/schedule.js +346 -0
- package/dist/tools/tickets.js +164 -0
- package/dist/tools/time.js +89 -0
- package/dist/tools/toolsets.js +83 -0
- package/package.json +3 -3
- package/dist/auth/roles.js +0 -74
- package/dist/auth/tokens.js +0 -93
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Invoicing tools: invoices and agreements (read-only). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { allOf, q } from "../cw/client.js";
|
|
4
|
+
import { clip, failure, json, pageFooter, pageNumberField, pageSizeField, responseFormatField, text, } from "./shared.js";
|
|
5
|
+
const INVOICE_FIELDS = "id,invoiceNumber,type,status/name,company/name,company/id,date,dueDate,total,balance";
|
|
6
|
+
const AGREEMENT_FIELDS = "id,name,type/name,company/name,company/id,agreementStatus,startDate,endDate,billAmount,cancelledFlag,noEndingDateFlag";
|
|
7
|
+
function money(value) {
|
|
8
|
+
return value == null ? "—" : `$${value.toFixed(2)}`;
|
|
9
|
+
}
|
|
10
|
+
function invoiceLine(inv) {
|
|
11
|
+
return [
|
|
12
|
+
`#${inv.id} ${inv.invoiceNumber ?? "(no number)"} — ${inv.company?.name ?? "?"}`,
|
|
13
|
+
` ${inv.status?.name ?? "?"} | ${inv.date ?? "?"}${inv.dueDate ? ` (due ${inv.dueDate})` : ""} | total ${money(inv.total)} | balance ${money(inv.balance)}`,
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
function agreementLine(a) {
|
|
17
|
+
const period = a.noEndingDateFlag ? `${a.startDate ?? "?"} → (ongoing)` : `${a.startDate ?? "?"} → ${a.endDate ?? "?"}`;
|
|
18
|
+
const status = a.cancelledFlag ? "cancelled" : (a.agreementStatus ?? "?");
|
|
19
|
+
return [
|
|
20
|
+
`#${a.id} ${a.name ?? "(unnamed)"} — ${a.company?.name ?? "?"}`,
|
|
21
|
+
` ${a.type?.name ?? "?"} | ${status} | ${period} | ${money(a.billAmount)}`,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
export function registerFinanceTools(reg, client) {
|
|
25
|
+
reg.register({
|
|
26
|
+
name: "cw_list_invoices",
|
|
27
|
+
title: "List ConnectWise Invoices",
|
|
28
|
+
description: "List invoices, optionally filtered by company, status, or invoice date range (YYYY-MM-DD). Read-only.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
company_id: z.number().int().positive().optional().describe("Filter by company ID"),
|
|
31
|
+
company_name: z.string().optional().describe("Filter by exact company name"),
|
|
32
|
+
status: z.string().optional().describe('Invoice status name (e.g. "Open", "Paid")'),
|
|
33
|
+
on_or_after: z.string().optional().describe("Invoice date on/after (YYYY-MM-DD)"),
|
|
34
|
+
on_or_before: z.string().optional().describe("Invoice date on/before (YYYY-MM-DD)"),
|
|
35
|
+
page_number: pageNumberField,
|
|
36
|
+
page_size: pageSizeField,
|
|
37
|
+
response_format: responseFormatField,
|
|
38
|
+
},
|
|
39
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
40
|
+
}, async (args) => {
|
|
41
|
+
try {
|
|
42
|
+
const conditions = allOf(args.company_id !== undefined && `company/id=${args.company_id}`, args.company_name && `company/name=${q(args.company_name)}`, args.status && `status/name=${q(args.status)}`, args.on_or_after && `date>=[${args.on_or_after}]`, args.on_or_before && `date<=[${args.on_or_before}]`);
|
|
43
|
+
const page = await client.getList("/finance/invoices", {
|
|
44
|
+
conditions,
|
|
45
|
+
orderBy: "date desc",
|
|
46
|
+
fields: INVOICE_FIELDS,
|
|
47
|
+
page: args.page_number,
|
|
48
|
+
pageSize: args.page_size,
|
|
49
|
+
});
|
|
50
|
+
if (page.items.length === 0)
|
|
51
|
+
return text("No invoices found.");
|
|
52
|
+
if (args.response_format === "json")
|
|
53
|
+
return text(clip(json(page)));
|
|
54
|
+
const lines = [`# Invoices (${page.items.length} on this page)`, ""];
|
|
55
|
+
for (const inv of page.items)
|
|
56
|
+
lines.push(invoiceLine(inv), "");
|
|
57
|
+
lines.push(pageFooter(page.page, page.hasMore));
|
|
58
|
+
return text(clip(lines.join("\n")));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return failure(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
reg.register({
|
|
65
|
+
name: "cw_get_invoice",
|
|
66
|
+
title: "Get ConnectWise Invoice",
|
|
67
|
+
description: "Get one invoice by ID. Read-only.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
invoice_id: z.number().int().positive().describe("The invoice ID"),
|
|
70
|
+
response_format: responseFormatField,
|
|
71
|
+
},
|
|
72
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
73
|
+
}, async (args) => {
|
|
74
|
+
try {
|
|
75
|
+
const inv = await client.getOne(`/finance/invoices/${args.invoice_id}`, INVOICE_FIELDS);
|
|
76
|
+
if (args.response_format === "json")
|
|
77
|
+
return text(clip(json(inv)));
|
|
78
|
+
return text([
|
|
79
|
+
`# Invoice ${inv.invoiceNumber ?? inv.id}`,
|
|
80
|
+
"",
|
|
81
|
+
`- **Company**: ${inv.company?.name ?? "?"} (ID: ${inv.company?.id ?? "?"})`,
|
|
82
|
+
`- **Status**: ${inv.status?.name ?? "?"} | **Type**: ${inv.type ?? "—"}`,
|
|
83
|
+
`- **Date**: ${inv.date ?? "?"}${inv.dueDate ? ` | **Due**: ${inv.dueDate}` : ""}`,
|
|
84
|
+
`- **Total**: ${money(inv.total)} | **Balance**: ${money(inv.balance)}`,
|
|
85
|
+
].join("\n"));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return failure(error);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
reg.register({
|
|
92
|
+
name: "cw_list_agreements",
|
|
93
|
+
title: "List ConnectWise Agreements",
|
|
94
|
+
description: "List service agreements (contracts), optionally filtered by company or status. Read-only.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
company_id: z.number().int().positive().optional().describe("Filter by company ID"),
|
|
97
|
+
company_name: z.string().optional().describe("Filter by exact company name"),
|
|
98
|
+
status: z.string().optional().describe("Agreement status name"),
|
|
99
|
+
page_number: pageNumberField,
|
|
100
|
+
page_size: pageSizeField,
|
|
101
|
+
response_format: responseFormatField,
|
|
102
|
+
},
|
|
103
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
104
|
+
}, async (args) => {
|
|
105
|
+
try {
|
|
106
|
+
const conditions = allOf(args.company_id !== undefined && `company/id=${args.company_id}`, args.company_name && `company/name=${q(args.company_name)}`, args.status && `agreementStatus=${q(args.status)}`);
|
|
107
|
+
const page = await client.getList("/finance/agreements", {
|
|
108
|
+
conditions,
|
|
109
|
+
orderBy: "name asc",
|
|
110
|
+
fields: AGREEMENT_FIELDS,
|
|
111
|
+
page: args.page_number,
|
|
112
|
+
pageSize: args.page_size,
|
|
113
|
+
});
|
|
114
|
+
if (page.items.length === 0)
|
|
115
|
+
return text("No agreements found.");
|
|
116
|
+
if (args.response_format === "json")
|
|
117
|
+
return text(clip(json(page)));
|
|
118
|
+
const lines = [`# Agreements (${page.items.length} on this page)`, ""];
|
|
119
|
+
for (const a of page.items)
|
|
120
|
+
lines.push(agreementLine(a), "");
|
|
121
|
+
lines.push(pageFooter(page.page, page.hasMore));
|
|
122
|
+
return text(clip(lines.join("\n")));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return failure(error);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
reg.register({
|
|
129
|
+
name: "cw_get_agreement",
|
|
130
|
+
title: "Get ConnectWise Agreement",
|
|
131
|
+
description: "Get one agreement (contract) with its coverage limits and billing cycle. Read-only.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
agreement_id: z.number().int().positive().describe("The agreement ID (from cw_list_agreements)"),
|
|
134
|
+
response_format: responseFormatField,
|
|
135
|
+
},
|
|
136
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
137
|
+
}, async (args) => {
|
|
138
|
+
try {
|
|
139
|
+
const a = await client.getOne(`/finance/agreements/${args.agreement_id}`, "id,name,type/name,company/name,company/id,agreementStatus,startDate,endDate,noEndingDateFlag,cancelledFlag,billAmount,billingCycle/name,applicationUnits,applicationLimit,applicationCycle,applicationUnlimitedFlag");
|
|
140
|
+
if (args.response_format === "json")
|
|
141
|
+
return text(clip(json(a)));
|
|
142
|
+
const period = a.noEndingDateFlag ? `${a.startDate ?? "?"} → (ongoing)` : `${a.startDate ?? "?"} → ${a.endDate ?? "?"}`;
|
|
143
|
+
const limit = a.applicationUnlimitedFlag
|
|
144
|
+
? "unlimited"
|
|
145
|
+
: a.applicationLimit != null
|
|
146
|
+
? `${a.applicationLimit} ${a.applicationUnits ?? ""}${a.applicationCycle ? ` / ${a.applicationCycle}` : ""}`
|
|
147
|
+
: "—";
|
|
148
|
+
return text([
|
|
149
|
+
`# ${a.name ?? `Agreement #${a.id}`}`,
|
|
150
|
+
"",
|
|
151
|
+
`- **Company**: ${a.company?.name ?? "?"}`,
|
|
152
|
+
`- **Type**: ${a.type?.name ?? "—"} | **Status**: ${a.cancelledFlag ? "cancelled" : (a.agreementStatus ?? "?")}`,
|
|
153
|
+
`- **Period**: ${period}`,
|
|
154
|
+
`- **Bill amount**: ${a.billAmount != null ? `$${a.billAmount.toFixed(2)}` : "—"}${a.billingCycle?.name ? ` / ${a.billingCycle.name}` : ""}`,
|
|
155
|
+
`- **Coverage**: ${limit}`,
|
|
156
|
+
].join("\n"));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return failure(error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
reg.register({
|
|
163
|
+
name: "cw_list_unbilled_time",
|
|
164
|
+
title: "List Unbilled Time",
|
|
165
|
+
description: "List billable time entries not yet on an invoice — what's ready to bill. Filter by company " +
|
|
166
|
+
"or date range (YYYY-MM-DD). Read-only.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
company_id: z.number().int().positive().optional().describe("Filter by company ID"),
|
|
169
|
+
company_name: z.string().optional().describe("Filter by exact company name"),
|
|
170
|
+
on_or_after: z.string().optional().describe("Time entries on/after this date (YYYY-MM-DD)"),
|
|
171
|
+
on_or_before: z.string().optional().describe("Time entries on/before this date (YYYY-MM-DD)"),
|
|
172
|
+
page_number: pageNumberField,
|
|
173
|
+
page_size: pageSizeField,
|
|
174
|
+
response_format: responseFormatField,
|
|
175
|
+
},
|
|
176
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
177
|
+
}, async (args) => {
|
|
178
|
+
try {
|
|
179
|
+
const conditions = allOf('billableOption="Billable"', "invoiceFlag=false", args.company_id !== undefined && `company/id=${args.company_id}`, args.company_name && `company/name=${q(args.company_name)}`, args.on_or_after && `timeStart>=[${args.on_or_after}]`, args.on_or_before && `timeStart<=[${args.on_or_before}]`);
|
|
180
|
+
const page = await client.getList("/time/entries", {
|
|
181
|
+
conditions,
|
|
182
|
+
orderBy: "timeStart desc",
|
|
183
|
+
fields: "id,member/identifier,company/name,chargeToId,timeStart,actualHours,workType/name,notes",
|
|
184
|
+
page: args.page_number,
|
|
185
|
+
pageSize: args.page_size,
|
|
186
|
+
});
|
|
187
|
+
if (page.items.length === 0)
|
|
188
|
+
return text("No unbilled billable time found.");
|
|
189
|
+
if (args.response_format === "json")
|
|
190
|
+
return text(clip(json(page)));
|
|
191
|
+
const total = page.items.reduce((s, e) => s + (e.actualHours ?? 0), 0);
|
|
192
|
+
const lines = [`# Unbilled billable time (${page.items.length} entries, ${total.toFixed(2)}h shown)`, ""];
|
|
193
|
+
for (const e of page.items)
|
|
194
|
+
lines.push(`- ${e.company?.name ?? "?"} | ticket #${e.chargeToId ?? "?"} | ${e.member?.identifier ?? "?"} | ${e.actualHours ?? 0}h | ${e.timeStart?.slice(0, 10) ?? "?"}`);
|
|
195
|
+
lines.push("", pageFooter(page.page, page.hasMore));
|
|
196
|
+
return text(clip(lines.join("\n")));
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return failure(error);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Every tool is registered on the session's McpServer. There is no MCP-level
|
|
5
|
+
* role gating: each session authenticates with a ConnectWise API member key
|
|
6
|
+
* (its own, via BYOK, or the server-wide keys on stdio), and ConnectWise
|
|
7
|
+
* enforces that member's security role server-side. The MCP server exposes the
|
|
8
|
+
* full tool surface and lets the CW API be the access control.
|
|
9
|
+
*/
|
|
10
|
+
/** Registers tools on an McpServer. */
|
|
11
|
+
export class ToolRegistrar {
|
|
12
|
+
server;
|
|
13
|
+
constructor(server) {
|
|
14
|
+
this.server = server;
|
|
15
|
+
}
|
|
16
|
+
register(spec, handler) {
|
|
17
|
+
this.server.registerTool(spec.name, {
|
|
18
|
+
title: spec.title,
|
|
19
|
+
description: spec.description,
|
|
20
|
+
inputSchema: spec.inputSchema,
|
|
21
|
+
annotations: spec.annotations,
|
|
22
|
+
}, handler);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -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
|
+
}
|