@alfe.ai/connectwise-automate-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/bin.cjs +17 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +18 -0
- package/dist/server.cjs +8 -0
- package/dist/server.d.cts +95 -0
- package/dist/server.d.ts +95 -0
- package/dist/server.js +2 -0
- package/dist/server2.cjs +622 -0
- package/dist/server2.js +557 -0
- package/package.json +37 -0
package/dist/server2.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
6
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import dns from "node:dns/promises";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import { BlockList, isIP } from "node:net";
|
|
11
|
+
//#region src/boundary.ts
|
|
12
|
+
const PROVIDER_ID = "connectwise-automate";
|
|
13
|
+
const messages = {
|
|
14
|
+
invalid_connection: "Select an available Automate connection using automate_list_connections.",
|
|
15
|
+
invalid_credentials: "The Automate connection credentials are invalid. Reconnect it in Alfe.",
|
|
16
|
+
unsafe_server: "The Automate server must resolve only to public HTTPS addresses.",
|
|
17
|
+
timeout: "The Automate request timed out. Narrow the request and try again.",
|
|
18
|
+
authentication_failed: "Automate rejected the credentials. Check the integrator account and Alfe ClientID configuration.",
|
|
19
|
+
interactive_authentication: "Automate requires interactive authentication. Connect an integrator account suitable for unattended API access.",
|
|
20
|
+
permission_denied: "The Automate integrator account cannot access this resource.",
|
|
21
|
+
rate_limited: "Automate rate limited the request. Wait before retrying.",
|
|
22
|
+
not_found: "Automate did not find this resource or API route.",
|
|
23
|
+
invalid_response: "Automate returned an invalid response.",
|
|
24
|
+
response_too_large: "Automate returned too much data. Use a smaller page or a narrower condition.",
|
|
25
|
+
request_failed: "The Automate request could not be completed.",
|
|
26
|
+
invalid_arguments: "The Automate tool arguments are invalid."
|
|
27
|
+
};
|
|
28
|
+
var AutomateError = class extends Error {
|
|
29
|
+
constructor(code) {
|
|
30
|
+
super(messages[code]);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.name = "AutomateError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function safeError(error) {
|
|
36
|
+
return error instanceof AutomateError ? {
|
|
37
|
+
code: error.code,
|
|
38
|
+
message: error.message
|
|
39
|
+
} : {
|
|
40
|
+
code: "request_failed",
|
|
41
|
+
message: messages.request_failed
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function hasControlCharacters(value) {
|
|
48
|
+
return Array.from(value).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
49
|
+
}
|
|
50
|
+
function boundedSecret(value, max = 4096) {
|
|
51
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || hasControlCharacters(value)) throw new AutomateError("invalid_credentials");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function validateConnectionId(value) {
|
|
55
|
+
if (typeof value !== "string" || !/^con_[A-Za-z0-9_-]{1,180}$/u.test(value)) throw new AutomateError("invalid_connection");
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function serverOrigin(value) {
|
|
59
|
+
try {
|
|
60
|
+
if (typeof value !== "string" || value.length > 2048 || value !== value.trim()) throw new Error();
|
|
61
|
+
const url = new URL(value);
|
|
62
|
+
const host = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
63
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || isIP(host.replace(/^\[|\]$/gu, "")) || !host.includes(".") || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])$/u.test(host) || host.split(".").some((label) => !label || label.length > 63) || /(?:^|\.)(?:localhost|local|internal|metadata|test|invalid)$/u.test(host)) throw new Error();
|
|
64
|
+
url.hostname = host;
|
|
65
|
+
return url.origin;
|
|
66
|
+
} catch {
|
|
67
|
+
throw new AutomateError("unsafe_server");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function credentialsFrom(value, connectionId) {
|
|
71
|
+
if (!isRecord(value) || value.provider !== "connectwise-automate" || value.connectionId !== connectionId) throw new AutomateError("invalid_connection");
|
|
72
|
+
const clientId = boundedSecret(value.clientId, 256);
|
|
73
|
+
if (clientId.trim().toLowerCase() === "placeholder") throw new AutomateError("invalid_credentials");
|
|
74
|
+
return {
|
|
75
|
+
serverUrl: serverOrigin(value.serverUrl),
|
|
76
|
+
username: boundedSecret(value.username, 256),
|
|
77
|
+
password: boundedSecret(value.password),
|
|
78
|
+
clientId
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const defaultResolver = (hostname) => dns.lookup(hostname, { all: true });
|
|
82
|
+
const blocked4 = new BlockList();
|
|
83
|
+
const blocked6 = new BlockList();
|
|
84
|
+
for (const [network, prefix] of [
|
|
85
|
+
["0.0.0.0", 8],
|
|
86
|
+
["10.0.0.0", 8],
|
|
87
|
+
["100.64.0.0", 10],
|
|
88
|
+
["127.0.0.0", 8],
|
|
89
|
+
["169.254.0.0", 16],
|
|
90
|
+
["172.16.0.0", 12],
|
|
91
|
+
["192.0.0.0", 24],
|
|
92
|
+
["192.0.2.0", 24],
|
|
93
|
+
["192.168.0.0", 16],
|
|
94
|
+
["198.18.0.0", 15],
|
|
95
|
+
["198.51.100.0", 24],
|
|
96
|
+
["203.0.113.0", 24],
|
|
97
|
+
["224.0.0.0", 4],
|
|
98
|
+
["240.0.0.0", 4]
|
|
99
|
+
]) blocked4.addSubnet(network, prefix, "ipv4");
|
|
100
|
+
for (const [network, prefix] of [
|
|
101
|
+
["::", 96],
|
|
102
|
+
["::ffff:0:0", 96],
|
|
103
|
+
["64:ff9b::", 96],
|
|
104
|
+
["64:ff9b:1::", 48],
|
|
105
|
+
["100::", 64],
|
|
106
|
+
["2001::", 32],
|
|
107
|
+
["2001:db8::", 32],
|
|
108
|
+
["2002::", 16],
|
|
109
|
+
["fc00::", 7],
|
|
110
|
+
["fe80::", 10],
|
|
111
|
+
["ff00::", 8]
|
|
112
|
+
]) blocked6.addSubnet(network, prefix, "ipv6");
|
|
113
|
+
/** Each credentialed request resolves afresh; its socket uses this exact address. */
|
|
114
|
+
async function resolvePublicAddress(url, resolve) {
|
|
115
|
+
let addresses;
|
|
116
|
+
try {
|
|
117
|
+
addresses = await resolve(url.hostname);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new AutomateError("unsafe_server");
|
|
120
|
+
}
|
|
121
|
+
if (addresses.length === 0 || addresses.length > 64 || addresses.some(({ address, family }) => ![4, 6].includes(family) || isIP(address) !== family || (family === 4 ? blocked4.check(address, "ipv4") : blocked6.check(address, "ipv6")))) throw new AutomateError("unsafe_server");
|
|
122
|
+
return addresses[0];
|
|
123
|
+
}
|
|
124
|
+
/** TLS validates the original hostname while lookup pins the checked public IP. */
|
|
125
|
+
const httpsTransport = ({ url, address, method, headers, body, signal }) => new Promise((resolve, reject) => {
|
|
126
|
+
const request = https.request(url, {
|
|
127
|
+
method,
|
|
128
|
+
headers,
|
|
129
|
+
agent: false,
|
|
130
|
+
family: address.family,
|
|
131
|
+
lookup: (_hostname, _options, callback) => {
|
|
132
|
+
callback(null, address.address, address.family);
|
|
133
|
+
},
|
|
134
|
+
signal
|
|
135
|
+
}, (response) => {
|
|
136
|
+
const status = response.statusCode ?? 0;
|
|
137
|
+
if (status < 200 || status >= 300) {
|
|
138
|
+
response.destroy();
|
|
139
|
+
resolve({
|
|
140
|
+
status,
|
|
141
|
+
body: ""
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const declared = response.headers["content-length"];
|
|
146
|
+
if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > 2097152)) {
|
|
147
|
+
response.destroy();
|
|
148
|
+
reject(new AutomateError("response_too_large"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let bytes = 0;
|
|
153
|
+
response.on("data", (chunk) => {
|
|
154
|
+
bytes += chunk.length;
|
|
155
|
+
if (bytes > 2097152) {
|
|
156
|
+
response.destroy();
|
|
157
|
+
reject(new AutomateError("response_too_large"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
});
|
|
162
|
+
response.on("error", () => {
|
|
163
|
+
reject(new AutomateError("request_failed"));
|
|
164
|
+
});
|
|
165
|
+
response.on("end", () => {
|
|
166
|
+
resolve({
|
|
167
|
+
status,
|
|
168
|
+
body: Buffer.concat(chunks).toString("utf8")
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
request.on("error", () => {
|
|
173
|
+
reject(new AutomateError(signal.aborted ? "timeout" : "request_failed"));
|
|
174
|
+
});
|
|
175
|
+
request.end(body);
|
|
176
|
+
});
|
|
177
|
+
async function withDeadline(operation, timeoutMs) {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
let timer;
|
|
180
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
181
|
+
timer = setTimeout(() => {
|
|
182
|
+
controller.abort();
|
|
183
|
+
reject(new AutomateError("timeout"));
|
|
184
|
+
}, timeoutMs);
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
return await Promise.race([operation(controller.signal), deadline]);
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** JSON payloads may echo credentials even on success. Never forward those fields. */
|
|
193
|
+
function redactResult(value, secrets) {
|
|
194
|
+
let nodes = 0;
|
|
195
|
+
const redactText = (text) => secrets.reduce((result, secret) => secret.length > 0 ? result.split(secret).join("[redacted]") : result, text);
|
|
196
|
+
const visit = (item, depth) => {
|
|
197
|
+
if (++nodes > 3e4 || depth > 30) throw new AutomateError("response_too_large");
|
|
198
|
+
if (typeof item === "string") return redactText(item);
|
|
199
|
+
if (Array.isArray(item)) return item.map((child) => visit(child, depth + 1));
|
|
200
|
+
if (isRecord(item)) return Object.fromEntries(Object.entries(item).filter(([key]) => !/password|secret|token|authorization|cookie|^__proto__$|^constructor$|^prototype$/iu.test(key)).map(([key, child]) => [redactText(key), visit(child, depth + 1)]));
|
|
201
|
+
if (item === null || typeof item === "boolean" || typeof item === "number" && Number.isFinite(item)) return item;
|
|
202
|
+
throw new AutomateError("invalid_response");
|
|
203
|
+
};
|
|
204
|
+
const result = visit(value, 0);
|
|
205
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 524288) throw new AutomateError("response_too_large");
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/client.ts
|
|
210
|
+
const LIST_RESOURCES = [
|
|
211
|
+
"clients",
|
|
212
|
+
"locations",
|
|
213
|
+
"computers",
|
|
214
|
+
"scripts",
|
|
215
|
+
"monitor_history",
|
|
216
|
+
"internal_monitor_results"
|
|
217
|
+
];
|
|
218
|
+
const listPaths = {
|
|
219
|
+
clients: "Clients",
|
|
220
|
+
locations: "Locations",
|
|
221
|
+
computers: "Computers",
|
|
222
|
+
scripts: "Scripts",
|
|
223
|
+
monitor_history: "MonitorHistory",
|
|
224
|
+
internal_monitor_results: "InternalMonitorResults"
|
|
225
|
+
};
|
|
226
|
+
function parseExpiry(value) {
|
|
227
|
+
if (typeof value !== "string" || value.length > 64) throw new AutomateError("invalid_response");
|
|
228
|
+
if (!/^\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) throw new AutomateError("invalid_response");
|
|
229
|
+
const parsed = Date.parse(value);
|
|
230
|
+
if (!Number.isFinite(parsed)) throw new AutomateError("invalid_response");
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
function buildListQuery(options) {
|
|
234
|
+
const page = options.page ?? 1;
|
|
235
|
+
const pageSize = options.pageSize ?? 50;
|
|
236
|
+
if (!Number.isInteger(page) || page < 1 || page > 1e5 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) throw new AutomateError("invalid_arguments");
|
|
237
|
+
const query = new URLSearchParams({
|
|
238
|
+
"options.page": String(page),
|
|
239
|
+
"options.pageSize": String(pageSize)
|
|
240
|
+
});
|
|
241
|
+
if (options.condition !== void 0) {
|
|
242
|
+
if (typeof options.condition !== "string" || !options.condition.trim() || options.condition.length > 2e3 || hasControlCharacters(options.condition)) throw new AutomateError("invalid_arguments");
|
|
243
|
+
query.set("options.condition", options.condition);
|
|
244
|
+
}
|
|
245
|
+
return query;
|
|
246
|
+
}
|
|
247
|
+
var AutomateClient = class {
|
|
248
|
+
credentials;
|
|
249
|
+
resolve;
|
|
250
|
+
transport;
|
|
251
|
+
now;
|
|
252
|
+
timeoutMs;
|
|
253
|
+
token;
|
|
254
|
+
tokenRequest;
|
|
255
|
+
constructor(credentials, options = {}) {
|
|
256
|
+
this.credentials = {
|
|
257
|
+
serverUrl: serverOrigin(credentials.serverUrl),
|
|
258
|
+
username: boundedSecret(credentials.username, 256),
|
|
259
|
+
password: boundedSecret(credentials.password),
|
|
260
|
+
clientId: boundedSecret(credentials.clientId, 256)
|
|
261
|
+
};
|
|
262
|
+
if (this.credentials.clientId.toLowerCase() === "placeholder") throw new AutomateError("invalid_credentials");
|
|
263
|
+
this.resolve = options.resolve ?? defaultResolver;
|
|
264
|
+
this.transport = options.transport ?? httpsTransport;
|
|
265
|
+
this.now = options.now ?? Date.now;
|
|
266
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
267
|
+
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 6e4) throw new AutomateError("invalid_arguments");
|
|
268
|
+
}
|
|
269
|
+
async list(resource, options = {}) {
|
|
270
|
+
if (!LIST_RESOURCES.includes(resource)) throw new AutomateError("invalid_arguments");
|
|
271
|
+
return this.readList(listPaths[resource], options);
|
|
272
|
+
}
|
|
273
|
+
async listComputer(resource, computerId, options = {}) {
|
|
274
|
+
if (!Number.isInteger(computerId) || computerId < 1 || computerId > 2147483647 || !["monitors", "script_history"].includes(resource)) throw new AutomateError("invalid_arguments");
|
|
275
|
+
const suffix = resource === "monitors" ? "Monitors" : "ScriptHistory";
|
|
276
|
+
return this.readList(`Computers/${String(computerId)}/${suffix}`, options);
|
|
277
|
+
}
|
|
278
|
+
async checkConnection() {
|
|
279
|
+
await this.list("clients", {
|
|
280
|
+
page: 1,
|
|
281
|
+
pageSize: 1
|
|
282
|
+
});
|
|
283
|
+
return { connected: true };
|
|
284
|
+
}
|
|
285
|
+
async readList(path, options) {
|
|
286
|
+
const query = buildListQuery(options);
|
|
287
|
+
let token = await this.getToken();
|
|
288
|
+
let data;
|
|
289
|
+
try {
|
|
290
|
+
data = await this.request("GET", path, token.accessToken, void 0, query);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!(error instanceof AutomateError) || error.code !== "authentication_failed") throw error;
|
|
293
|
+
token = await this.getToken(token.accessToken);
|
|
294
|
+
data = await this.request("GET", path, token.accessToken, void 0, query);
|
|
295
|
+
}
|
|
296
|
+
if (!Array.isArray(data)) throw new AutomateError("invalid_response");
|
|
297
|
+
const { password, username, clientId } = this.credentials;
|
|
298
|
+
return redactResult({
|
|
299
|
+
items: data,
|
|
300
|
+
page: options.page ?? 1,
|
|
301
|
+
pageSize: options.pageSize ?? 50
|
|
302
|
+
}, [
|
|
303
|
+
password,
|
|
304
|
+
username,
|
|
305
|
+
clientId,
|
|
306
|
+
token.accessToken
|
|
307
|
+
]);
|
|
308
|
+
}
|
|
309
|
+
getToken(rejectedToken) {
|
|
310
|
+
if (rejectedToken && this.token?.accessToken === rejectedToken) this.token = void 0;
|
|
311
|
+
if (this.token && this.token.expiresAt > this.now() + 6e4 && this.token.absoluteExpiresAt > this.now() + 6e4) return Promise.resolve(this.token);
|
|
312
|
+
if (this.tokenRequest) return this.tokenRequest;
|
|
313
|
+
const previous = this.token;
|
|
314
|
+
this.tokenRequest = this.acquireToken(previous).then((token) => {
|
|
315
|
+
this.token = token;
|
|
316
|
+
return token;
|
|
317
|
+
}).finally(() => {
|
|
318
|
+
this.tokenRequest = void 0;
|
|
319
|
+
});
|
|
320
|
+
return this.tokenRequest;
|
|
321
|
+
}
|
|
322
|
+
async acquireToken(previous) {
|
|
323
|
+
let value;
|
|
324
|
+
if (previous && previous.expiresAt > this.now() && previous.absoluteExpiresAt > this.now() + 6e4) try {
|
|
325
|
+
value = await this.request("POST", "APIToken/Refresh", previous.accessToken, JSON.stringify(previous.accessToken));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!(error instanceof AutomateError) || error.code !== "authentication_failed") throw error;
|
|
328
|
+
value = await this.mintToken();
|
|
329
|
+
}
|
|
330
|
+
else value = await this.mintToken();
|
|
331
|
+
if (!isRecord(value)) throw new AutomateError("invalid_response");
|
|
332
|
+
if (value.IsTwoFactorRequired === true || value.IsInternalTwoFactorRequired === true) throw new AutomateError("interactive_authentication");
|
|
333
|
+
if (typeof value.AccessToken !== "string" || !value.AccessToken || value.AccessToken.length > 16384 || /\s/u.test(value.AccessToken)) throw new AutomateError("invalid_response");
|
|
334
|
+
if (value.TokenType !== void 0 && value.TokenType !== "Bearer" && value.TokenType !== "bearer") throw new AutomateError("invalid_response");
|
|
335
|
+
const expiresAt = parseExpiry(value.ExpirationDate);
|
|
336
|
+
const absoluteExpiresAt = value.AbsoluteExpirationDate === void 0 ? expiresAt : parseExpiry(value.AbsoluteExpirationDate);
|
|
337
|
+
if (expiresAt <= this.now() || absoluteExpiresAt <= this.now()) throw new AutomateError("authentication_failed");
|
|
338
|
+
return {
|
|
339
|
+
accessToken: value.AccessToken,
|
|
340
|
+
expiresAt,
|
|
341
|
+
absoluteExpiresAt
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
mintToken() {
|
|
345
|
+
return this.request("POST", "APIToken", void 0, JSON.stringify({
|
|
346
|
+
Username: this.credentials.username,
|
|
347
|
+
Password: this.credentials.password
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
request(method, path, token, body, query) {
|
|
351
|
+
return withDeadline(async (signal) => {
|
|
352
|
+
const url = new URL(`/cwa/api/v1/${path}`, this.credentials.serverUrl);
|
|
353
|
+
if (query) url.search = query.toString();
|
|
354
|
+
const address = await resolvePublicAddress(url, this.resolve);
|
|
355
|
+
if (signal.aborted) throw new AutomateError("timeout");
|
|
356
|
+
let response;
|
|
357
|
+
try {
|
|
358
|
+
response = await this.transport({
|
|
359
|
+
url,
|
|
360
|
+
address,
|
|
361
|
+
method,
|
|
362
|
+
signal,
|
|
363
|
+
body,
|
|
364
|
+
headers: {
|
|
365
|
+
Accept: "application/json",
|
|
366
|
+
clientId: this.credentials.clientId,
|
|
367
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {},
|
|
368
|
+
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw error instanceof AutomateError ? error : new AutomateError("request_failed");
|
|
373
|
+
}
|
|
374
|
+
if (response.status === 401) throw new AutomateError("authentication_failed");
|
|
375
|
+
if (response.status === 403) throw new AutomateError("permission_denied");
|
|
376
|
+
if (response.status === 404) throw new AutomateError("not_found");
|
|
377
|
+
if (response.status === 429) throw new AutomateError("rate_limited");
|
|
378
|
+
if (response.status < 200 || response.status >= 300) throw new AutomateError("request_failed");
|
|
379
|
+
if (Buffer.byteLength(response.body) > 2097152) throw new AutomateError("response_too_large");
|
|
380
|
+
try {
|
|
381
|
+
return JSON.parse(response.body);
|
|
382
|
+
} catch {
|
|
383
|
+
throw new AutomateError("invalid_response");
|
|
384
|
+
}
|
|
385
|
+
}, this.timeoutMs);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/server.ts
|
|
390
|
+
const metadata = createRequire(import.meta.url)("../package.json");
|
|
391
|
+
const SERVER_NAME = "connectwise-automate-mcp";
|
|
392
|
+
const SERVER_VERSION = metadata.version;
|
|
393
|
+
var AutomateRuntime = class {
|
|
394
|
+
clients = /* @__PURE__ */ new Map();
|
|
395
|
+
constructor(source, options = {}) {
|
|
396
|
+
this.source = source;
|
|
397
|
+
this.options = options;
|
|
398
|
+
}
|
|
399
|
+
async listConnections() {
|
|
400
|
+
const result = await withDeadline(() => this.source.getConnectProviderAccounts(PROVIDER_ID), 3e4);
|
|
401
|
+
if (!isRecord(result) || result.provider !== "connectwise-automate" || !Array.isArray(result.accounts) || result.accounts.length > 100) throw new AutomateError("invalid_response");
|
|
402
|
+
const seen = /* @__PURE__ */ new Set();
|
|
403
|
+
return { connections: result.accounts.map((row) => {
|
|
404
|
+
if (!isRecord(row)) throw new AutomateError("invalid_response");
|
|
405
|
+
const connectionId = validateConnectionId(row.connectionId);
|
|
406
|
+
if (seen.has(connectionId)) throw new AutomateError("invalid_response");
|
|
407
|
+
seen.add(connectionId);
|
|
408
|
+
const serverUrl = serverOrigin(row.serverUrl);
|
|
409
|
+
const displayName = typeof row.displayName === "string" && row.displayName.length <= 256 ? Array.from(row.displayName, (character) => hasControlCharacters(character) ? " " : character).join("") : null;
|
|
410
|
+
const secrets = [
|
|
411
|
+
row.username,
|
|
412
|
+
row.password,
|
|
413
|
+
row.clientId,
|
|
414
|
+
row.accessToken
|
|
415
|
+
].filter((value) => typeof value === "string");
|
|
416
|
+
return redactResult({
|
|
417
|
+
connectionId,
|
|
418
|
+
serverUrl,
|
|
419
|
+
displayName
|
|
420
|
+
}, secrets);
|
|
421
|
+
}) };
|
|
422
|
+
}
|
|
423
|
+
async getClient(selector) {
|
|
424
|
+
const connectionId = validateConnectionId(selector);
|
|
425
|
+
try {
|
|
426
|
+
const credentials = credentialsFrom(await withDeadline(() => this.source.getConnectionCredentials(connectionId), 3e4), connectionId);
|
|
427
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(credentials)).digest("hex");
|
|
428
|
+
const cached = this.clients.get(connectionId);
|
|
429
|
+
if (cached?.fingerprint === fingerprint) return cached.client;
|
|
430
|
+
if (this.clients.size >= 100 && !cached) {
|
|
431
|
+
const first = this.clients.keys().next();
|
|
432
|
+
if (!first.done) this.clients.delete(first.value);
|
|
433
|
+
}
|
|
434
|
+
const client = new AutomateClient(credentials, this.options);
|
|
435
|
+
this.clients.set(connectionId, {
|
|
436
|
+
fingerprint,
|
|
437
|
+
client
|
|
438
|
+
});
|
|
439
|
+
return client;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.clients.delete(connectionId);
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
clear() {
|
|
446
|
+
this.clients.clear();
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const connectionField = z.string().regex(/^con_[A-Za-z0-9_-]{1,180}$/u).describe("Exact connectionId from automate_list_connections; select the intended Automate server explicitly.");
|
|
450
|
+
const listFields = {
|
|
451
|
+
connectionId: connectionField,
|
|
452
|
+
page: z.coerce.number().int().min(1).max(1e5).optional().describe("1-based page number."),
|
|
453
|
+
pageSize: z.coerce.number().int().min(1).max(200).optional().describe("Records per page, defaults to 50."),
|
|
454
|
+
condition: z.string().trim().min(1).max(2e3).refine((value) => !hasControlCharacters(value)).optional().describe("Automate API condition expression using documented field names, for example Id = 42.")
|
|
455
|
+
};
|
|
456
|
+
const annotations = {
|
|
457
|
+
readOnlyHint: true,
|
|
458
|
+
destructiveHint: false,
|
|
459
|
+
idempotentHint: true,
|
|
460
|
+
openWorldHint: true
|
|
461
|
+
};
|
|
462
|
+
async function execute(tool, operation, emit) {
|
|
463
|
+
try {
|
|
464
|
+
return { content: [{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: JSON.stringify(await operation())
|
|
467
|
+
}] };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
const safe = safeError(error);
|
|
470
|
+
try {
|
|
471
|
+
emit(`[ERROR] alfe-tool plugin=connectwise-automate-mcp tool=${tool} result-error: ${safe.code}`);
|
|
472
|
+
} catch {}
|
|
473
|
+
return {
|
|
474
|
+
content: [{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: JSON.stringify({
|
|
477
|
+
error: safe.code,
|
|
478
|
+
message: safe.message
|
|
479
|
+
})
|
|
480
|
+
}],
|
|
481
|
+
isError: true
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function createServer(source, options = {}) {
|
|
486
|
+
const server = new McpServer({
|
|
487
|
+
name: SERVER_NAME,
|
|
488
|
+
version: SERVER_VERSION
|
|
489
|
+
});
|
|
490
|
+
const runtime = new AutomateRuntime(source, options);
|
|
491
|
+
const emit = options.emit ?? ((line) => {
|
|
492
|
+
process.stderr.write(`${line}\n`);
|
|
493
|
+
});
|
|
494
|
+
server.registerTool("automate_list_connections", {
|
|
495
|
+
description: "List the Automate servers available to this agent. Use the exact connectionId on every other tool.",
|
|
496
|
+
inputSchema: {},
|
|
497
|
+
annotations
|
|
498
|
+
}, () => execute("automate_list_connections", () => runtime.listConnections(), emit));
|
|
499
|
+
server.registerTool("automate_check_connection", {
|
|
500
|
+
description: "Check the selected Automate connection by authenticating and reading one client. Returns no token or customer data.",
|
|
501
|
+
inputSchema: { connectionId: connectionField },
|
|
502
|
+
annotations
|
|
503
|
+
}, ({ connectionId }) => execute("automate_check_connection", async () => (await runtime.getClient(connectionId)).checkConnection(), emit));
|
|
504
|
+
const descriptions = {
|
|
505
|
+
clients: "List client companies visible to this Automate account.",
|
|
506
|
+
locations: "List Automate locations; narrow using an Automate condition expression.",
|
|
507
|
+
computers: "List Automate endpoint inventory and current status; narrow or paginate large fleets.",
|
|
508
|
+
scripts: "List available Automate script metadata. This does not execute a script.",
|
|
509
|
+
monitor_history: "Read Automate monitor history, using conditions and pages to narrow the time window.",
|
|
510
|
+
internal_monitor_results: "Read Automate internal monitor results for diagnosing fleet issues."
|
|
511
|
+
};
|
|
512
|
+
for (const resource of LIST_RESOURCES) {
|
|
513
|
+
const name = `automate_list_${resource}`;
|
|
514
|
+
server.registerTool(name, {
|
|
515
|
+
description: descriptions[resource],
|
|
516
|
+
inputSchema: listFields,
|
|
517
|
+
annotations
|
|
518
|
+
}, ({ connectionId, ...args }) => execute(name, async () => (await runtime.getClient(connectionId)).list(resource, args), emit));
|
|
519
|
+
}
|
|
520
|
+
for (const resource of ["monitors", "script_history"]) {
|
|
521
|
+
const name = `automate_list_computer_${resource}`;
|
|
522
|
+
server.registerTool(name, {
|
|
523
|
+
description: resource === "monitors" ? "Read the monitors attached to one Automate computer." : "Read script execution history for one Automate computer. Does not run scripts.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
...listFields,
|
|
526
|
+
computerId: z.coerce.number().int().positive().max(2147483647)
|
|
527
|
+
},
|
|
528
|
+
annotations
|
|
529
|
+
}, ({ connectionId, computerId, ...args }) => execute(name, async () => (await runtime.getClient(connectionId)).listComputer(resource, computerId, args), emit));
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
server,
|
|
533
|
+
runtime
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
async function startServer(source, transport = new StdioServerTransport()) {
|
|
537
|
+
const { server, runtime } = createServer(source ?? new AgentApiClient(resolveConfig()));
|
|
538
|
+
let closing;
|
|
539
|
+
const close = () => {
|
|
540
|
+
closing ??= withDeadline(() => server.close(), 5e3).finally(() => {
|
|
541
|
+
runtime.clear();
|
|
542
|
+
});
|
|
543
|
+
return closing;
|
|
544
|
+
};
|
|
545
|
+
try {
|
|
546
|
+
await server.connect(transport);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
await close().catch(() => void 0);
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
server,
|
|
553
|
+
close
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
export { startServer as a, createServer as i, SERVER_NAME as n, AutomateClient as o, SERVER_VERSION as r, safeError as s, AutomateRuntime as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/connectwise-automate-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ConnectWise Automate inventory and monitoring MCP server with Alfe connections",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
|
+
"types": "./dist/server.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"connectwise-automate-mcp": "./dist/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/server.d.ts",
|
|
14
|
+
"import": "./dist/server.js",
|
|
15
|
+
"require": "./dist/server.cjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
24
|
+
"zod": "^4.0.5",
|
|
25
|
+
"@alfe.ai/agent-api-client": "0.19.0",
|
|
26
|
+
"@alfe.ai/config": "0.4.1"
|
|
27
|
+
},
|
|
28
|
+
"license": "UNLICENSED",
|
|
29
|
+
"homepage": "https://alfe.ai",
|
|
30
|
+
"author": "Alfe (https://alfe.ai)",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"lint": "eslint ."
|
|
36
|
+
}
|
|
37
|
+
}
|