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 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
- - **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
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=JSmith \
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": "JSmith"
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 -e MCP_TOKENS_VIEWER -e MCP_TOKENS_EDITOR mcp-connectwise-psa`
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
- ### Role tokens
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
- ```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:
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
- 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.
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
- 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).
81
+ ## Toolsets
91
82
 
92
- ## Tools
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
- | Tool | Tier |
85
+ | Toolset key | Tools |
95
86
  |---|---|
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 |
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
- Viewer = read. Editor/Admin = read + write. No destructive (delete) tools in v1.
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` | — | Server-wide API member keys (optional when BYOK is enabled) |
113
- | `CW_MEMBER_IDENTIFIER` | — | Member the server-wide keys belong to |
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
- | `CLIENT_CW_KEYS` | `with-token` | BYOK policy: `disabled`, `with-token`, `open` |
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 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)
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
- * 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
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
- const CLIENT_KEY_MODES = ["disabled", "with-token", "open"];
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
- 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");
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
- clientKeyMode,
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 + RBAC)
4
+ * POST/GET/DELETE /mcp MCP streamable-http endpoint (BYOK auth)
5
5
  * GET /health liveness probe
6
6
  *
7
- * Authentication model:
8
- * - Role tokens (Authorization: Bearer …) map to viewer/editor/admin and gate
9
- * which tools a session gets, using the server-wide ConnectWise keys.
10
- * - Bring-your-own-keys: x-cw-public-key + x-cw-private-key headers bind the
11
- * session to that member's API keys with the FULL tool surface — the
12
- * member's own ConnectWise security role is the access control, and writes
13
- * (notes, time entries) are attributed to that member. Optional
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 principal (role + label + key hash) the session was
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, config) {
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 || clientPrivate) {
48
- if (config.clientKeyMode === "disabled") {
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
- const serverCredentials = config.publicKey && config.privateKey
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
- if (!token) {
88
- return unauthorized("Unauthorized: invalid or missing bearer token.");
89
- }
90
- if (!serverCredentials) {
91
- return unauthorized("This server has no shared ConnectWise keys — supply your own via the x-cw-public-key/x-cw-private-key headers.");
92
- }
93
- return { ok: true, role: token.role, label: token.label, credentials: serverCredentials, keyHash: null };
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 (session.role === auth.role && session.label === auth.label && session.keyHash === auth.keyHash);
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, config);
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(`[rbac] session ${newSessionId} created for ${auth.label} (${auth.role})`);
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, config);
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 Server-wide API member public key (optional with BYOK)
23
- CW_PRIVATE_KEY Server-wide API member private key
24
- CW_MEMBER_IDENTIFIER Member the server-wide keys belong to (my-tickets/my-time)
25
- MCP_TOKENS_VIEWER label:token[,label:token…] read-only access (HTTP)
26
- MCP_TOKENS_EDITOR label:token[,…] read + tickets/notes/time writes
27
- MCP_TOKENS_ADMIN label:token[,…] reserved for future destructive tools
28
- CLIENT_CW_KEYS disabled|with-token|open — client-supplied CW keys (default: with-token)
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
- const entries = loadTokenEntries();
32
- if (entries.length === 0) {
33
- console.error("[auth] WARNING: no MCP_TOKENS_* configured — all HTTP requests are accepted with FULL access. " +
34
- "Set MCP_TOKENS_VIEWER / MCP_TOKENS_EDITOR / MCP_TOKENS_ADMIN in production.");
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
- * - the ConnectWise credentials it uses (server-wide keys or client-supplied)
4
- * - the role that gates which tools are registered
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 "./auth/roles.js";
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
- - Some tools may be unavailable depending on this session's access level.`;
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, session.role);
44
- registerTicketTools(reg, client);
45
- registerTimeTools(reg, client);
46
- registerCompanyTools(reg, client);
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
  }
@@ -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
  }