mcp-connectwise-psa 0.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.
@@ -0,0 +1,234 @@
1
+ /** Service ticket tools: search, my tickets, get, create, update, notes. */
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 TICKET_LIST_FIELDS = "id,summary,status/name,company/name,board/name,priority/name,owner/identifier,resources,dateEntered,_info/lastUpdated";
6
+ const TICKET_DETAIL_FIELDS = "id,summary,recordType,board/name,status/name,priority/name,company/name,company/id,contact/name,owner/identifier,resources,closedFlag,dateEntered";
7
+ const NOTE_FIELDS = "id,text,detailDescriptionFlag,internalAnalysisFlag,resolutionFlag,member/name,contact/name,createdBy,dateCreated";
8
+ function ticketLine(t) {
9
+ const bits = [
10
+ `#${t.id} ${t.summary ?? "(no summary)"}`,
11
+ ` ${t.company?.name ?? "?"} | ${t.board?.name ?? "?"} | ${t.status?.name ?? "?"} | priority: ${t.priority?.name ?? "—"}`,
12
+ ];
13
+ const assigned = t.resources || t.owner?.identifier;
14
+ if (assigned)
15
+ bits.push(` assigned: ${assigned}`);
16
+ return bits.join("\n");
17
+ }
18
+ function ticketList(items, page, hasMore) {
19
+ const lines = [`# Tickets (${items.length} on this page)`, ""];
20
+ for (const t of items)
21
+ lines.push(ticketLine(t), "");
22
+ lines.push(pageFooter(page, hasMore));
23
+ return lines.join("\n");
24
+ }
25
+ function noteBlock(n) {
26
+ const kind = n.internalAnalysisFlag ? "internal" : n.resolutionFlag ? "resolution" : "discussion";
27
+ const author = n.member?.name ?? n.contact?.name ?? n.createdBy ?? "?";
28
+ return [`— ${author} (${kind}) ${n.dateCreated ?? ""}`, n.text ?? "", ""].join("\n");
29
+ }
30
+ export function registerTicketTools(reg, client) {
31
+ reg.register({
32
+ name: "cw_search_tickets",
33
+ title: "Search ConnectWise Tickets",
34
+ description: "Search service tickets by company, board, status, summary text, or assigned member. " +
35
+ "Defaults to open tickets only. Returns ticket ids for use with cw_get_ticket / cw_add_ticket_note / cw_create_time_entry.",
36
+ inputSchema: {
37
+ company_id: z.number().int().positive().optional().describe("Filter by company ID"),
38
+ company_name: z.string().optional().describe("Filter by exact company name"),
39
+ board: z.string().optional().describe('Service board name (e.g. "Help Desk")'),
40
+ status: z.string().optional().describe('Status name (e.g. "New", "In Progress")'),
41
+ summary_contains: z.string().optional().describe("Text the ticket summary contains"),
42
+ assigned_to: z.string().optional().describe("Member identifier assigned to the ticket"),
43
+ open_only: z.boolean().default(true).describe("Only tickets that are not closed (default true)"),
44
+ page_number: pageNumberField,
45
+ page_size: pageSizeField,
46
+ response_format: responseFormatField,
47
+ },
48
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
49
+ }, async (args) => {
50
+ try {
51
+ const conditions = allOf(args.open_only && "closedFlag=false", args.company_id !== undefined && `company/id=${args.company_id}`, args.company_name && `company/name=${q(args.company_name)}`, args.board && `board/name=${q(args.board)}`, args.status && `status/name=${q(args.status)}`, args.summary_contains && `summary contains ${q(args.summary_contains)}`, args.assigned_to &&
52
+ `(resources contains ${q(args.assigned_to)} OR owner/identifier=${q(args.assigned_to)})`);
53
+ const page = await client.getList("/service/tickets", {
54
+ conditions,
55
+ orderBy: "id desc",
56
+ fields: TICKET_LIST_FIELDS,
57
+ page: args.page_number,
58
+ pageSize: args.page_size,
59
+ });
60
+ if (page.items.length === 0)
61
+ return text("No tickets found.");
62
+ if (args.response_format === "json")
63
+ return text(clip(json(page)));
64
+ return text(clip(ticketList(page.items, page.page, page.hasMore)));
65
+ }
66
+ catch (error) {
67
+ return failure(error);
68
+ }
69
+ });
70
+ reg.register({
71
+ name: "cw_my_tickets",
72
+ title: "My ConnectWise Tickets",
73
+ description: "List open tickets assigned to the member this session acts as (owner or listed in resources).",
74
+ inputSchema: {
75
+ page_number: pageNumberField,
76
+ page_size: pageSizeField,
77
+ response_format: responseFormatField,
78
+ },
79
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
80
+ }, async (args) => {
81
+ try {
82
+ const memberId = await client.me();
83
+ if (!memberId)
84
+ return { ...text(UNKNOWN_MEMBER_MESSAGE), isError: true };
85
+ const page = await client.getList("/service/tickets", {
86
+ conditions: allOf("closedFlag=false", `(resources contains ${q(memberId)} OR owner/identifier=${q(memberId)})`),
87
+ orderBy: "id desc",
88
+ fields: TICKET_LIST_FIELDS,
89
+ page: args.page_number,
90
+ pageSize: args.page_size,
91
+ });
92
+ if (page.items.length === 0)
93
+ return text(`No open tickets assigned to ${memberId}.`);
94
+ if (args.response_format === "json")
95
+ return text(clip(json(page)));
96
+ return text(clip(`Acting as **${memberId}**\n\n` + ticketList(page.items, page.page, page.hasMore)));
97
+ }
98
+ catch (error) {
99
+ return failure(error);
100
+ }
101
+ });
102
+ reg.register({
103
+ name: "cw_get_ticket",
104
+ title: "Get ConnectWise Ticket",
105
+ description: "Get one ticket with its most recent notes (newest first).",
106
+ inputSchema: {
107
+ ticket_id: z.number().int().positive().describe("The ticket ID"),
108
+ note_count: z.number().int().positive().max(50).default(10).describe("How many recent notes to include"),
109
+ response_format: responseFormatField,
110
+ },
111
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
112
+ }, async (args) => {
113
+ try {
114
+ const ticket = await client.getOne(`/service/tickets/${args.ticket_id}`, TICKET_DETAIL_FIELDS);
115
+ const notes = await client.getList(`/service/tickets/${args.ticket_id}/notes`, {
116
+ orderBy: "dateCreated desc",
117
+ fields: NOTE_FIELDS,
118
+ pageSize: args.note_count,
119
+ });
120
+ if (args.response_format === "json") {
121
+ return text(clip(json({ ...ticket, notes: notes.items })));
122
+ }
123
+ const lines = [
124
+ `# Ticket #${ticket.id}: ${ticket.summary ?? ""}`,
125
+ "",
126
+ `- **Company**: ${ticket.company?.name ?? "?"} (ID: ${ticket.company?.id ?? "?"})`,
127
+ `- **Board**: ${ticket.board?.name ?? "?"} | **Status**: ${ticket.status?.name ?? "?"} | **Priority**: ${ticket.priority?.name ?? "—"}`,
128
+ `- **Contact**: ${ticket.contact?.name ?? "—"}`,
129
+ `- **Assigned**: ${ticket.resources || ticket.owner?.identifier || "—"}`,
130
+ `- **Opened**: ${ticket.dateEntered ?? "?"} | **Closed**: ${ticket.closedFlag ? "yes" : "no"}`,
131
+ "",
132
+ `## Recent notes (${notes.items.length})`,
133
+ "",
134
+ ];
135
+ for (const n of notes.items)
136
+ lines.push(noteBlock(n));
137
+ return text(clip(lines.join("\n"), "Request fewer notes or json format."));
138
+ }
139
+ catch (error) {
140
+ return failure(error);
141
+ }
142
+ });
143
+ reg.register({
144
+ name: "cw_create_ticket",
145
+ title: "Create ConnectWise Ticket",
146
+ description: "Create a new service ticket on a board for a company.",
147
+ inputSchema: {
148
+ company_id: z.number().int().positive().describe("Company ID (find with cw_search_companies)"),
149
+ board: z.string().describe('Service board name (e.g. "Help Desk")'),
150
+ summary: z.string().min(1).max(100).describe("Ticket summary (max 100 chars)"),
151
+ initial_description: z.string().optional().describe("Initial description note"),
152
+ priority: z.string().optional().describe("Priority name (board default when omitted)"),
153
+ response_format: responseFormatField,
154
+ },
155
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
156
+ }, async (args) => {
157
+ try {
158
+ const ticket = await client.post("/service/tickets", {
159
+ summary: args.summary,
160
+ company: { id: args.company_id },
161
+ board: { name: args.board },
162
+ ...(args.initial_description ? { initialDescription: args.initial_description } : {}),
163
+ ...(args.priority ? { priority: { name: args.priority } } : {}),
164
+ });
165
+ if (args.response_format === "json")
166
+ return text(json(ticket));
167
+ return text(`Ticket created: #${ticket.id} "${ticket.summary}" on ${ticket.board?.name ?? args.board} (status: ${ticket.status?.name ?? "?"}).`);
168
+ }
169
+ catch (error) {
170
+ return failure(error);
171
+ }
172
+ });
173
+ reg.register({
174
+ name: "cw_update_ticket",
175
+ title: "Update ConnectWise Ticket",
176
+ description: "Update a ticket's status, priority, summary, or owner. Only provided fields change.",
177
+ inputSchema: {
178
+ ticket_id: z.number().int().positive().describe("The ticket ID to update"),
179
+ status: z.string().optional().describe('New status name (must exist on the ticket\'s board)'),
180
+ priority: z.string().optional().describe("New priority name"),
181
+ summary: z.string().max(100).optional().describe("New summary"),
182
+ owner_identifier: z.string().optional().describe("Member identifier to set as owner"),
183
+ response_format: responseFormatField,
184
+ },
185
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
186
+ }, async (args) => {
187
+ try {
188
+ const ops = [];
189
+ if (args.status)
190
+ ops.push({ op: "replace", path: "status", value: { name: args.status } });
191
+ if (args.priority)
192
+ ops.push({ op: "replace", path: "priority", value: { name: args.priority } });
193
+ if (args.summary)
194
+ ops.push({ op: "replace", path: "summary", value: args.summary });
195
+ if (args.owner_identifier)
196
+ ops.push({ op: "replace", path: "owner", value: { identifier: args.owner_identifier } });
197
+ if (ops.length === 0)
198
+ return text("Nothing to update — provide at least one field.");
199
+ const ticket = await client.patch(`/service/tickets/${args.ticket_id}`, ops);
200
+ if (args.response_format === "json")
201
+ return text(json(ticket));
202
+ return text(`Ticket #${ticket.id} updated — status: ${ticket.status?.name ?? "?"}, priority: ${ticket.priority?.name ?? "—"}, owner: ${ticket.owner?.identifier ?? "—"}.`);
203
+ }
204
+ catch (error) {
205
+ return failure(error);
206
+ }
207
+ });
208
+ reg.register({
209
+ name: "cw_add_ticket_note",
210
+ title: "Add ConnectWise Ticket Note",
211
+ description: "Add a note to a ticket. Discussion notes are visible to the customer on portals/emails; " +
212
+ "internal notes are not. Notes are attributed to the member whose API keys this session uses.",
213
+ inputSchema: {
214
+ ticket_id: z.number().int().positive().describe("The ticket ID"),
215
+ note: z.string().min(1).describe("Note text (plain text)"),
216
+ internal: z.boolean().default(false).describe("Internal analysis note instead of discussion (default false)"),
217
+ resolution: z.boolean().default(false).describe("Mark as resolution note (default false)"),
218
+ },
219
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
220
+ }, async (args) => {
221
+ try {
222
+ const created = await client.post(`/service/tickets/${args.ticket_id}/notes`, {
223
+ text: args.note,
224
+ detailDescriptionFlag: !args.internal,
225
+ internalAnalysisFlag: args.internal,
226
+ resolutionFlag: args.resolution,
227
+ });
228
+ return text(`Note ${created.id} added to ticket #${args.ticket_id} (${args.internal ? "internal" : "discussion"}${args.resolution ? ", resolution" : ""}).`);
229
+ }
230
+ catch (error) {
231
+ return failure(error);
232
+ }
233
+ });
234
+ }
@@ -0,0 +1,101 @@
1
+ /** Time entry tools: log time against tickets, list my time. */
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 TIME_FIELDS = "id,chargeToId,chargeToType,member/identifier,member/name,company/name,timeStart,timeEnd,actualHours,billableOption,notes";
6
+ /** ConnectWise rejects fractional seconds — format as YYYY-MM-DDTHH:mm:ssZ. */
7
+ const cwTimestamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, "Z");
8
+ export function registerTimeTools(reg, client) {
9
+ reg.register({
10
+ name: "cw_create_time_entry",
11
+ title: "Log ConnectWise Time Entry",
12
+ description: "Log time against a service ticket. Time is attributed to the member whose API keys this session uses. " +
13
+ "Provide time_start plus either time_end or hours.",
14
+ inputSchema: {
15
+ ticket_id: z.number().int().positive().describe("Ticket to charge the time to"),
16
+ time_start: z
17
+ .string()
18
+ .describe('Start time, ISO 8601 (e.g. "2026-07-04T14:00:00Z")'),
19
+ time_end: z.string().optional().describe("End time, ISO 8601 (or use hours)"),
20
+ hours: z.number().positive().max(24).optional().describe("Duration in hours (alternative to time_end)"),
21
+ notes: z.string().min(1).describe("Work performed"),
22
+ billable: z.boolean().default(true).describe("Billable (default true)"),
23
+ add_to_detail: z
24
+ .boolean()
25
+ .default(false)
26
+ .describe("Also show the notes on the ticket as a discussion note (default false)"),
27
+ },
28
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
29
+ }, async (args) => {
30
+ try {
31
+ const start = new Date(args.time_start);
32
+ if (Number.isNaN(start.getTime()))
33
+ return { ...text("Error: time_start is not a valid ISO timestamp."), isError: true };
34
+ let end;
35
+ if (args.time_end) {
36
+ end = new Date(args.time_end);
37
+ }
38
+ else if (args.hours !== undefined) {
39
+ end = new Date(start.getTime() + args.hours * 3_600_000);
40
+ }
41
+ if (!end || Number.isNaN(end.getTime()) || end <= start) {
42
+ return { ...text("Error: provide time_end after time_start, or a positive hours value."), isError: true };
43
+ }
44
+ const entry = await client.post("/time/entries", {
45
+ chargeToId: args.ticket_id,
46
+ chargeToType: "ServiceTicket",
47
+ timeStart: cwTimestamp(start),
48
+ timeEnd: cwTimestamp(end),
49
+ notes: args.notes,
50
+ billableOption: args.billable ? "Billable" : "DoNotBill",
51
+ addToDetailDescriptionFlag: args.add_to_detail,
52
+ });
53
+ const hours = entry.actualHours ?? ((end.getTime() - start.getTime()) / 3_600_000).toFixed(2);
54
+ return text(`Time entry ${entry.id} logged: ${hours}h on ticket #${args.ticket_id} by ${entry.member?.name ?? entry.member?.identifier ?? "(session member)"} (${entry.billableOption ?? (args.billable ? "Billable" : "DoNotBill")}).`);
55
+ }
56
+ catch (error) {
57
+ return failure(error);
58
+ }
59
+ });
60
+ reg.register({
61
+ name: "cw_list_my_time",
62
+ title: "My ConnectWise Time Entries",
63
+ description: "List time entries for the member this session acts as, newest first, optionally within a date range.",
64
+ inputSchema: {
65
+ date_from: z.string().optional().describe('Only entries starting on/after this date (e.g. "2026-07-01")'),
66
+ date_to: z.string().optional().describe("Only entries starting before this date"),
67
+ page_number: pageNumberField,
68
+ page_size: pageSizeField,
69
+ response_format: responseFormatField,
70
+ },
71
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
72
+ }, async (args) => {
73
+ try {
74
+ const memberId = await client.me();
75
+ if (!memberId)
76
+ return { ...text(UNKNOWN_MEMBER_MESSAGE), isError: true };
77
+ const page = await client.getList("/time/entries", {
78
+ conditions: allOf(`member/identifier=${q(memberId)}`, args.date_from && `timeStart >= [${args.date_from}]`, args.date_to && `timeStart < [${args.date_to}]`),
79
+ orderBy: "timeStart desc",
80
+ fields: TIME_FIELDS,
81
+ page: args.page_number,
82
+ pageSize: args.page_size,
83
+ });
84
+ if (page.items.length === 0)
85
+ return text(`No time entries found for ${memberId}.`);
86
+ if (args.response_format === "json")
87
+ return text(clip(json(page)));
88
+ let total = 0;
89
+ const lines = [`# Time entries for ${memberId}`, ""];
90
+ for (const entry of page.items) {
91
+ total += entry.actualHours ?? 0;
92
+ lines.push(`- ${entry.timeStart ?? "?"} — ${entry.actualHours ?? "?"}h — ${entry.chargeToType ?? ""} ${entry.chargeToId ?? ""} (${entry.company?.name ?? "?"}) ${entry.billableOption ?? ""}`, ` ${(entry.notes ?? "").split("\n")[0] ?? ""}`);
93
+ }
94
+ lines.push("", `**Total on this page:** ${total.toFixed(2)}h`, pageFooter(page.page, page.hasMore));
95
+ return text(clip(lines.join("\n")));
96
+ }
97
+ catch (error) {
98
+ return failure(error);
99
+ }
100
+ });
101
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "mcp-connectwise-psa",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for ConnectWise PSA (Manage) — tickets, time entries, companies, and configurations for technicians, with role-based access control and per-tech API keys",
5
+ "mcpName": "io.github.selic/mcp-connectwise-psa",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/selic/mcp-connectwise-psa.git"
10
+ },
11
+ "homepage": "https://github.com/selic/mcp-connectwise-psa#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/selic/mcp-connectwise-psa/issues"
14
+ },
15
+ "main": "dist/index.js",
16
+ "bin": {
17
+ "mcp-connectwise-psa": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "prepublishOnly": "npm run build",
27
+ "bundle": "npm run build && node scripts/bundle.mjs",
28
+ "start": "node dist/index.js",
29
+ "start:http": "node dist/index.js --transport http",
30
+ "dev": "tsx src/index.ts",
31
+ "dev:http": "tsx src/index.ts --transport http",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest"
34
+ },
35
+ "keywords": [
36
+ "mcp",
37
+ "mcp-server",
38
+ "model-context-protocol",
39
+ "connectwise",
40
+ "connectwise-manage",
41
+ "psa",
42
+ "msp",
43
+ "tickets",
44
+ "rbac"
45
+ ],
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "license": "MIT",
50
+ "author": "Eugene Samotija (selic) <eselic@gmail.com> (https://defency.net)",
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "express": "^5.2.1",
54
+ "zod": "^4.4.3"
55
+ },
56
+ "devDependencies": {
57
+ "@types/express": "^5.0.6",
58
+ "@types/node": "^26.1.0",
59
+ "tsx": "^4.23.0",
60
+ "typescript": "^6.0.3",
61
+ "vitest": "^4.1.9"
62
+ }
63
+ }