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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Samotija
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # mcp-connectwise-psa
2
+
3
+ An MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server for [ConnectWise PSA](https://www.connectwise.com/platform/psa) (Manage), focused on what technicians do all day:
4
+
5
+ - **Tickets** — my tickets, search, full detail with notes, create, update status/priority/owner, add discussion/internal notes
6
+ - **Time entries** — log time against tickets, review your own time
7
+ - **Companies & contacts** — fast lookup (read-only)
8
+ - **Configurations** — devices/assets with serials, IPs, OS, warranty (read-only)
9
+ - **Role-based access control** — viewer / editor bearer tokens decide which tools a session sees
10
+ - **Per-tech API keys (BYOK)** — techs supply their own ConnectWise member keys, so notes and time entries are attributed to the *actual person* in ConnectWise
11
+ - **Transports** — stdio for local use, streamable HTTP for shared deployments; Docker image included
12
+
13
+ ## Quick start (local, stdio)
14
+
15
+ ```bash
16
+ npm install && npm run build
17
+ CW_SITE=na.myconnectwise.net \
18
+ CW_COMPANY_ID=yourcompany \
19
+ CW_CLIENT_ID=<integration clientId> \
20
+ CW_PUBLIC_KEY=xxxx CW_PRIVATE_KEY=yyyy \
21
+ CW_MEMBER_IDENTIFIER=JSmith \
22
+ node dist/index.js
23
+ ```
24
+
25
+ Claude Desktop / Claude Code config:
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "connectwise": {
31
+ "command": "node",
32
+ "args": ["/path/to/mcp-connectwise-psa/dist/index.js"],
33
+ "env": {
34
+ "CW_SITE": "na.myconnectwise.net",
35
+ "CW_COMPANY_ID": "yourcompany",
36
+ "CW_CLIENT_ID": "<clientId>",
37
+ "CW_PUBLIC_KEY": "xxxx",
38
+ "CW_PRIVATE_KEY": "yyyy",
39
+ "CW_MEMBER_IDENTIFIER": "JSmith"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ A `clientId` is required by the ConnectWise API — register a (free) integration at [developer.connectwise.com](https://developer.connectwise.com). API member keys are created in ConnectWise under **My Account → API Keys** (per member) or **System → Members → API Members** (integration accounts).
47
+
48
+ ## HTTP deployment
49
+
50
+ ```bash
51
+ CW_SITE=… CW_COMPANY_ID=… CW_CLIENT_ID=… \
52
+ MCP_TOKENS_VIEWER="alice:$(openssl rand -hex 32)" \
53
+ MCP_TOKENS_EDITOR="bot:$(openssl rand -hex 32)" \
54
+ node dist/index.js --transport http --port 3000
55
+ ```
56
+
57
+ Or with Docker: `docker build -t mcp-connectwise-psa . && docker run -p 3000:3000 -e CW_SITE -e CW_COMPANY_ID -e CW_CLIENT_ID -e MCP_TOKENS_VIEWER -e MCP_TOKENS_EDITOR mcp-connectwise-psa`
58
+
59
+ | Route | Purpose |
60
+ |---|---|
61
+ | `POST/GET/DELETE /mcp` | MCP streamable-http endpoint |
62
+ | `GET /health` | Liveness probe |
63
+
64
+ > Sessions are held in memory — run a single instance (or sticky sessions).
65
+
66
+ ## Access control
67
+
68
+ ### Role tokens
69
+
70
+ ```bash
71
+ MCP_TOKENS_VIEWER="alice:tokA,bob:tokB" # read-only tools
72
+ MCP_TOKENS_EDITOR="helpdesk-bot:tokC" # + create/update tickets, notes, time entries
73
+ MCP_TOKENS_ADMIN="ops:tokD" # reserved for future destructive tools
74
+ ```
75
+
76
+ Clients send `Authorization: Bearer <token>`. Tools outside the role are not even registered for the session, a runtime guard re-checks every call, and session ids never carry privilege (each request re-authenticates; principal mismatch → 403). Labels appear in the audit log and allow per-person revocation. With no tokens configured the server runs in dev mode (full access + loud warning).
77
+
78
+ ### Bring your own keys (BYOK) — recommended for techs
79
+
80
+ Send your own ConnectWise member API keys on the initialize request:
81
+
82
+ ```
83
+ x-cw-public-key: <public key>
84
+ x-cw-private-key: <private key>
85
+ x-cw-member-id: <your member identifier> (optional — enables "my tickets"/"my time")
86
+ ```
87
+
88
+ The session then acts as *you* in ConnectWise: your security role limits what succeeds, and every note and time entry is attributed to you. `CLIENT_CW_KEYS` controls the policy: `with-token` (default — a bearer token is still required), `open`, or `disabled`. Keys are never logged; sessions are bound to a SHA-256 hash of the pair.
89
+
90
+ Token-only sessions use the server-wide `CW_PUBLIC_KEY`/`CW_PRIVATE_KEY` (use an integration API member with a conservative security role — its writes are attributed to that integration member).
91
+
92
+ ## Tools
93
+
94
+ | Tool | Tier |
95
+ |---|---|
96
+ | `cw_my_tickets`, `cw_search_tickets`, `cw_get_ticket` | read |
97
+ | `cw_list_my_time` | read |
98
+ | `cw_search_companies`, `cw_get_company`, `cw_search_contacts` | read |
99
+ | `cw_list_configurations`, `cw_get_configuration` | read |
100
+ | `cw_create_ticket`, `cw_update_ticket`, `cw_add_ticket_note` | write |
101
+ | `cw_create_time_entry` | write |
102
+
103
+ Viewer = read. Editor/Admin = read + write. No destructive (delete) tools in v1.
104
+
105
+ ## Configuration reference
106
+
107
+ | Variable | Default | Purpose |
108
+ |---|---|---|
109
+ | `CW_SITE` | — | ConnectWise host (cloud or on-prem; full URLs accepted) |
110
+ | `CW_COMPANY_ID` | — | Login company id |
111
+ | `CW_CLIENT_ID` | — | Integration clientId |
112
+ | `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — | Server-wide API member keys (optional when BYOK is enabled) |
113
+ | `CW_MEMBER_IDENTIFIER` | — | Member the server-wide keys belong to |
114
+ | `TRANSPORT` / `PORT` | `stdio` / `3000` | Transport selection |
115
+ | `CLIENT_CW_KEYS` | `with-token` | BYOK policy: `disabled`, `with-token`, `open` |
116
+ | `MCP_TOKENS_VIEWER/EDITOR/ADMIN` | — | `label:token,label:token` per role |
117
+
118
+ ## Notes & limits
119
+
120
+ - Ticket searches default to open tickets; status/board names are exact, text filters are substrings.
121
+ - Timestamps must have whole seconds — the server normalizes (ConnectWise rejects fractional seconds).
122
+ - Time entries require an **open time report period** in ConnectWise for the entry date; the API's message is passed through when none exists.
123
+ - `/system/myAccount` is missing on some on-prem versions — provide the member identifier explicitly (`CW_MEMBER_IDENTIFIER` or `x-cw-member-id`) for "my tickets"/"my time".
124
+ - Discussion notes are customer-visible; internal notes are not — the tool makes this explicit.
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ npm install
130
+ npm run dev # stdio via tsx
131
+ npm run dev:http # http via tsx
132
+ npm test # vitest
133
+ npm run build # tsc → dist/
134
+ ```
135
+
136
+ ## License
137
+
138
+ [MIT](LICENSE)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Role-based tool gating.
3
+ *
4
+ * Every tool belongs to a tier. The tier is derived from the tool's MCP
5
+ * annotations (readOnlyHint / destructiveHint) unless the tool declares an
6
+ * explicit `tier` override — used when the RBAC intent differs from the
7
+ * annotation (e.g. deleting a document section is destructive for clients,
8
+ * but editors need it to restructure documents).
9
+ *
10
+ * Enforcement is two-layered:
11
+ * 1. Tools outside the session's role are never registered, so they don't
12
+ * even appear in tools/list.
13
+ * 2. Registered handlers are wrapped with a runtime re-check, so a
14
+ * registration bug can never reach the IT Glue client.
15
+ */
16
+ const TIER_ROLES = {
17
+ read: ["viewer", "editor", "admin"],
18
+ write: ["editor", "admin"],
19
+ destructive: ["admin"],
20
+ };
21
+ export function tierOf(spec) {
22
+ if (spec.tier)
23
+ return spec.tier;
24
+ if (spec.annotations.readOnlyHint === true)
25
+ return "read";
26
+ if (spec.annotations.destructiveHint === true)
27
+ return "destructive";
28
+ return "write";
29
+ }
30
+ export function roleAllows(role, tier) {
31
+ return TIER_ROLES[tier].includes(role);
32
+ }
33
+ /** Minimum role that can use a tier — for error messages. */
34
+ function tierRequires(tier) {
35
+ return TIER_ROLES[tier][0] === "viewer" ? "viewer" : tier === "write" ? "editor" : "admin";
36
+ }
37
+ /**
38
+ * Registers tools on an McpServer, filtered and guarded by the session role.
39
+ */
40
+ export class ToolRegistrar {
41
+ server;
42
+ role;
43
+ constructor(server, role) {
44
+ this.server = server;
45
+ this.role = role;
46
+ }
47
+ register(spec, handler) {
48
+ const tier = tierOf(spec);
49
+ if (!roleAllows(this.role, tier)) {
50
+ return; // tool is invisible to this session
51
+ }
52
+ const role = this.role;
53
+ const guarded = async (args) => {
54
+ if (!roleAllows(role, tier)) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: `Error: ${spec.name} requires the ${tierRequires(tier)} role; this session is authenticated as ${role}.`,
60
+ },
61
+ ],
62
+ isError: true,
63
+ };
64
+ }
65
+ return handler(args);
66
+ };
67
+ this.server.registerTool(spec.name, {
68
+ title: spec.title,
69
+ description: spec.description,
70
+ inputSchema: spec.inputSchema,
71
+ annotations: spec.annotations,
72
+ }, guarded);
73
+ }
74
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Bearer-token authentication with per-role token pools.
3
+ *
4
+ * Tokens are configured through three environment variables, one per role:
5
+ *
6
+ * MCP_TOKENS_VIEWER="alice:tok1,bob:tok2"
7
+ * MCP_TOKENS_EDITOR="automation:tok3"
8
+ * MCP_TOKENS_ADMIN="ops:tok4"
9
+ *
10
+ * Each entry is `label:token`; the label identifies the holder in audit logs
11
+ * and lets individual tokens be revoked. Entries without a label get an
12
+ * auto-generated one (`viewer-1`, ...). Tokens must be unique across roles —
13
+ * duplicates are dropped with a warning.
14
+ */
15
+ import { timingSafeEqual } from "node:crypto";
16
+ const ROLE_ENV_VARS = [
17
+ ["viewer", "MCP_TOKENS_VIEWER"],
18
+ ["editor", "MCP_TOKENS_EDITOR"],
19
+ ["admin", "MCP_TOKENS_ADMIN"],
20
+ ];
21
+ export function parseTokenList(raw, role) {
22
+ const entries = [];
23
+ let unlabeled = 0;
24
+ for (const piece of raw.split(",")) {
25
+ const trimmed = piece.trim();
26
+ if (!trimmed)
27
+ continue;
28
+ const sep = trimmed.indexOf(":");
29
+ if (sep > 0 && sep < trimmed.length - 1) {
30
+ entries.push({
31
+ role,
32
+ label: trimmed.slice(0, sep).trim(),
33
+ token: trimmed.slice(sep + 1).trim(),
34
+ });
35
+ }
36
+ else {
37
+ unlabeled += 1;
38
+ entries.push({ role, label: `${role}-${unlabeled}`, token: trimmed.replace(/^:|:$/g, "") });
39
+ }
40
+ }
41
+ return entries.filter((e) => e.token.length > 0);
42
+ }
43
+ /** Load all configured token entries; duplicate token values are dropped. */
44
+ export function loadTokenEntries(env = process.env) {
45
+ const entries = [];
46
+ for (const [role, envVar] of ROLE_ENV_VARS) {
47
+ const raw = env[envVar];
48
+ if (raw)
49
+ entries.push(...parseTokenList(raw, role));
50
+ }
51
+ const seen = new Set();
52
+ return entries.filter((entry) => {
53
+ if (seen.has(entry.token)) {
54
+ console.error(`[auth] Warning: duplicate token for "${entry.label}" (${entry.role}) ignored — tokens must be unique across roles.`);
55
+ return false;
56
+ }
57
+ seen.add(entry.token);
58
+ return true;
59
+ });
60
+ }
61
+ /** Timing-safe string comparison for secrets. */
62
+ export function safeEquals(a, b) {
63
+ const bufA = Buffer.from(a, "utf-8");
64
+ const bufB = Buffer.from(b, "utf-8");
65
+ if (bufA.length !== bufB.length)
66
+ return false;
67
+ try {
68
+ return timingSafeEqual(bufA, bufB);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /** Extract the token from an `Authorization: Bearer <token>` header. */
75
+ export function bearerToken(authorizationHeader) {
76
+ if (!authorizationHeader)
77
+ return null;
78
+ const parts = authorizationHeader.split(" ");
79
+ if (parts.length !== 2 || parts[0] !== "Bearer" || !parts[1])
80
+ return null;
81
+ return parts[1];
82
+ }
83
+ /** Match an Authorization header against the configured tokens. */
84
+ export function authenticateToken(authorizationHeader, entries) {
85
+ const presented = bearerToken(authorizationHeader);
86
+ if (!presented)
87
+ return null;
88
+ for (const entry of entries) {
89
+ if (safeEquals(presented, entry.token))
90
+ return entry;
91
+ }
92
+ return null;
93
+ }
package/dist/config.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Server configuration, resolved from CLI flags and environment variables.
3
+ *
4
+ * Environment variables:
5
+ * CW_SITE ConnectWise host (e.g. na.myconnectwise.net or an
6
+ * on-prem host). A full URL is accepted; scheme and
7
+ * path are stripped.
8
+ * CW_COMPANY_ID ConnectWise login company id (required)
9
+ * CW_CLIENT_ID Integration clientId from developer.connectwise.com (required)
10
+ * CW_PUBLIC_KEY Server-wide API member public key (optional — enables
11
+ * CW_PRIVATE_KEY token-only sessions; omit to require per-tech keys)
12
+ * CW_MEMBER_IDENTIFIER Member identifier the server-wide keys belong to
13
+ * (enables "my tickets"/"my time" for token-only sessions)
14
+ * TRANSPORT stdio | http (default: stdio)
15
+ * PORT HTTP port (default: 3000)
16
+ * CLIENT_CW_KEYS disabled | with-token | open (default: with-token) —
17
+ * whether HTTP clients may supply their own member API
18
+ * keys via x-cw-public-key / x-cw-private-key headers
19
+ * MCP_TOKENS_VIEWER Comma-separated label:token list for the viewer role
20
+ * MCP_TOKENS_EDITOR Comma-separated label:token list for the editor role
21
+ * MCP_TOKENS_ADMIN Comma-separated label:token list for the admin role
22
+ */
23
+ const CLIENT_KEY_MODES = ["disabled", "with-token", "open"];
24
+ export class ConfigError extends Error {
25
+ }
26
+ function flagValue(argv, name) {
27
+ const idx = argv.indexOf(name);
28
+ if (idx === -1)
29
+ return undefined;
30
+ const value = argv[idx + 1];
31
+ if (value === undefined || value.startsWith("--")) {
32
+ throw new ConfigError(`Missing value for ${name}`);
33
+ }
34
+ return value;
35
+ }
36
+ /** Accept "host", "https://host", or "https://host/v4_6_release/apis/3.0/". */
37
+ export function normalizeSite(raw) {
38
+ const withoutScheme = raw.replace(/^https?:\/\//i, "");
39
+ const host = withoutScheme.split("/")[0]?.trim();
40
+ if (!host)
41
+ throw new ConfigError(`Invalid CW_SITE "${raw}"`);
42
+ return host;
43
+ }
44
+ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
45
+ // `||` not `??`: desktop hosts (MCPB) pass unset optional config as empty strings
46
+ const transport = ((flagValue(argv, "--transport") ?? env.TRANSPORT) || "stdio");
47
+ if (transport !== "stdio" && transport !== "http") {
48
+ throw new ConfigError(`Invalid transport "${transport}" — expected "stdio" or "http"`);
49
+ }
50
+ const portRaw = (flagValue(argv, "--port") ?? env.PORT) || "3000";
51
+ const port = Number(portRaw);
52
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
53
+ throw new ConfigError(`Invalid port "${portRaw}"`);
54
+ }
55
+ const siteRaw = flagValue(argv, "--site") ?? env.CW_SITE;
56
+ if (!siteRaw)
57
+ throw new ConfigError("CW_SITE is required (ConnectWise host)");
58
+ const site = normalizeSite(siteRaw);
59
+ const companyId = env.CW_COMPANY_ID;
60
+ if (!companyId)
61
+ throw new ConfigError("CW_COMPANY_ID is required");
62
+ const clientId = env.CW_CLIENT_ID;
63
+ if (!clientId) {
64
+ throw new ConfigError("CW_CLIENT_ID is required — register an integration at developer.connectwise.com");
65
+ }
66
+ const clientKeyMode = (env.CLIENT_CW_KEYS || "with-token");
67
+ if (!CLIENT_KEY_MODES.includes(clientKeyMode)) {
68
+ throw new ConfigError(`Invalid CLIENT_CW_KEYS "${clientKeyMode}" — expected one of: ${CLIENT_KEY_MODES.join(", ")}`);
69
+ }
70
+ const publicKey = env.CW_PUBLIC_KEY || undefined;
71
+ const privateKey = env.CW_PRIVATE_KEY || undefined;
72
+ if (!!publicKey !== !!privateKey) {
73
+ throw new ConfigError("CW_PUBLIC_KEY and CW_PRIVATE_KEY must be set together");
74
+ }
75
+ if (transport === "stdio" && !publicKey) {
76
+ throw new ConfigError("CW_PUBLIC_KEY / CW_PRIVATE_KEY are required for stdio transport");
77
+ }
78
+ if (transport === "http" && !publicKey && clientKeyMode === "disabled") {
79
+ throw new ConfigError("CW_PUBLIC_KEY / CW_PRIVATE_KEY are required when CLIENT_CW_KEYS=disabled — there are no keys the server could use");
80
+ }
81
+ return {
82
+ transport,
83
+ port,
84
+ site,
85
+ companyId,
86
+ clientId,
87
+ publicKey,
88
+ privateKey,
89
+ memberIdentifier: env.CW_MEMBER_IDENTIFIER || undefined,
90
+ clientKeyMode,
91
+ };
92
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ConnectWise Manage REST client (API 3.0) on the global fetch API.
3
+ *
4
+ * - Base URL: https://<site>/v4_6_release/apis/3.0
5
+ * - Auth: HTTP Basic with username "<companyId>+<publicKey>" and the private
6
+ * key as password, plus the mandatory `clientId` header.
7
+ * - Lists use CW's query grammar: `conditions` (e.g. `name contains "x" AND
8
+ * closedFlag=false`, date literals in [brackets]), `orderBy`, `page`,
9
+ * `pageSize` (max 1000), and `fields` for server-side field selection.
10
+ */
11
+ const REQUEST_TIMEOUT_MS = 30_000;
12
+ export const MAX_PAGE_SIZE = 1000;
13
+ /** Quote a string value for use inside a `conditions` expression. */
14
+ export function q(value) {
15
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
16
+ }
17
+ /** Join condition fragments with AND, skipping empties. */
18
+ export function allOf(...parts) {
19
+ const joined = parts.filter(Boolean).join(" AND ");
20
+ return joined || undefined;
21
+ }
22
+ export class CWApiError extends Error {
23
+ status;
24
+ detail;
25
+ constructor(message, status, detail) {
26
+ super(message);
27
+ this.status = status;
28
+ this.detail = detail;
29
+ this.name = "CWApiError";
30
+ }
31
+ }
32
+ export function describeError(error) {
33
+ if (error instanceof CWApiError) {
34
+ const detail = error.detail ? ` ${error.detail}` : "";
35
+ switch (error.status) {
36
+ case 400:
37
+ return `Error: Bad request.${detail} Check the parameters/conditions syntax.`;
38
+ case 401:
39
+ return "Error: ConnectWise authentication failed — check the API keys, company id, and clientId.";
40
+ case 403:
41
+ return `Error: Permission denied.${detail} The API member's security role may not allow this.`;
42
+ case 404:
43
+ return "Error: Resource not found. Verify the ID is correct.";
44
+ case 429:
45
+ return "Error: ConnectWise rate limit hit. Wait a moment and retry.";
46
+ default:
47
+ if (error.status && error.status >= 500) {
48
+ return `Error: ConnectWise server error (${error.status}). Try again later.`;
49
+ }
50
+ return `Error: ConnectWise request failed${error.status ? ` with status ${error.status}` : ""}.${detail}`;
51
+ }
52
+ }
53
+ if (error instanceof Error && error.name === "TimeoutError") {
54
+ return "Error: ConnectWise request timed out. Try again.";
55
+ }
56
+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
57
+ }
58
+ export class CWClient {
59
+ baseUrl;
60
+ authHeader;
61
+ clientId;
62
+ memberIdentifier;
63
+ triedSelfResolve = false;
64
+ constructor(options) {
65
+ this.baseUrl = `https://${options.site}/v4_6_release/apis/3.0`;
66
+ this.authHeader =
67
+ "Basic " +
68
+ Buffer.from(`${options.companyId}+${options.credentials.publicKey}:${options.credentials.privateKey}`).toString("base64");
69
+ this.clientId = options.clientId;
70
+ this.memberIdentifier = options.credentials.memberIdentifier;
71
+ }
72
+ async request(method, path, query, body) {
73
+ const url = new URL(this.baseUrl + path);
74
+ for (const [key, value] of Object.entries(query ?? {})) {
75
+ if (value !== undefined)
76
+ url.searchParams.set(key, String(value));
77
+ }
78
+ let response;
79
+ try {
80
+ response = await fetch(url, {
81
+ method,
82
+ headers: {
83
+ Authorization: this.authHeader,
84
+ clientId: this.clientId,
85
+ Accept: "application/json",
86
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
87
+ },
88
+ body: body === undefined ? undefined : JSON.stringify(body),
89
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
90
+ });
91
+ }
92
+ catch (err) {
93
+ if (err instanceof Error && err.name === "TimeoutError")
94
+ throw err;
95
+ throw new CWApiError(`Could not reach ConnectWise at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ if (!response.ok) {
98
+ let detail;
99
+ try {
100
+ const errBody = (await response.json());
101
+ detail = errBody.errors?.[0]?.message ?? errBody.message;
102
+ }
103
+ catch {
104
+ // non-JSON error body
105
+ }
106
+ throw new CWApiError(`ConnectWise responded with ${response.status}`, response.status, detail);
107
+ }
108
+ if (response.status === 204)
109
+ return undefined;
110
+ return (await response.json());
111
+ }
112
+ async getOne(path, fields) {
113
+ return this.request("GET", path, { fields });
114
+ }
115
+ async getList(path, queryOptions = {}) {
116
+ const page = queryOptions.page ?? 1;
117
+ const pageSize = Math.min(queryOptions.pageSize ?? 25, MAX_PAGE_SIZE);
118
+ const items = await this.request("GET", path, {
119
+ conditions: queryOptions.conditions,
120
+ childConditions: queryOptions.childConditions,
121
+ orderBy: queryOptions.orderBy,
122
+ fields: queryOptions.fields,
123
+ page,
124
+ pageSize,
125
+ });
126
+ return { items, hasMore: items.length === pageSize, page };
127
+ }
128
+ async post(path, body) {
129
+ return this.request("POST", path, undefined, body);
130
+ }
131
+ /** JSON-Patch style operations, e.g. [{op:"replace", path:"status", value:{name:"Closed"}}]. */
132
+ async patch(path, operations) {
133
+ return this.request("PATCH", path, undefined, operations);
134
+ }
135
+ /**
136
+ * The member identifier this session acts as. Uses the explicitly provided
137
+ * identifier when available; otherwise tries /system/myAccount once (not
138
+ * present on all CW versions). Returns undefined when unknown.
139
+ */
140
+ async me() {
141
+ if (this.memberIdentifier)
142
+ return this.memberIdentifier;
143
+ if (this.triedSelfResolve)
144
+ return undefined;
145
+ this.triedSelfResolve = true;
146
+ try {
147
+ const account = await this.request("GET", "/system/myAccount");
148
+ if (account?.identifier)
149
+ this.memberIdentifier = account.identifier;
150
+ }
151
+ catch {
152
+ // endpoint missing on this CW version — caller will ask for an explicit identifier
153
+ }
154
+ return this.memberIdentifier;
155
+ }
156
+ }
@@ -0,0 +1,2 @@
1
+ /** ConnectWise Manage REST resource shapes (fields this server selects). */
2
+ export {};