mcp-connectwise-psa 0.2.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 +20 -12
- 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 +89 -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
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ CW_SITE=na.myconnectwise.net \
|
|
|
17
17
|
CW_COMPANY_ID=yourcompany \
|
|
18
18
|
CW_CLIENT_ID=<integration clientId> \
|
|
19
19
|
CW_PUBLIC_KEY=xxxx CW_PRIVATE_KEY=yyyy \
|
|
20
|
-
CW_MEMBER_IDENTIFIER=
|
|
20
|
+
CW_MEMBER_IDENTIFIER=jdoe \
|
|
21
21
|
node dist/index.js
|
|
22
22
|
```
|
|
23
23
|
|
|
@@ -35,7 +35,7 @@ Claude Desktop / Claude Code config:
|
|
|
35
35
|
"CW_CLIENT_ID": "<clientId>",
|
|
36
36
|
"CW_PUBLIC_KEY": "xxxx",
|
|
37
37
|
"CW_PRIVATE_KEY": "yyyy",
|
|
38
|
-
"CW_MEMBER_IDENTIFIER": "
|
|
38
|
+
"CW_MEMBER_IDENTIFIER": "jdoe"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -78,20 +78,27 @@ x-cw-member-id: <your member identifier> (optional — enables "my tickets"/
|
|
|
78
78
|
|
|
79
79
|
Local **stdio** is single-user and uses the `CW_PUBLIC_KEY`/`CW_PRIVATE_KEY` from the environment instead of headers.
|
|
80
80
|
|
|
81
|
-
##
|
|
81
|
+
## Toolsets
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Tools are grouped into **toolsets** so a session only sees the capabilities it needs — a dispatcher doesn't need the invoicing tools, and a small tool surface keeps the assistant focused (and its context cheap). Whether a write actually succeeds is still governed by the member's ConnectWise security role.
|
|
84
84
|
|
|
85
|
-
|
|
|
85
|
+
| Toolset key | Tools |
|
|
86
86
|
|---|---|
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `cw_search_companies`, `cw_get_company`, `cw_search_contacts`
|
|
90
|
-
| `cw_list_configurations`, `cw_get_configuration` |
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
87
|
+
| `tickets` | `cw_search_tickets`, `cw_my_tickets`, `cw_get_ticket`, `cw_create_ticket`, `cw_update_ticket`, `cw_add_ticket_note`, `cw_list_boards`, `cw_get_board`, `cw_list_priorities`, `cw_list_ticket_time`, `cw_list_ticket_tasks` |
|
|
88
|
+
| `time` | `cw_create_time_entry`, `cw_list_my_time`, `cw_list_work_roles`, `cw_list_my_timesheets`, `cw_submit_timesheet` |
|
|
89
|
+
| `companies` | `cw_search_companies`, `cw_get_company`, `cw_search_contacts`, `cw_get_contact`, `cw_list_company_sites` |
|
|
90
|
+
| `configurations` | `cw_list_configurations`, `cw_get_configuration` |
|
|
91
|
+
| `schedule` | `cw_list_schedule_entries`, `cw_my_schedule`, `cw_schedule_ticket`, `cw_update_schedule_entry`, `cw_delete_schedule_entry`, `cw_member_availability`, `cw_list_members`, `cw_get_member` |
|
|
92
|
+
| `finance` | `cw_list_invoices`, `cw_get_invoice`, `cw_list_agreements`, `cw_get_agreement`, `cw_list_unbilled_time` |
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
**Presets** bundle keys per persona: `tech` = tickets + time + companies + configurations · `dispatch` = tickets + schedule + companies + configurations · `invoicing` = finance + time + companies · `all` = everything.
|
|
95
|
+
|
|
96
|
+
Select toolsets with a comma list mixing keys and presets:
|
|
97
|
+
|
|
98
|
+
- **HTTP** — the `x-cw-toolsets` header, per session: `x-cw-toolsets: dispatch` or `x-cw-toolsets: tech,finance`.
|
|
99
|
+
- **stdio** — the `CW_TOOLSETS` env var or `--toolsets` flag: `CW_TOOLSETS=invoicing`.
|
|
100
|
+
|
|
101
|
+
The **default is the `tech` preset** — the same tools this server exposed before toolsets existed, so nothing changes for existing clients until they opt in. Unknown keys in `CW_TOOLSETS`/`--toolsets` fail fast; unknown tokens in the `x-cw-toolsets` header are ignored. The only destructive tool is `cw_delete_schedule_entry` (dispatch); finance is read-only.
|
|
95
102
|
|
|
96
103
|
## Configuration reference
|
|
97
104
|
|
|
@@ -103,6 +110,7 @@ No destructive (delete) tools in v1.
|
|
|
103
110
|
| `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — | API member keys — required for stdio; unused on HTTP (BYOK) |
|
|
104
111
|
| `CW_MEMBER_IDENTIFIER` | — | Member the stdio keys belong to (my-tickets/my-time) |
|
|
105
112
|
| `TRANSPORT` / `PORT` | `stdio` / `3000` | Transport selection |
|
|
113
|
+
| `CW_TOOLSETS` | `tech` | Enabled toolsets (keys/presets); HTTP overrides per session via `x-cw-toolsets` |
|
|
106
114
|
|
|
107
115
|
## Notes & limits
|
|
108
116
|
|
package/dist/config.js
CHANGED
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
* "my tickets"/"my time")
|
|
14
14
|
* TRANSPORT stdio | http (default: stdio)
|
|
15
15
|
* PORT HTTP port (default: 3000)
|
|
16
|
+
* CW_TOOLSETS Comma list of toolset keys/presets to expose (default:
|
|
17
|
+
* the "tech" preset). HTTP sessions may override per
|
|
18
|
+
* request via the x-cw-toolsets header.
|
|
16
19
|
*
|
|
17
20
|
* Access model: stdio uses the server-wide keys above (single local user). HTTP
|
|
18
21
|
* sessions each bring their own member API keys (BYOK) via x-cw-public-key /
|
|
19
22
|
* x-cw-private-key headers; ConnectWise enforces that member's security role.
|
|
20
23
|
* There is no MCP-level role gating.
|
|
21
24
|
*/
|
|
25
|
+
import { DEFAULT_TOOLSETS, resolveToolsets } from "./tools/toolsets.js";
|
|
22
26
|
export class ConfigError extends Error {
|
|
23
27
|
}
|
|
24
28
|
function flagValue(argv, name) {
|
|
@@ -69,6 +73,13 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
69
73
|
if (transport === "stdio" && !publicKey) {
|
|
70
74
|
throw new ConfigError("CW_PUBLIC_KEY / CW_PRIVATE_KEY are required for stdio transport");
|
|
71
75
|
}
|
|
76
|
+
let toolsets;
|
|
77
|
+
try {
|
|
78
|
+
toolsets = resolveToolsets(flagValue(argv, "--toolsets") ?? env.CW_TOOLSETS, DEFAULT_TOOLSETS, "throw");
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new ConfigError(err instanceof Error ? err.message : String(err));
|
|
82
|
+
}
|
|
72
83
|
return {
|
|
73
84
|
transport,
|
|
74
85
|
port,
|
|
@@ -78,5 +89,6 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
78
89
|
publicKey,
|
|
79
90
|
privateKey,
|
|
80
91
|
memberIdentifier: env.CW_MEMBER_IDENTIFIER || undefined,
|
|
92
|
+
toolsets,
|
|
81
93
|
};
|
|
82
94
|
}
|
package/dist/cw/client.js
CHANGED
|
@@ -132,6 +132,10 @@ export class CWClient {
|
|
|
132
132
|
async patch(path, operations) {
|
|
133
133
|
return this.request("PATCH", path, undefined, operations);
|
|
134
134
|
}
|
|
135
|
+
/** DELETE a resource (e.g. /schedule/entries/{id}). Returns void (CW replies 204/200). */
|
|
136
|
+
async del(path) {
|
|
137
|
+
await this.request("DELETE", path);
|
|
138
|
+
}
|
|
135
139
|
/**
|
|
136
140
|
* The member identifier this session acts as. Uses the explicitly provided
|
|
137
141
|
* identifier when available; otherwise tries /system/myAccount once (not
|
package/dist/http/app.js
CHANGED
|
@@ -19,6 +19,7 @@ import express from "express";
|
|
|
19
19
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
20
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
21
|
import { createServer, SERVER_NAME, SERVER_VERSION } from "../server.js";
|
|
22
|
+
import { resolveToolsets } from "../tools/toolsets.js";
|
|
22
23
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
23
24
|
function rpcError(res, status, code, message) {
|
|
24
25
|
res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id: null });
|
|
@@ -59,6 +60,14 @@ export function resolveAuth(req) {
|
|
|
59
60
|
function principalMatches(session, auth) {
|
|
60
61
|
return session.keyHash === auth.keyHash;
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Toolsets for a new session: the x-cw-toolsets header narrows/widens within the
|
|
65
|
+
* valid keys, falling back to the server default. Unknown tokens are ignored
|
|
66
|
+
* (warn) rather than failing the request. Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function sessionToolsets(req, config) {
|
|
69
|
+
return resolveToolsets(headerValue(req, "x-cw-toolsets"), config.toolsets, "warn");
|
|
70
|
+
}
|
|
62
71
|
export function createApp(config) {
|
|
63
72
|
const app = express();
|
|
64
73
|
app.use(express.json({ limit: "2mb" }));
|
|
@@ -103,6 +112,7 @@ export function createApp(config) {
|
|
|
103
112
|
const server = createServer(config, {
|
|
104
113
|
label: auth.label,
|
|
105
114
|
credentials: auth.credentials,
|
|
115
|
+
toolsets: sessionToolsets(req, config),
|
|
106
116
|
});
|
|
107
117
|
await server.connect(transport);
|
|
108
118
|
await transport.handleRequest(req, res, req.body);
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ Options:
|
|
|
12
12
|
--transport stdio|http Transport (default: stdio; env TRANSPORT)
|
|
13
13
|
--port <n> HTTP port (default: 3000; env PORT)
|
|
14
14
|
--site <host> ConnectWise host (env CW_SITE)
|
|
15
|
+
--toolsets <list> Enabled toolsets (env CW_TOOLSETS; default: tech)
|
|
15
16
|
--help Show this help
|
|
16
17
|
|
|
17
18
|
Environment:
|
|
@@ -21,10 +22,13 @@ Environment:
|
|
|
21
22
|
CW_PUBLIC_KEY API member public key (required for stdio)
|
|
22
23
|
CW_PRIVATE_KEY API member private key (required for stdio)
|
|
23
24
|
CW_MEMBER_IDENTIFIER Member the stdio keys belong to (my-tickets/my-time)
|
|
25
|
+
CW_TOOLSETS Comma list of toolset keys/presets (default: tech).
|
|
26
|
+
Keys: tickets, time, companies, configurations,
|
|
27
|
+
schedule, finance. Presets: tech, dispatch, invoicing, all.
|
|
24
28
|
|
|
25
29
|
HTTP sessions authenticate per-request with their own member keys via the
|
|
26
30
|
x-cw-public-key / x-cw-private-key headers (BYOK); the CW_* keys above are used
|
|
27
|
-
only by stdio.
|
|
31
|
+
only by stdio. HTTP clients pick toolsets per session with the x-cw-toolsets header.
|
|
28
32
|
`;
|
|
29
33
|
function logStartupSummary(config) {
|
|
30
34
|
console.error("[auth] HTTP: each session must present its own ConnectWise keys via " +
|
|
@@ -41,6 +45,7 @@ async function runStdio(config) {
|
|
|
41
45
|
privateKey: config.privateKey,
|
|
42
46
|
memberIdentifier: config.memberIdentifier,
|
|
43
47
|
},
|
|
48
|
+
toolsets: config.toolsets,
|
|
44
49
|
});
|
|
45
50
|
await server.connect(new StdioServerTransport());
|
|
46
51
|
console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio (${config.site})`);
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Builds an McpServer for one session. A session is defined by the ConnectWise
|
|
3
|
-
* credentials it uses (server-wide keys on stdio, or client-supplied via BYOK)
|
|
4
|
-
*
|
|
5
|
-
* role is the access control.
|
|
3
|
+
* credentials it uses (server-wide keys on stdio, or client-supplied via BYOK)
|
|
4
|
+
* and the toolsets it selected. Only the selected toolsets are registered; the
|
|
5
|
+
* member's ConnectWise security role is the access control.
|
|
6
6
|
*/
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
@@ -12,10 +12,21 @@ import { registerTicketTools } from "./tools/tickets.js";
|
|
|
12
12
|
import { registerTimeTools } from "./tools/time.js";
|
|
13
13
|
import { registerCompanyTools } from "./tools/companies.js";
|
|
14
14
|
import { registerConfigurationTools } from "./tools/configurations.js";
|
|
15
|
+
import { registerScheduleTools } from "./tools/schedule.js";
|
|
16
|
+
import { registerFinanceTools } from "./tools/finance.js";
|
|
15
17
|
const require = createRequire(import.meta.url);
|
|
16
18
|
const pkg = require("../package.json");
|
|
17
19
|
export const SERVER_NAME = pkg.name;
|
|
18
20
|
export const SERVER_VERSION = pkg.version;
|
|
21
|
+
/** Maps each toolset key to the function that registers its tools. */
|
|
22
|
+
const TOOLSETS = {
|
|
23
|
+
tickets: registerTicketTools,
|
|
24
|
+
time: registerTimeTools,
|
|
25
|
+
companies: registerCompanyTools,
|
|
26
|
+
configurations: registerConfigurationTools,
|
|
27
|
+
schedule: registerScheduleTools,
|
|
28
|
+
finance: registerFinanceTools,
|
|
29
|
+
};
|
|
19
30
|
const INSTRUCTIONS = `# ConnectWise PSA MCP server
|
|
20
31
|
|
|
21
32
|
## Finding things
|
|
@@ -32,7 +43,8 @@ const INSTRUCTIONS = `# ConnectWise PSA MCP server
|
|
|
32
43
|
|
|
33
44
|
## Notes
|
|
34
45
|
- Lists are paginated; ask for more pages rather than huge page sizes.
|
|
35
|
-
- A write may still fail if your ConnectWise security role forbids it
|
|
46
|
+
- A write may still fail if your ConnectWise security role forbids it.
|
|
47
|
+
- Only the tools for this session's enabled toolsets are listed; other capabilities may exist on the server.`;
|
|
36
48
|
export function createServer(config, session) {
|
|
37
49
|
const client = new CWClient({
|
|
38
50
|
site: config.site,
|
|
@@ -42,9 +54,8 @@ export function createServer(config, session) {
|
|
|
42
54
|
});
|
|
43
55
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: INSTRUCTIONS });
|
|
44
56
|
const reg = new ToolRegistrar(server);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
registerConfigurationTools(reg, client);
|
|
57
|
+
for (const key of session.toolsets) {
|
|
58
|
+
TOOLSETS[key](reg, client);
|
|
59
|
+
}
|
|
49
60
|
return server;
|
|
50
61
|
}
|
package/dist/tools/companies.js
CHANGED
|
@@ -106,4 +106,66 @@ export function registerCompanyTools(reg, client) {
|
|
|
106
106
|
return failure(error);
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
|
+
reg.register({
|
|
110
|
+
name: "cw_get_contact",
|
|
111
|
+
title: "Get ConnectWise Contact",
|
|
112
|
+
description: "Get one contact with their phone numbers and email addresses (communication items).",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
contact_id: z.number().int().positive().describe("The contact ID (from cw_search_contacts)"),
|
|
115
|
+
response_format: responseFormatField,
|
|
116
|
+
},
|
|
117
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
118
|
+
}, async (args) => {
|
|
119
|
+
try {
|
|
120
|
+
const c = await client.getOne(`/company/contacts/${args.contact_id}`, "id,firstName,lastName,title,company/name,company/id,inactiveFlag,communicationItems");
|
|
121
|
+
if (args.response_format === "json")
|
|
122
|
+
return text(clip(json(c)));
|
|
123
|
+
const lines = [
|
|
124
|
+
`# ${c.firstName ?? ""} ${c.lastName ?? ""}${c.inactiveFlag ? " (inactive)" : ""}`.trim(),
|
|
125
|
+
"",
|
|
126
|
+
`- **Company**: ${c.company?.name ?? "?"}`,
|
|
127
|
+
`- **Title**: ${c.title ?? "—"}`,
|
|
128
|
+
];
|
|
129
|
+
const comms = c.communicationItems ?? [];
|
|
130
|
+
if (comms.length) {
|
|
131
|
+
lines.push("", "## Contact methods");
|
|
132
|
+
for (const ci of comms)
|
|
133
|
+
lines.push(`- ${ci.type?.name ?? ci.communicationType ?? "?"}: ${ci.value ?? ""}${ci.defaultFlag ? " (default)" : ""}`);
|
|
134
|
+
}
|
|
135
|
+
return text(clip(lines.join("\n")));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return failure(error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
reg.register({
|
|
142
|
+
name: "cw_list_company_sites",
|
|
143
|
+
title: "List ConnectWise Company Sites",
|
|
144
|
+
description: "List a company's sites (locations/addresses), with phone and timezone.",
|
|
145
|
+
inputSchema: {
|
|
146
|
+
company_id: z.number().int().positive().describe("The company ID"),
|
|
147
|
+
response_format: responseFormatField,
|
|
148
|
+
},
|
|
149
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
150
|
+
}, async (args) => {
|
|
151
|
+
try {
|
|
152
|
+
const page = await client.getList(`/company/companies/${args.company_id}/sites`, {
|
|
153
|
+
fields: "id,name,addressLine1,city,stateReference/name,zip,phoneNumber,timeZone/name,inactiveFlag,primaryAddressFlag",
|
|
154
|
+
pageSize: 200,
|
|
155
|
+
});
|
|
156
|
+
if (page.items.length === 0)
|
|
157
|
+
return text(`No sites for company #${args.company_id}.`);
|
|
158
|
+
if (args.response_format === "json")
|
|
159
|
+
return text(clip(json(page.items)));
|
|
160
|
+
const lines = [`# Sites for company #${args.company_id} (${page.items.length})`, ""];
|
|
161
|
+
for (const s of page.items) {
|
|
162
|
+
const addr = [s.addressLine1, s.city, s.stateReference?.name, s.zip].filter(Boolean).join(", ");
|
|
163
|
+
lines.push(`- **${s.name ?? "?"}**${s.primaryAddressFlag ? " (primary)" : ""}${s.inactiveFlag ? " (inactive)" : ""}`, ` ${addr || "—"}${s.phoneNumber ? ` | ${s.phoneNumber}` : ""}${s.timeZone?.name ? ` | TZ: ${s.timeZone.name}` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
return text(clip(lines.join("\n")));
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return failure(error);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
109
171
|
}
|
|
@@ -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
|
+
}
|