mcp-connectwise-psa 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/dist/config.js +12 -0
- package/dist/cw/client.js +4 -0
- package/dist/http/app.js +10 -0
- package/dist/index.js +6 -1
- package/dist/server.js +19 -8
- package/dist/tools/companies.js +62 -0
- package/dist/tools/finance.js +202 -0
- package/dist/tools/schedule.js +346 -0
- package/dist/tools/tickets.js +164 -0
- package/dist/tools/time.js +89 -0
- package/dist/tools/toolsets.js +83 -0
- package/package.json +1 -1
- package/dist/auth/roles.js +0 -74
- package/dist/auth/tokens.js +0 -93
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolsets — capability groups that can be enabled per session.
|
|
3
|
+
*
|
|
4
|
+
* A session exposes only the tools in its selected toolsets, so a dispatcher
|
|
5
|
+
* or a billing user isn't shown the full technician surface. Selection comes
|
|
6
|
+
* from the `x-cw-toolsets` header (HTTP) or the `CW_TOOLSETS` env / `--toolsets`
|
|
7
|
+
* flag (stdio); both accept a comma-separated list mixing capability keys and
|
|
8
|
+
* persona presets. This is a surface/usability filter, not access control —
|
|
9
|
+
* ConnectWise still enforces the member's real security role.
|
|
10
|
+
*/
|
|
11
|
+
export const TOOLSET_KEYS = [
|
|
12
|
+
"tickets",
|
|
13
|
+
"time",
|
|
14
|
+
"companies",
|
|
15
|
+
"configurations",
|
|
16
|
+
"schedule",
|
|
17
|
+
"finance",
|
|
18
|
+
];
|
|
19
|
+
const TECH = ["tickets", "time", "companies", "configurations"];
|
|
20
|
+
/** Persona presets — convenient shortcuts that expand to a set of keys. */
|
|
21
|
+
export const PRESETS = {
|
|
22
|
+
tech: TECH,
|
|
23
|
+
dispatch: ["tickets", "schedule", "companies", "configurations"],
|
|
24
|
+
invoicing: ["finance", "time", "companies"],
|
|
25
|
+
all: [...TOOLSET_KEYS],
|
|
26
|
+
};
|
|
27
|
+
/** The default selection when nothing is specified — today's technician surface. */
|
|
28
|
+
export const DEFAULT_TOOLSETS = TECH;
|
|
29
|
+
const KEY_SET = new Set(TOOLSET_KEYS);
|
|
30
|
+
function isToolsetKey(value) {
|
|
31
|
+
return KEY_SET.has(value);
|
|
32
|
+
}
|
|
33
|
+
export class UnknownToolsetError extends Error {
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a raw comma-separated list (keys and/or preset names) to a deduped,
|
|
37
|
+
* order-stable list of toolset keys.
|
|
38
|
+
*
|
|
39
|
+
* @param raw the header / env value, or undefined/empty
|
|
40
|
+
* @param fallback used when `raw` is undefined/blank/only-separators
|
|
41
|
+
* @param onUnknown "throw" (config/env — fail loudly) or "warn" (request header —
|
|
42
|
+
* ignore the bad token, keep the rest, never 500)
|
|
43
|
+
*/
|
|
44
|
+
export function resolveToolsets(raw, fallback, onUnknown = "throw") {
|
|
45
|
+
const tokens = (raw ?? "")
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((t) => t.trim().toLowerCase())
|
|
48
|
+
.filter((t) => t.length > 0);
|
|
49
|
+
if (tokens.length === 0)
|
|
50
|
+
return dedupe(fallback);
|
|
51
|
+
const keys = [];
|
|
52
|
+
for (const token of tokens) {
|
|
53
|
+
const preset = PRESETS[token];
|
|
54
|
+
if (preset) {
|
|
55
|
+
keys.push(...preset);
|
|
56
|
+
}
|
|
57
|
+
else if (isToolsetKey(token)) {
|
|
58
|
+
keys.push(token);
|
|
59
|
+
}
|
|
60
|
+
else if (onUnknown === "throw") {
|
|
61
|
+
throw new UnknownToolsetError(`Unknown toolset "${token}" — expected keys (${TOOLSET_KEYS.join(", ")}) ` +
|
|
62
|
+
`or presets (${Object.keys(PRESETS).join(", ")}).`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.error(`[toolsets] ignoring unknown toolset "${token}" in x-cw-toolsets header.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const resolved = dedupe(keys);
|
|
69
|
+
// An all-unknown "warn" list would otherwise silently expose nothing.
|
|
70
|
+
return resolved.length > 0 ? resolved : dedupe(fallback);
|
|
71
|
+
}
|
|
72
|
+
/** Dedupe while preserving first-seen order and TOOLSET_KEYS ordering intent. */
|
|
73
|
+
function dedupe(keys) {
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
if (!seen.has(key)) {
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-connectwise-psa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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",
|
package/dist/auth/roles.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/auth/tokens.js
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|