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
package/README.md
CHANGED
|
@@ -6,8 +6,7 @@ An MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server for [C
|
|
|
6
6
|
- **Time entries** — log time against tickets, review your own time
|
|
7
7
|
- **Companies & contacts** — fast lookup (read-only)
|
|
8
8
|
- **Configurations** — devices/assets with serials, IPs, OS, warranty (read-only)
|
|
9
|
-
- **
|
|
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
|
|
9
|
+
- **Per-tech API keys (BYOK)** — each tech supplies their own ConnectWise member keys; ConnectWise enforces that member's security role, and notes and time entries are attributed to the *actual person*
|
|
11
10
|
- **Transports** — stdio for local use, streamable HTTP for shared deployments; Docker image included
|
|
12
11
|
|
|
13
12
|
## Quick start (local, stdio)
|
|
@@ -18,7 +17,7 @@ CW_SITE=na.myconnectwise.net \
|
|
|
18
17
|
CW_COMPANY_ID=yourcompany \
|
|
19
18
|
CW_CLIENT_ID=<integration clientId> \
|
|
20
19
|
CW_PUBLIC_KEY=xxxx CW_PRIVATE_KEY=yyyy \
|
|
21
|
-
CW_MEMBER_IDENTIFIER=
|
|
20
|
+
CW_MEMBER_IDENTIFIER=jdoe \
|
|
22
21
|
node dist/index.js
|
|
23
22
|
```
|
|
24
23
|
|
|
@@ -36,7 +35,7 @@ Claude Desktop / Claude Code config:
|
|
|
36
35
|
"CW_CLIENT_ID": "<clientId>",
|
|
37
36
|
"CW_PUBLIC_KEY": "xxxx",
|
|
38
37
|
"CW_PRIVATE_KEY": "yyyy",
|
|
39
|
-
"CW_MEMBER_IDENTIFIER": "
|
|
38
|
+
"CW_MEMBER_IDENTIFIER": "jdoe"
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
}
|
|
@@ -49,12 +48,10 @@ A `clientId` is required by the ConnectWise API — register a (free) integratio
|
|
|
49
48
|
|
|
50
49
|
```bash
|
|
51
50
|
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
51
|
node dist/index.js --transport http --port 3000
|
|
55
52
|
```
|
|
56
53
|
|
|
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
|
|
54
|
+
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 mcp-connectwise-psa`
|
|
58
55
|
|
|
59
56
|
| Route | Purpose |
|
|
60
57
|
|---|---|
|
|
@@ -63,44 +60,45 @@ Or with Docker: `docker build -t mcp-connectwise-psa . && docker run -p 3000:300
|
|
|
63
60
|
|
|
64
61
|
> Sessions are held in memory — run a single instance (or sticky sessions).
|
|
65
62
|
|
|
66
|
-
## Access control
|
|
63
|
+
## Access control — bring your own keys (BYOK)
|
|
67
64
|
|
|
68
|
-
|
|
65
|
+
Over HTTP there is **no MCP-level role system**. Each session presents its own ConnectWise member API keys, and ConnectWise itself is the access control: the member's security role decides what succeeds, and every note and time entry is attributed to that member.
|
|
69
66
|
|
|
70
|
-
|
|
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:
|
|
67
|
+
Send your keys on the initialize request (and on every subsequent request in the session):
|
|
81
68
|
|
|
82
|
-
```
|
|
69
|
+
```http
|
|
83
70
|
x-cw-public-key: <public key>
|
|
84
71
|
x-cw-private-key: <private key>
|
|
85
72
|
x-cw-member-id: <your member identifier> (optional — enables "my tickets"/"my time")
|
|
86
73
|
```
|
|
87
74
|
|
|
88
|
-
|
|
75
|
+
- A request with no keys is rejected with `401`; both key headers are required together.
|
|
76
|
+
- Keys are never logged. A session is bound to a SHA-256 hash of the key pair; presenting a different pair on the same session id → `403`.
|
|
77
|
+
- Create member API keys in ConnectWise under **My Account → API Keys**. Each tech uses their own.
|
|
78
|
+
|
|
79
|
+
Local **stdio** is single-user and uses the `CW_PUBLIC_KEY`/`CW_PRIVATE_KEY` from the environment instead of headers.
|
|
89
80
|
|
|
90
|
-
|
|
81
|
+
## Toolsets
|
|
91
82
|
|
|
92
|
-
|
|
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.
|
|
93
84
|
|
|
94
|
-
|
|
|
85
|
+
| Toolset key | Tools |
|
|
95
86
|
|---|---|
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `cw_search_companies`, `cw_get_company`, `cw_search_contacts`
|
|
99
|
-
| `cw_list_configurations`, `cw_get_configuration` |
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
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
|
+
|
|
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`.
|
|
102
100
|
|
|
103
|
-
|
|
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.
|
|
104
102
|
|
|
105
103
|
## Configuration reference
|
|
106
104
|
|
|
@@ -109,11 +107,10 @@ Viewer = read. Editor/Admin = read + write. No destructive (delete) tools in v1.
|
|
|
109
107
|
| `CW_SITE` | — | ConnectWise host (cloud or on-prem; full URLs accepted) |
|
|
110
108
|
| `CW_COMPANY_ID` | — | Login company id |
|
|
111
109
|
| `CW_CLIENT_ID` | — | Integration clientId |
|
|
112
|
-
| `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — |
|
|
113
|
-
| `CW_MEMBER_IDENTIFIER` | — | Member the
|
|
110
|
+
| `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — | API member keys — required for stdio; unused on HTTP (BYOK) |
|
|
111
|
+
| `CW_MEMBER_IDENTIFIER` | — | Member the stdio keys belong to (my-tickets/my-time) |
|
|
114
112
|
| `TRANSPORT` / `PORT` | `stdio` / `3000` | Transport selection |
|
|
115
|
-
| `
|
|
116
|
-
| `MCP_TOKENS_VIEWER/EDITOR/ADMIN` | — | `label:token,label:token` per role |
|
|
113
|
+
| `CW_TOOLSETS` | `tech` | Enabled toolsets (keys/presets); HTTP overrides per session via `x-cw-toolsets` |
|
|
117
114
|
|
|
118
115
|
## Notes & limits
|
|
119
116
|
|
package/dist/config.js
CHANGED
|
@@ -7,20 +7,22 @@
|
|
|
7
7
|
* path are stripped.
|
|
8
8
|
* CW_COMPANY_ID ConnectWise login company id (required)
|
|
9
9
|
* CW_CLIENT_ID Integration clientId from developer.connectwise.com (required)
|
|
10
|
-
* CW_PUBLIC_KEY
|
|
11
|
-
* CW_PRIVATE_KEY
|
|
12
|
-
* CW_MEMBER_IDENTIFIER Member
|
|
13
|
-
*
|
|
10
|
+
* CW_PUBLIC_KEY API member public key — required for stdio; unused on
|
|
11
|
+
* CW_PRIVATE_KEY HTTP, where each session brings its own keys (BYOK)
|
|
12
|
+
* CW_MEMBER_IDENTIFIER Member the stdio keys belong to (enables
|
|
13
|
+
* "my tickets"/"my time")
|
|
14
14
|
* TRANSPORT stdio | http (default: stdio)
|
|
15
15
|
* PORT HTTP port (default: 3000)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
19
|
+
*
|
|
20
|
+
* Access model: stdio uses the server-wide keys above (single local user). HTTP
|
|
21
|
+
* sessions each bring their own member API keys (BYOK) via x-cw-public-key /
|
|
22
|
+
* x-cw-private-key headers; ConnectWise enforces that member's security role.
|
|
23
|
+
* There is no MCP-level role gating.
|
|
22
24
|
*/
|
|
23
|
-
|
|
25
|
+
import { DEFAULT_TOOLSETS, resolveToolsets } from "./tools/toolsets.js";
|
|
24
26
|
export class ConfigError extends Error {
|
|
25
27
|
}
|
|
26
28
|
function flagValue(argv, name) {
|
|
@@ -63,10 +65,6 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
63
65
|
if (!clientId) {
|
|
64
66
|
throw new ConfigError("CW_CLIENT_ID is required — register an integration at developer.connectwise.com");
|
|
65
67
|
}
|
|
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
68
|
const publicKey = env.CW_PUBLIC_KEY || undefined;
|
|
71
69
|
const privateKey = env.CW_PRIVATE_KEY || undefined;
|
|
72
70
|
if (!!publicKey !== !!privateKey) {
|
|
@@ -75,8 +73,12 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
75
73
|
if (transport === "stdio" && !publicKey) {
|
|
76
74
|
throw new ConfigError("CW_PUBLIC_KEY / CW_PRIVATE_KEY are required for stdio transport");
|
|
77
75
|
}
|
|
78
|
-
|
|
79
|
-
|
|
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));
|
|
80
82
|
}
|
|
81
83
|
return {
|
|
82
84
|
transport,
|
|
@@ -87,6 +89,6 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
87
89
|
publicKey,
|
|
88
90
|
privateKey,
|
|
89
91
|
memberIdentifier: env.CW_MEMBER_IDENTIFIER || undefined,
|
|
90
|
-
|
|
92
|
+
toolsets,
|
|
91
93
|
};
|
|
92
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
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP transport: Express app exposing
|
|
3
3
|
*
|
|
4
|
-
* POST/GET/DELETE /mcp MCP streamable-http endpoint (auth
|
|
4
|
+
* POST/GET/DELETE /mcp MCP streamable-http endpoint (BYOK auth)
|
|
5
5
|
* GET /health liveness probe
|
|
6
6
|
*
|
|
7
|
-
* Authentication model:
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* member
|
|
13
|
-
*
|
|
14
|
-
* x-cw-member-id header names the member for "my tickets"/"my time".
|
|
15
|
-
* CLIENT_CW_KEYS controls the policy (with-token default / open / disabled).
|
|
7
|
+
* Authentication model — bring-your-own-keys only:
|
|
8
|
+
* - Every session presents its own ConnectWise member API keys via the
|
|
9
|
+
* x-cw-public-key + x-cw-private-key headers. Those keys are both the
|
|
10
|
+
* credential and the permission model: ConnectWise enforces that member's
|
|
11
|
+
* security role, and writes (notes, time entries) are attributed to that
|
|
12
|
+
* member. Optional x-cw-member-id names the member for "my tickets"/"my time".
|
|
13
|
+
* - The full tool surface is exposed; there is no MCP-level role gating.
|
|
16
14
|
* - A session id never carries privilege: every request re-authenticates and
|
|
17
|
-
* must present the same
|
|
18
|
-
* created with.
|
|
15
|
+
* must present the same key pair (SHA-256 hash) the session was created with.
|
|
19
16
|
*/
|
|
20
17
|
import { createHash, randomUUID } from "node:crypto";
|
|
21
18
|
import express from "express";
|
|
22
19
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
20
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
24
|
-
import { authenticateToken, loadTokenEntries } from "../auth/tokens.js";
|
|
25
21
|
import { createServer, SERVER_NAME, SERVER_VERSION } from "../server.js";
|
|
22
|
+
import { resolveToolsets } from "../tools/toolsets.js";
|
|
26
23
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
27
24
|
function rpcError(res, status, code, message) {
|
|
28
25
|
res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id: null });
|
|
@@ -38,62 +35,38 @@ function headerValue(req, name) {
|
|
|
38
35
|
return Array.isArray(value) ? value[0] : value;
|
|
39
36
|
}
|
|
40
37
|
/** Resolve the principal for a request. Exported for tests. */
|
|
41
|
-
export function resolveAuth(req
|
|
42
|
-
const entries = loadTokenEntries();
|
|
43
|
-
const token = authenticateToken(req.headers.authorization, entries);
|
|
38
|
+
export function resolveAuth(req) {
|
|
44
39
|
const clientPublic = headerValue(req, "x-cw-public-key");
|
|
45
40
|
const clientPrivate = headerValue(req, "x-cw-private-key");
|
|
46
41
|
const clientMember = headerValue(req, "x-cw-member-id");
|
|
47
|
-
if (clientPublic
|
|
48
|
-
|
|
49
|
-
return unauthorized("Client-supplied ConnectWise keys are disabled on this server (CLIENT_CW_KEYS=disabled).");
|
|
50
|
-
}
|
|
51
|
-
if (!clientPublic || !clientPrivate) {
|
|
52
|
-
return unauthorized("Both x-cw-public-key and x-cw-private-key headers are required.");
|
|
53
|
-
}
|
|
54
|
-
const keyHash = sha256(`${clientPublic}:${clientPrivate}`);
|
|
55
|
-
const byokLabel = `byok:${keyHash.slice(0, 8)}`;
|
|
56
|
-
if (config.clientKeyMode === "with-token" && entries.length > 0 && !token) {
|
|
57
|
-
return unauthorized("A valid bearer token is required alongside your ConnectWise keys (CLIENT_CW_KEYS=with-token).");
|
|
58
|
-
}
|
|
59
|
-
const label = token ? `${token.label}+${byokLabel}` : byokLabel;
|
|
60
|
-
// BYOK sessions get the full tool surface; the member's own ConnectWise
|
|
61
|
-
// security role is the effective access control.
|
|
62
|
-
return {
|
|
63
|
-
ok: true,
|
|
64
|
-
role: "admin",
|
|
65
|
-
label,
|
|
66
|
-
credentials: {
|
|
67
|
-
publicKey: clientPublic,
|
|
68
|
-
privateKey: clientPrivate,
|
|
69
|
-
memberIdentifier: clientMember,
|
|
70
|
-
},
|
|
71
|
-
keyHash,
|
|
72
|
-
};
|
|
42
|
+
if (!clientPublic && !clientPrivate) {
|
|
43
|
+
return unauthorized("Supply your ConnectWise member API keys via the x-cw-public-key and x-cw-private-key headers.");
|
|
73
44
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
publicKey: config.publicKey,
|
|
77
|
-
privateKey: config.privateKey,
|
|
78
|
-
memberIdentifier: config.memberIdentifier,
|
|
79
|
-
}
|
|
80
|
-
: null;
|
|
81
|
-
if (entries.length === 0) {
|
|
82
|
-
if (!serverCredentials) {
|
|
83
|
-
return unauthorized("No ConnectWise keys available — set CW_PUBLIC_KEY/CW_PRIVATE_KEY on the server or send x-cw-public-key/x-cw-private-key.");
|
|
84
|
-
}
|
|
85
|
-
return { ok: true, role: "admin", label: "dev-unauthenticated", credentials: serverCredentials, keyHash: null };
|
|
45
|
+
if (!clientPublic || !clientPrivate) {
|
|
46
|
+
return unauthorized("Both x-cw-public-key and x-cw-private-key headers are required.");
|
|
86
47
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
48
|
+
const keyHash = sha256(`${clientPublic}:${clientPrivate}`);
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
label: `byok:${keyHash.slice(0, 8)}`,
|
|
52
|
+
credentials: {
|
|
53
|
+
publicKey: clientPublic,
|
|
54
|
+
privateKey: clientPrivate,
|
|
55
|
+
memberIdentifier: clientMember,
|
|
56
|
+
},
|
|
57
|
+
keyHash,
|
|
58
|
+
};
|
|
94
59
|
}
|
|
95
60
|
function principalMatches(session, auth) {
|
|
96
|
-
return
|
|
61
|
+
return session.keyHash === auth.keyHash;
|
|
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");
|
|
97
70
|
}
|
|
98
71
|
export function createApp(config) {
|
|
99
72
|
const app = express();
|
|
@@ -104,7 +77,7 @@ export function createApp(config) {
|
|
|
104
77
|
});
|
|
105
78
|
app.post("/mcp", (req, res) => {
|
|
106
79
|
void (async () => {
|
|
107
|
-
const auth = resolveAuth(req
|
|
80
|
+
const auth = resolveAuth(req);
|
|
108
81
|
if (!auth.ok)
|
|
109
82
|
return rpcError(res, auth.status, auth.code, auth.message);
|
|
110
83
|
const sessionId = headerValue(req, "mcp-session-id");
|
|
@@ -126,11 +99,10 @@ export function createApp(config) {
|
|
|
126
99
|
onsessioninitialized: (newSessionId) => {
|
|
127
100
|
sessions.set(newSessionId, {
|
|
128
101
|
transport,
|
|
129
|
-
role: auth.role,
|
|
130
102
|
label: auth.label,
|
|
131
103
|
keyHash: auth.keyHash,
|
|
132
104
|
});
|
|
133
|
-
console.error(`[
|
|
105
|
+
console.error(`[auth] session ${newSessionId} created for ${auth.label}`);
|
|
134
106
|
},
|
|
135
107
|
});
|
|
136
108
|
transport.onclose = () => {
|
|
@@ -138,9 +110,9 @@ export function createApp(config) {
|
|
|
138
110
|
sessions.delete(transport.sessionId);
|
|
139
111
|
};
|
|
140
112
|
const server = createServer(config, {
|
|
141
|
-
role: auth.role,
|
|
142
113
|
label: auth.label,
|
|
143
114
|
credentials: auth.credentials,
|
|
115
|
+
toolsets: sessionToolsets(req, config),
|
|
144
116
|
});
|
|
145
117
|
await server.connect(transport);
|
|
146
118
|
await transport.handleRequest(req, res, req.body);
|
|
@@ -152,7 +124,7 @@ export function createApp(config) {
|
|
|
152
124
|
});
|
|
153
125
|
const handleSessionRequest = (req, res) => {
|
|
154
126
|
void (async () => {
|
|
155
|
-
const auth = resolveAuth(req
|
|
127
|
+
const auth = resolveAuth(req);
|
|
156
128
|
if (!auth.ok)
|
|
157
129
|
return rpcError(res, auth.status, auth.code, auth.message);
|
|
158
130
|
const sessionId = headerValue(req, "mcp-session-id");
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
/** CLI entry point — runs the MCP server over stdio (default) or HTTP. */
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { ConfigError, loadConfig } from "./config.js";
|
|
5
|
-
import { loadTokenEntries } from "./auth/tokens.js";
|
|
6
5
|
import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
7
6
|
import { createApp } from "./http/app.js";
|
|
8
7
|
const USAGE = `${SERVER_NAME} v${SERVER_VERSION}
|
|
@@ -13,43 +12,40 @@ Options:
|
|
|
13
12
|
--transport stdio|http Transport (default: stdio; env TRANSPORT)
|
|
14
13
|
--port <n> HTTP port (default: 3000; env PORT)
|
|
15
14
|
--site <host> ConnectWise host (env CW_SITE)
|
|
15
|
+
--toolsets <list> Enabled toolsets (env CW_TOOLSETS; default: tech)
|
|
16
16
|
--help Show this help
|
|
17
17
|
|
|
18
18
|
Environment:
|
|
19
19
|
CW_SITE ConnectWise host (e.g. na.myconnectwise.net)
|
|
20
20
|
CW_COMPANY_ID Login company id
|
|
21
21
|
CW_CLIENT_ID Integration clientId (developer.connectwise.com)
|
|
22
|
-
CW_PUBLIC_KEY
|
|
23
|
-
CW_PRIVATE_KEY
|
|
24
|
-
CW_MEMBER_IDENTIFIER Member the
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
CW_PUBLIC_KEY API member public key (required for stdio)
|
|
23
|
+
CW_PRIVATE_KEY API member private key (required for stdio)
|
|
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.
|
|
28
|
+
|
|
29
|
+
HTTP sessions authenticate per-request with their own member keys via the
|
|
30
|
+
x-cw-public-key / x-cw-private-key headers (BYOK); the CW_* keys above are used
|
|
31
|
+
only by stdio. HTTP clients pick toolsets per session with the x-cw-toolsets header.
|
|
29
32
|
`;
|
|
30
33
|
function logStartupSummary(config) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
console.error(`[auth] ${entries.length} token(s) configured: ${entries.map((e) => `${e.label}(${e.role})`).join(", ")}`);
|
|
34
|
+
console.error("[auth] HTTP: each session must present its own ConnectWise keys via " +
|
|
35
|
+
"x-cw-public-key / x-cw-private-key (BYOK); ConnectWise enforces the member's security role.");
|
|
36
|
+
if (config.publicKey) {
|
|
37
|
+
console.error("[cw] CW_PUBLIC_KEY/CW_PRIVATE_KEY are set but unused on HTTP — sessions use their own keys.");
|
|
38
38
|
}
|
|
39
|
-
console.error(`[auth] Client-supplied ConnectWise keys: ${config.clientKeyMode}`);
|
|
40
|
-
console.error(config.publicKey
|
|
41
|
-
? `[cw] Server-wide keys configured${config.memberIdentifier ? ` (member: ${config.memberIdentifier})` : " (no CW_MEMBER_IDENTIFIER — my-tickets/my-time unavailable for token-only sessions)"}`
|
|
42
|
-
: "[cw] No server-wide keys — sessions must bring their own via x-cw-public-key/x-cw-private-key.");
|
|
43
39
|
}
|
|
44
40
|
async function runStdio(config) {
|
|
45
41
|
const server = createServer(config, {
|
|
46
|
-
role: "admin",
|
|
47
42
|
label: "stdio",
|
|
48
43
|
credentials: {
|
|
49
44
|
publicKey: config.publicKey,
|
|
50
45
|
privateKey: config.privateKey,
|
|
51
46
|
memberIdentifier: config.memberIdentifier,
|
|
52
47
|
},
|
|
48
|
+
toolsets: config.toolsets,
|
|
53
49
|
});
|
|
54
50
|
await server.connect(new StdioServerTransport());
|
|
55
51
|
console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio (${config.site})`);
|
package/dist/server.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Builds an McpServer for one session. A session is defined by
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
+
* and the toolsets it selected. Only the selected toolsets are registered; the
|
|
5
|
+
* member's ConnectWise security role is the access control.
|
|
5
6
|
*/
|
|
6
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
8
|
import { createRequire } from "node:module";
|
|
8
|
-
import { ToolRegistrar } from "./
|
|
9
|
+
import { ToolRegistrar } from "./tools/registrar.js";
|
|
9
10
|
import { CWClient } from "./cw/client.js";
|
|
10
11
|
import { registerTicketTools } from "./tools/tickets.js";
|
|
11
12
|
import { registerTimeTools } from "./tools/time.js";
|
|
12
13
|
import { registerCompanyTools } from "./tools/companies.js";
|
|
13
14
|
import { registerConfigurationTools } from "./tools/configurations.js";
|
|
15
|
+
import { registerScheduleTools } from "./tools/schedule.js";
|
|
16
|
+
import { registerFinanceTools } from "./tools/finance.js";
|
|
14
17
|
const require = createRequire(import.meta.url);
|
|
15
18
|
const pkg = require("../package.json");
|
|
16
19
|
export const SERVER_NAME = pkg.name;
|
|
17
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
|
+
};
|
|
18
30
|
const INSTRUCTIONS = `# ConnectWise PSA MCP server
|
|
19
31
|
|
|
20
32
|
## Finding things
|
|
@@ -31,7 +43,8 @@ const INSTRUCTIONS = `# ConnectWise PSA MCP server
|
|
|
31
43
|
|
|
32
44
|
## Notes
|
|
33
45
|
- Lists are paginated; ask for more pages rather than huge page sizes.
|
|
34
|
-
-
|
|
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.`;
|
|
35
48
|
export function createServer(config, session) {
|
|
36
49
|
const client = new CWClient({
|
|
37
50
|
site: config.site,
|
|
@@ -40,10 +53,9 @@ export function createServer(config, session) {
|
|
|
40
53
|
credentials: session.credentials,
|
|
41
54
|
});
|
|
42
55
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: INSTRUCTIONS });
|
|
43
|
-
const reg = new ToolRegistrar(server
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
registerConfigurationTools(reg, client);
|
|
56
|
+
const reg = new ToolRegistrar(server);
|
|
57
|
+
for (const key of session.toolsets) {
|
|
58
|
+
TOOLSETS[key](reg, client);
|
|
59
|
+
}
|
|
48
60
|
return server;
|
|
49
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
|
}
|