mcp-connectwise-psa 0.1.0 → 0.2.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 +16 -27
- package/dist/config.js +9 -19
- package/dist/http/app.js +29 -67
- package/dist/index.js +11 -20
- package/dist/server.js +7 -6
- package/dist/tools/registrar.js +24 -0
- package/package.json +3 -3
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)
|
|
@@ -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,35 +60,29 @@ 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
|
|
67
|
+
Send your keys on the initialize request (and on every subsequent request in the session):
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
89
78
|
|
|
90
|
-
|
|
79
|
+
Local **stdio** is single-user and uses the `CW_PUBLIC_KEY`/`CW_PRIVATE_KEY` from the environment instead of headers.
|
|
91
80
|
|
|
92
81
|
## Tools
|
|
93
82
|
|
|
94
|
-
|
|
83
|
+
The full tool surface is always available; whether a write succeeds is governed by the member's ConnectWise security role.
|
|
84
|
+
|
|
85
|
+
| Tool | Access |
|
|
95
86
|
|---|---|
|
|
96
87
|
| `cw_my_tickets`, `cw_search_tickets`, `cw_get_ticket` | read |
|
|
97
88
|
| `cw_list_my_time` | read |
|
|
@@ -100,7 +91,7 @@ Token-only sessions use the server-wide `CW_PUBLIC_KEY`/`CW_PRIVATE_KEY` (use an
|
|
|
100
91
|
| `cw_create_ticket`, `cw_update_ticket`, `cw_add_ticket_note` | write |
|
|
101
92
|
| `cw_create_time_entry` | write |
|
|
102
93
|
|
|
103
|
-
|
|
94
|
+
No destructive (delete) tools in v1.
|
|
104
95
|
|
|
105
96
|
## Configuration reference
|
|
106
97
|
|
|
@@ -109,11 +100,9 @@ Viewer = read. Editor/Admin = read + write. No destructive (delete) tools in v1.
|
|
|
109
100
|
| `CW_SITE` | — | ConnectWise host (cloud or on-prem; full URLs accepted) |
|
|
110
101
|
| `CW_COMPANY_ID` | — | Login company id |
|
|
111
102
|
| `CW_CLIENT_ID` | — | Integration clientId |
|
|
112
|
-
| `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — |
|
|
113
|
-
| `CW_MEMBER_IDENTIFIER` | — | Member the
|
|
103
|
+
| `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY` | — | API member keys — required for stdio; unused on HTTP (BYOK) |
|
|
104
|
+
| `CW_MEMBER_IDENTIFIER` | — | Member the stdio keys belong to (my-tickets/my-time) |
|
|
114
105
|
| `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
106
|
|
|
118
107
|
## Notes & limits
|
|
119
108
|
|
package/dist/config.js
CHANGED
|
@@ -7,20 +7,18 @@
|
|
|
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
|
-
* MCP_TOKENS_ADMIN Comma-separated label:token list for the admin role
|
|
16
|
+
*
|
|
17
|
+
* Access model: stdio uses the server-wide keys above (single local user). HTTP
|
|
18
|
+
* sessions each bring their own member API keys (BYOK) via x-cw-public-key /
|
|
19
|
+
* x-cw-private-key headers; ConnectWise enforces that member's security role.
|
|
20
|
+
* There is no MCP-level role gating.
|
|
22
21
|
*/
|
|
23
|
-
const CLIENT_KEY_MODES = ["disabled", "with-token", "open"];
|
|
24
22
|
export class ConfigError extends Error {
|
|
25
23
|
}
|
|
26
24
|
function flagValue(argv, name) {
|
|
@@ -63,10 +61,6 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
63
61
|
if (!clientId) {
|
|
64
62
|
throw new ConfigError("CW_CLIENT_ID is required — register an integration at developer.connectwise.com");
|
|
65
63
|
}
|
|
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
64
|
const publicKey = env.CW_PUBLIC_KEY || undefined;
|
|
71
65
|
const privateKey = env.CW_PRIVATE_KEY || undefined;
|
|
72
66
|
if (!!publicKey !== !!privateKey) {
|
|
@@ -75,9 +69,6 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
75
69
|
if (transport === "stdio" && !publicKey) {
|
|
76
70
|
throw new ConfigError("CW_PUBLIC_KEY / CW_PRIVATE_KEY are required for stdio transport");
|
|
77
71
|
}
|
|
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
72
|
return {
|
|
82
73
|
transport,
|
|
83
74
|
port,
|
|
@@ -87,6 +78,5 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
87
78
|
publicKey,
|
|
88
79
|
privateKey,
|
|
89
80
|
memberIdentifier: env.CW_MEMBER_IDENTIFIER || undefined,
|
|
90
|
-
clientKeyMode,
|
|
91
81
|
};
|
|
92
82
|
}
|
package/dist/http/app.js
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
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";
|
|
26
22
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
27
23
|
function rpcError(res, status, code, message) {
|
|
@@ -38,62 +34,30 @@ function headerValue(req, name) {
|
|
|
38
34
|
return Array.isArray(value) ? value[0] : value;
|
|
39
35
|
}
|
|
40
36
|
/** 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);
|
|
37
|
+
export function resolveAuth(req) {
|
|
44
38
|
const clientPublic = headerValue(req, "x-cw-public-key");
|
|
45
39
|
const clientPrivate = headerValue(req, "x-cw-private-key");
|
|
46
40
|
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
|
-
};
|
|
41
|
+
if (!clientPublic && !clientPrivate) {
|
|
42
|
+
return unauthorized("Supply your ConnectWise member API keys via the x-cw-public-key and x-cw-private-key headers.");
|
|
73
43
|
}
|
|
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 };
|
|
44
|
+
if (!clientPublic || !clientPrivate) {
|
|
45
|
+
return unauthorized("Both x-cw-public-key and x-cw-private-key headers are required.");
|
|
86
46
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
47
|
+
const keyHash = sha256(`${clientPublic}:${clientPrivate}`);
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
label: `byok:${keyHash.slice(0, 8)}`,
|
|
51
|
+
credentials: {
|
|
52
|
+
publicKey: clientPublic,
|
|
53
|
+
privateKey: clientPrivate,
|
|
54
|
+
memberIdentifier: clientMember,
|
|
55
|
+
},
|
|
56
|
+
keyHash,
|
|
57
|
+
};
|
|
94
58
|
}
|
|
95
59
|
function principalMatches(session, auth) {
|
|
96
|
-
return
|
|
60
|
+
return session.keyHash === auth.keyHash;
|
|
97
61
|
}
|
|
98
62
|
export function createApp(config) {
|
|
99
63
|
const app = express();
|
|
@@ -104,7 +68,7 @@ export function createApp(config) {
|
|
|
104
68
|
});
|
|
105
69
|
app.post("/mcp", (req, res) => {
|
|
106
70
|
void (async () => {
|
|
107
|
-
const auth = resolveAuth(req
|
|
71
|
+
const auth = resolveAuth(req);
|
|
108
72
|
if (!auth.ok)
|
|
109
73
|
return rpcError(res, auth.status, auth.code, auth.message);
|
|
110
74
|
const sessionId = headerValue(req, "mcp-session-id");
|
|
@@ -126,11 +90,10 @@ export function createApp(config) {
|
|
|
126
90
|
onsessioninitialized: (newSessionId) => {
|
|
127
91
|
sessions.set(newSessionId, {
|
|
128
92
|
transport,
|
|
129
|
-
role: auth.role,
|
|
130
93
|
label: auth.label,
|
|
131
94
|
keyHash: auth.keyHash,
|
|
132
95
|
});
|
|
133
|
-
console.error(`[
|
|
96
|
+
console.error(`[auth] session ${newSessionId} created for ${auth.label}`);
|
|
134
97
|
},
|
|
135
98
|
});
|
|
136
99
|
transport.onclose = () => {
|
|
@@ -138,7 +101,6 @@ export function createApp(config) {
|
|
|
138
101
|
sessions.delete(transport.sessionId);
|
|
139
102
|
};
|
|
140
103
|
const server = createServer(config, {
|
|
141
|
-
role: auth.role,
|
|
142
104
|
label: auth.label,
|
|
143
105
|
credentials: auth.credentials,
|
|
144
106
|
});
|
|
@@ -152,7 +114,7 @@ export function createApp(config) {
|
|
|
152
114
|
});
|
|
153
115
|
const handleSessionRequest = (req, res) => {
|
|
154
116
|
void (async () => {
|
|
155
|
-
const auth = resolveAuth(req
|
|
117
|
+
const auth = resolveAuth(req);
|
|
156
118
|
if (!auth.ok)
|
|
157
119
|
return rpcError(res, auth.status, auth.code, auth.message);
|
|
158
120
|
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}
|
|
@@ -19,31 +18,23 @@ Environment:
|
|
|
19
18
|
CW_SITE ConnectWise host (e.g. na.myconnectwise.net)
|
|
20
19
|
CW_COMPANY_ID Login company id
|
|
21
20
|
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
|
-
|
|
21
|
+
CW_PUBLIC_KEY API member public key (required for stdio)
|
|
22
|
+
CW_PRIVATE_KEY API member private key (required for stdio)
|
|
23
|
+
CW_MEMBER_IDENTIFIER Member the stdio keys belong to (my-tickets/my-time)
|
|
24
|
+
|
|
25
|
+
HTTP sessions authenticate per-request with their own member keys via the
|
|
26
|
+
x-cw-public-key / x-cw-private-key headers (BYOK); the CW_* keys above are used
|
|
27
|
+
only by stdio.
|
|
29
28
|
`;
|
|
30
29
|
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(", ")}`);
|
|
30
|
+
console.error("[auth] HTTP: each session must present its own ConnectWise keys via " +
|
|
31
|
+
"x-cw-public-key / x-cw-private-key (BYOK); ConnectWise enforces the member's security role.");
|
|
32
|
+
if (config.publicKey) {
|
|
33
|
+
console.error("[cw] CW_PUBLIC_KEY/CW_PRIVATE_KEY are set but unused on HTTP — sessions use their own keys.");
|
|
38
34
|
}
|
|
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
35
|
}
|
|
44
36
|
async function runStdio(config) {
|
|
45
37
|
const server = createServer(config, {
|
|
46
|
-
role: "admin",
|
|
47
38
|
label: "stdio",
|
|
48
39
|
credentials: {
|
|
49
40
|
publicKey: config.publicKey,
|
package/dist/server.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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
|
+
* The full tool surface is always registered; the member's ConnectWise security
|
|
5
|
+
* 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";
|
|
@@ -31,7 +32,7 @@ const INSTRUCTIONS = `# ConnectWise PSA MCP server
|
|
|
31
32
|
|
|
32
33
|
## Notes
|
|
33
34
|
- Lists are paginated; ask for more pages rather than huge page sizes.
|
|
34
|
-
-
|
|
35
|
+
- A write may still fail if your ConnectWise security role forbids it.`;
|
|
35
36
|
export function createServer(config, session) {
|
|
36
37
|
const client = new CWClient({
|
|
37
38
|
site: config.site,
|
|
@@ -40,7 +41,7 @@ export function createServer(config, session) {
|
|
|
40
41
|
credentials: session.credentials,
|
|
41
42
|
});
|
|
42
43
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: INSTRUCTIONS });
|
|
43
|
-
const reg = new ToolRegistrar(server
|
|
44
|
+
const reg = new ToolRegistrar(server);
|
|
44
45
|
registerTicketTools(reg, client);
|
|
45
46
|
registerTimeTools(reg, client);
|
|
46
47
|
registerCompanyTools(reg, client);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Every tool is registered on the session's McpServer. There is no MCP-level
|
|
5
|
+
* role gating: each session authenticates with a ConnectWise API member key
|
|
6
|
+
* (its own, via BYOK, or the server-wide keys on stdio), and ConnectWise
|
|
7
|
+
* enforces that member's security role server-side. The MCP server exposes the
|
|
8
|
+
* full tool surface and lets the CW API be the access control.
|
|
9
|
+
*/
|
|
10
|
+
/** Registers tools on an McpServer. */
|
|
11
|
+
export class ToolRegistrar {
|
|
12
|
+
server;
|
|
13
|
+
constructor(server) {
|
|
14
|
+
this.server = server;
|
|
15
|
+
}
|
|
16
|
+
register(spec, handler) {
|
|
17
|
+
this.server.registerTool(spec.name, {
|
|
18
|
+
title: spec.title,
|
|
19
|
+
description: spec.description,
|
|
20
|
+
inputSchema: spec.inputSchema,
|
|
21
|
+
annotations: spec.annotations,
|
|
22
|
+
}, handler);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-connectwise-psa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for ConnectWise PSA (Manage) — tickets, time entries, companies, and configurations for technicians, with
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for ConnectWise PSA (Manage) — tickets, time entries, companies, and configurations for technicians, with per-tech API keys (BYOK) so writes are attributed to the real member",
|
|
5
5
|
"mcpName": "io.github.selic/mcp-connectwise-psa",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"psa",
|
|
42
42
|
"msp",
|
|
43
43
|
"tickets",
|
|
44
|
-
"
|
|
44
|
+
"byok"
|
|
45
45
|
],
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=20"
|