@owine/unifi-protect-mcp 2.14.2 → 3.0.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 +248 -92
- package/dist/client.d.ts +43 -3
- package/dist/client.js +255 -85
- package/dist/config.d.ts +7 -1
- package/dist/config.js +114 -6
- package/dist/index.js +14 -6
- package/dist/instructions.d.ts +6 -0
- package/dist/instructions.js +24 -0
- package/dist/schemas/cameras.d.ts +32 -30
- package/dist/schemas/cameras.js +7 -6
- package/dist/schemas/common.d.ts +31 -6
- package/dist/schemas/common.js +45 -6
- package/dist/schemas/devices.d.ts +2 -10
- package/dist/schemas/devices.js +35 -26
- package/dist/schemas/misc.d.ts +50 -18
- package/dist/schemas/misc.js +23 -15
- package/dist/schemas/pos.d.ts +1 -1
- package/dist/schemas/pos.js +1 -1
- package/dist/tls.d.ts +41 -0
- package/dist/tls.js +75 -0
- package/dist/tools/alarm-manager.d.ts +3 -0
- package/dist/tools/alarm-manager.js +32 -0
- package/dist/tools/arm-profiles.d.ts +8 -2
- package/dist/tools/arm-profiles.js +98 -95
- package/dist/tools/cameras.d.ts +2 -2
- package/dist/tools/cameras.js +156 -181
- package/dist/tools/device-actions.d.ts +2 -2
- package/dist/tools/device-actions.js +84 -97
- package/dist/tools/devices.d.ts +6 -3
- package/dist/tools/devices.js +50 -51
- package/dist/tools/files.d.ts +2 -2
- package/dist/tools/files.js +82 -57
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.js +15 -10
- package/dist/tools/liveviews.d.ts +2 -2
- package/dist/tools/liveviews.js +52 -52
- package/dist/tools/pos.d.ts +6 -4
- package/dist/tools/pos.js +15 -20
- package/dist/tools/registrar.d.ts +34 -0
- package/dist/tools/registrar.js +103 -0
- package/dist/tools/subscriptions.d.ts +13 -2
- package/dist/tools/subscriptions.js +148 -27
- package/dist/tools/system.d.ts +2 -2
- package/dist/tools/system.js +5 -15
- package/dist/tools/users.d.ts +2 -2
- package/dist/tools/users.js +18 -35
- package/dist/utils/limits.d.ts +33 -0
- package/dist/utils/limits.js +74 -0
- package/dist/utils/paging.d.ts +15 -0
- package/dist/utils/paging.js +28 -0
- package/dist/utils/params.d.ts +13 -0
- package/dist/utils/params.js +40 -0
- package/dist/utils/responses.js +2 -3
- package/dist/utils/safety.d.ts +39 -3
- package/dist/utils/safety.js +45 -3
- package/dist/utils/url.d.ts +4 -1
- package/dist/utils/url.js +4 -1
- package/dist/version.d.ts +6 -0
- package/dist/version.js +11 -0
- package/package.json +14 -12
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SUBSCRIPTION_TEXT_CHARS = exports.MAX_SUBSCRIPTION_MESSAGES = void 0;
|
|
3
4
|
exports.registerSubscriptionTools = registerSubscriptionTools;
|
|
4
5
|
const zod_1 = require("zod");
|
|
6
|
+
const client_js_1 = require("../client.js");
|
|
5
7
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
8
|
const safety_js_1 = require("../utils/safety.js");
|
|
7
9
|
const misc_js_1 = require("../schemas/misc.js");
|
|
@@ -11,64 +13,183 @@ const durationSchema = zod_1.z
|
|
|
11
13
|
.max(30)
|
|
12
14
|
.default(5)
|
|
13
15
|
.describe("Seconds to listen (1-30, default 5)");
|
|
16
|
+
/**
|
|
17
|
+
* Each device message carries a full device object, so a busy site can emit
|
|
18
|
+
* enough in 30s to flood the MCP client's context. Stop listening at the cap.
|
|
19
|
+
*/
|
|
20
|
+
exports.MAX_SUBSCRIPTION_MESSAGES = 200;
|
|
21
|
+
/**
|
|
22
|
+
* The most JSON text one listen returns, about 25k tokens: the scale at which
|
|
23
|
+
* MCP hosts commonly cap or warn about a tool result. The count cap alone
|
|
24
|
+
* allowed results several times that size.
|
|
25
|
+
*/
|
|
26
|
+
exports.MAX_SUBSCRIPTION_TEXT_CHARS = 100_000;
|
|
27
|
+
/**
|
|
28
|
+
* The most bytes a UTF-16 code unit takes in UTF-8 (a surrogate pair is four
|
|
29
|
+
* bytes for two units). A frame larger than the budget times this could never
|
|
30
|
+
* be returned, so it is not accepted; a smaller one is measured as parsed.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_UTF8_BYTES_PER_CHAR = 3;
|
|
33
|
+
const MAX_FRAME_BYTES = exports.MAX_SUBSCRIPTION_TEXT_CHARS * MAX_UTF8_BYTES_PER_CHAR;
|
|
34
|
+
/** ws's error code for a frame over maxPayload. */
|
|
35
|
+
const FRAME_TOO_LARGE = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH";
|
|
36
|
+
/** The text a message that cannot fit the budget is reported with. */
|
|
37
|
+
const TOO_LARGE = `a message larger than the ${exports.MAX_SUBSCRIPTION_TEXT_CHARS.toLocaleString("en-US")}-character limit for one listen arrived, and cannot be returned`;
|
|
38
|
+
/**
|
|
39
|
+
* How long a closing socket may wait for the peer's close frame before it is
|
|
40
|
+
* dropped. ws itself waits 30 s, which on a half-open connection held a 30 s
|
|
41
|
+
* listen past the MCP client's default request timeout.
|
|
42
|
+
*/
|
|
43
|
+
const CLOSE_GRACE_MS = 1_000;
|
|
44
|
+
const MS_PER_SECOND = 1000;
|
|
45
|
+
/** The reported listen time is rounded to this many decimal places (0.1s). */
|
|
46
|
+
const ELAPSED_DECIMALS = 1;
|
|
14
47
|
function collectMessages(client, path, duration) {
|
|
15
48
|
return new Promise((resolve) => {
|
|
16
49
|
const messages = [];
|
|
17
50
|
let error;
|
|
18
|
-
|
|
19
|
-
|
|
51
|
+
let truncated = false;
|
|
52
|
+
let opened = false;
|
|
53
|
+
let finishedAt;
|
|
54
|
+
// Start with the result's own envelope ({"messages":[],"duration":…}).
|
|
55
|
+
let textChars = JSON.stringify({ messages: [], duration, truncated: true }).length;
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
const ws = client.connectWebSocket(path, { maxPayload: MAX_FRAME_BYTES });
|
|
58
|
+
let grace;
|
|
59
|
+
/**
|
|
60
|
+
* Stop collecting now and close the socket. Messages that arrive after
|
|
61
|
+
* this are dropped, and a peer that never answers the close frame is cut
|
|
62
|
+
* off after CLOSE_GRACE_MS.
|
|
63
|
+
*/
|
|
64
|
+
const finish = () => {
|
|
65
|
+
if (finishedAt !== undefined)
|
|
66
|
+
return;
|
|
67
|
+
finishedAt = Date.now();
|
|
68
|
+
clearTimeout(window);
|
|
69
|
+
// Armed before close(): a socket may report "close" synchronously, and
|
|
70
|
+
// the close handler must find the timer to clear it.
|
|
71
|
+
grace = setTimeout(() => { ws.terminate(); }, CLOSE_GRACE_MS);
|
|
20
72
|
ws.close();
|
|
21
|
-
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A message too large for the budget ends the listen as truncated. When
|
|
76
|
+
* nothing was collected before it, say why the result is empty.
|
|
77
|
+
*/
|
|
78
|
+
const tooLarge = () => {
|
|
79
|
+
truncated = true;
|
|
80
|
+
if (messages.length === 0)
|
|
81
|
+
error = TOO_LARGE;
|
|
82
|
+
finish();
|
|
83
|
+
};
|
|
84
|
+
// A window that ends before the socket opened listened to nothing, which
|
|
85
|
+
// an empty result would report as a quiet site.
|
|
86
|
+
const window = setTimeout(() => {
|
|
87
|
+
if (!opened)
|
|
88
|
+
error = `the connection was not established within ${duration.toString()} s; try a longer duration`;
|
|
89
|
+
finish();
|
|
90
|
+
}, duration * MS_PER_SECOND);
|
|
91
|
+
// A cancelled call gets no result, so drop the socket at once rather than
|
|
92
|
+
// holding it open for the rest of the listen window.
|
|
93
|
+
const signal = (0, client_js_1.currentCallSignal)();
|
|
94
|
+
const onAbort = () => {
|
|
95
|
+
finish();
|
|
96
|
+
ws.terminate();
|
|
97
|
+
};
|
|
98
|
+
ws.on("open", () => {
|
|
99
|
+
opened = true;
|
|
100
|
+
});
|
|
22
101
|
ws.on("message", (data) => {
|
|
102
|
+
if (finishedAt !== undefined)
|
|
103
|
+
return;
|
|
23
104
|
const text = String(data);
|
|
105
|
+
let message;
|
|
24
106
|
try {
|
|
25
|
-
|
|
107
|
+
message = JSON.parse(text);
|
|
26
108
|
}
|
|
27
109
|
catch {
|
|
28
|
-
|
|
110
|
+
message = text;
|
|
111
|
+
}
|
|
112
|
+
// The comma between array elements counts too.
|
|
113
|
+
const chars = JSON.stringify(message).length + 1;
|
|
114
|
+
if (textChars + chars > exports.MAX_SUBSCRIPTION_TEXT_CHARS) {
|
|
115
|
+
tooLarge();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
textChars += chars;
|
|
119
|
+
messages.push(message);
|
|
120
|
+
if (messages.length >= exports.MAX_SUBSCRIPTION_MESSAGES) {
|
|
121
|
+
truncated = true;
|
|
122
|
+
finish();
|
|
29
123
|
}
|
|
30
124
|
});
|
|
31
125
|
ws.on("error", (err) => {
|
|
32
|
-
error
|
|
33
|
-
|
|
34
|
-
|
|
126
|
+
// After finish(), an error is the close it started: closing a socket
|
|
127
|
+
// that is still connecting aborts the handshake with an error.
|
|
128
|
+
if (finishedAt !== undefined)
|
|
129
|
+
return;
|
|
130
|
+
if (err.code === FRAME_TOO_LARGE) {
|
|
131
|
+
tooLarge();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
error = (0, client_js_1.errorReason)(err);
|
|
135
|
+
finish();
|
|
35
136
|
});
|
|
36
|
-
ws.on("close", () => {
|
|
37
|
-
|
|
38
|
-
|
|
137
|
+
ws.on("close", (code, reason) => {
|
|
138
|
+
// A close this listen did not start (the console refusing the
|
|
139
|
+
// subscription, or a dropped connection) ended it early.
|
|
140
|
+
if (finishedAt === undefined && error === undefined) {
|
|
141
|
+
const why = reason.length > 0 ? `: ${reason.toString("utf8")}` : "";
|
|
142
|
+
error = `the console closed the connection (code ${code.toString()}${why})`;
|
|
143
|
+
}
|
|
144
|
+
clearTimeout(window);
|
|
145
|
+
clearTimeout(grace);
|
|
146
|
+
signal?.removeEventListener("abort", onAbort);
|
|
147
|
+
// Time actually spent listening: the cap, an error or a cancel can end
|
|
148
|
+
// the listen early, and the close handshake is not part of it.
|
|
149
|
+
const elapsed = Number((((finishedAt ?? Date.now()) - startedAt) / MS_PER_SECOND).toFixed(ELAPSED_DECIMALS));
|
|
150
|
+
const result = { messages, duration: elapsed };
|
|
39
151
|
if (error)
|
|
40
152
|
result.error = error;
|
|
153
|
+
if (truncated)
|
|
154
|
+
result.truncated = true;
|
|
41
155
|
resolve(result);
|
|
42
156
|
});
|
|
157
|
+
// Last, once every listener is attached: a call cancelled before its
|
|
158
|
+
// listen began gets no abort event, and must still see its close.
|
|
159
|
+
if (signal?.aborted)
|
|
160
|
+
onAbort();
|
|
161
|
+
else
|
|
162
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
43
163
|
});
|
|
44
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* A listen that failed before receiving anything is an error, not an empty
|
|
167
|
+
* result: `{messages: []}` would read as "no activity" when the socket was
|
|
168
|
+
* refused or unauthorised. One that failed partway returns what it collected,
|
|
169
|
+
* with the error alongside.
|
|
170
|
+
*/
|
|
171
|
+
function listenResult(data) {
|
|
172
|
+
return data.error !== undefined && data.messages.length === 0
|
|
173
|
+
? (0, responses_js_1.formatError)(new Error(`Subscription failed: ${data.error}`))
|
|
174
|
+
: (0, responses_js_1.formatSuccess)(data);
|
|
175
|
+
}
|
|
45
176
|
function registerSubscriptionTools(server, client) {
|
|
46
177
|
server.registerTool("protect_subscribe_devices", {
|
|
47
|
-
description:
|
|
178
|
+
description: `Connect to the device update WebSocket and collect messages for a specified duration (1-30s). Returns {messages, duration, error?, truncated?}; stops early with truncated=true after ${exports.MAX_SUBSCRIPTION_MESSAGES.toString()} messages or ${exports.MAX_SUBSCRIPTION_TEXT_CHARS.toLocaleString("en-US")} characters of JSON, whichever comes first. A connection that fails before any message arrives returns an error. Each message is an envelope {type, item} where item is a device resource carrying id and modelKey (camera, light, sensor, nvr, …). The 7.3.70 docs show type 'add' with the full device object as item; other message types and whether updates carry only changed fields are not documented — inspect the captured messages. Use to detect device state changes or newly adopted devices. Not available over Cloud Connector (api.ui.com).`,
|
|
48
179
|
inputSchema: { duration: durationSchema },
|
|
49
180
|
outputSchema: misc_js_1.subscriptionOutputSchema,
|
|
50
181
|
annotations: safety_js_1.READ_ONLY,
|
|
51
182
|
}, async ({ duration }) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
return (0, responses_js_1.formatError)(err);
|
|
58
|
-
}
|
|
183
|
+
const data = await collectMessages(client, "/subscribe/devices", duration);
|
|
184
|
+
return listenResult(data);
|
|
59
185
|
});
|
|
60
186
|
server.registerTool("protect_subscribe_events", {
|
|
61
|
-
description:
|
|
187
|
+
description: `Connect to the Protect event WebSocket and collect messages for a specified duration (1-30s). Returns {messages, duration, error?, truncated?}; stops early with truncated=true after ${exports.MAX_SUBSCRIPTION_MESSAGES.toString()} messages or ${exports.MAX_SUBSCRIPTION_TEXT_CHARS.toLocaleString("en-US")} characters of JSON, whichever comes first. A connection that fails before any message arrives returns an error. Each message is an envelope {type: 'add' | 'update', item: {id, modelKey: 'event', type (event kind, e.g. 'ring'), start, end (epoch ms), device (ID of the camera/sensor that raised it)}} (7.3.70 docs). The full set of event kinds and any further event fields are not documented — inspect the captured messages. Not available over Cloud Connector (api.ui.com).`,
|
|
62
188
|
inputSchema: { duration: durationSchema },
|
|
63
189
|
outputSchema: misc_js_1.subscriptionOutputSchema,
|
|
64
190
|
annotations: safety_js_1.READ_ONLY,
|
|
65
191
|
}, async ({ duration }) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
return (0, responses_js_1.formatError)(err);
|
|
72
|
-
}
|
|
192
|
+
const data = await collectMessages(client, "/subscribe/events", duration);
|
|
193
|
+
return listenResult(data);
|
|
73
194
|
});
|
|
74
195
|
}
|
package/dist/tools/system.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ToolRegistrar } from "./registrar.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerSystemTools(server:
|
|
3
|
+
export declare function registerSystemTools(server: ToolRegistrar, client: ProtectClient): void;
|
package/dist/tools/system.js
CHANGED
|
@@ -10,25 +10,15 @@ function registerSystemTools(server, client) {
|
|
|
10
10
|
outputSchema: misc_js_1.infoOutputSchema,
|
|
11
11
|
annotations: safety_js_1.READ_ONLY,
|
|
12
12
|
}, async () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
16
|
-
}
|
|
17
|
-
catch (err) {
|
|
18
|
-
return (0, responses_js_1.formatError)(err);
|
|
19
|
-
}
|
|
13
|
+
const data = await client.get("/meta/info");
|
|
14
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
20
15
|
});
|
|
21
16
|
server.registerTool("protect_list_nvrs", {
|
|
22
|
-
description: "Get the NVR (Network Video Recorder) console info. NOTE: despite the name, the Protect Integration API's /nvrs endpoint returns a SINGLE NVR object, not an array. Returns: id, modelKey, name, type (e.g. UNVR-PRO), guid, mac, doorbellSettings (defaultMessageText, defaultMessageResetTimeoutMs, customMessages[], customImages[]), armMode (status, armedAt, willBeArmedAt, breachDetectedAt, breachEventCount, breachTriggerEventId, breachEventId).",
|
|
17
|
+
description: "Get the NVR (Network Video Recorder) console info. NOTE: despite the name, the Protect Integration API's /nvrs endpoint returns a SINGLE NVR object, not an array. Returns: id, modelKey, name, type (e.g. UNVR-PRO), guid, mac, doorbellSettings (defaultMessageText, defaultMessageResetTimeoutMs, customMessages[], customImages[] (preview, sprite)), armMode (status, armProfileId, armedAt, willBeArmedAt, breachDetectedAt, breachEventCount, breachTriggerEventId, breachEventId).",
|
|
23
18
|
outputSchema: misc_js_1.nvrOutputSchema,
|
|
24
19
|
annotations: safety_js_1.READ_ONLY,
|
|
25
20
|
}, async () => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
29
|
-
}
|
|
30
|
-
catch (err) {
|
|
31
|
-
return (0, responses_js_1.formatError)(err);
|
|
32
|
-
}
|
|
21
|
+
const data = await client.get("/nvrs");
|
|
22
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
33
23
|
});
|
|
34
24
|
}
|
package/dist/tools/users.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ToolRegistrar } from "./registrar.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerUserTools(server:
|
|
3
|
+
export declare function registerUserTools(server: ToolRegistrar, client: ProtectClient): void;
|
package/dist/tools/users.js
CHANGED
|
@@ -1,64 +1,47 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerUserTools = registerUserTools;
|
|
4
|
-
const zod_1 = require("zod");
|
|
5
4
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
5
|
const safety_js_1 = require("../utils/safety.js");
|
|
7
6
|
const url_js_1 = require("../utils/url.js");
|
|
7
|
+
const paging_js_1 = require("../utils/paging.js");
|
|
8
|
+
const params_js_1 = require("../utils/params.js");
|
|
8
9
|
const misc_js_1 = require("../schemas/misc.js");
|
|
9
10
|
function registerUserTools(server, client) {
|
|
10
11
|
server.registerTool("protect_list_users", {
|
|
11
|
-
description: "List all Protect users (filtered by the API key's access permissions). Returns array; each user includes (Integration API 7.3.
|
|
12
|
+
description: "List all Protect users (filtered by the API key's access permissions). Returns array; each user includes (Integration API 7.3.70-verified): id, modelKey, name, firstName, lastName, email, ucoreUserId. The Integration API does NOT expose roles, permissions, login history, groups, or notification settings." + paging_js_1.PAGING_NOTE,
|
|
13
|
+
inputSchema: paging_js_1.pagingInput,
|
|
12
14
|
outputSchema: misc_js_1.userListOutputSchema,
|
|
13
15
|
annotations: safety_js_1.READ_ONLY,
|
|
14
|
-
}, async () => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
18
|
-
}
|
|
19
|
-
catch (err) {
|
|
20
|
-
return (0, responses_js_1.formatError)(err);
|
|
21
|
-
}
|
|
16
|
+
}, async ({ limit, offset }) => {
|
|
17
|
+
const data = await client.get("/users");
|
|
18
|
+
return (0, responses_js_1.formatSuccess)((0, paging_js_1.pageOf)(data, limit, offset));
|
|
22
19
|
});
|
|
23
20
|
server.registerTool("protect_get_user", {
|
|
24
21
|
description: "Get details for a specific Protect user by ID. Returns the same fields as protect_list_users entries: id, modelKey, name, firstName, lastName, email, ucoreUserId.",
|
|
25
|
-
inputSchema: { id:
|
|
22
|
+
inputSchema: { id: (0, params_js_1.resourceId)().describe("User ID") },
|
|
26
23
|
outputSchema: misc_js_1.userOutputSchema,
|
|
27
24
|
annotations: safety_js_1.READ_ONLY,
|
|
28
25
|
}, async ({ id }) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
return (0, responses_js_1.formatError)(err);
|
|
35
|
-
}
|
|
26
|
+
const data = await client.get((0, url_js_1.safePath) `/users/${id}`);
|
|
27
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
36
28
|
});
|
|
37
29
|
server.registerTool("protect_list_ulp_users", {
|
|
38
|
-
description: "List
|
|
30
|
+
description: "List UniFi Identity (ULP) users — per the API docs, those with enrolled credentials (NFC cards, fingerprints). Returns array; each ULP user includes (Integration API 7.3.70-verified): id, modelKey, firstName, lastName, fullName, email (empty string for service accounts), status (e.g. ACTIVE). Enrolled-credential detail (NFC cards, fingerprints) is NOT exposed by this API surface." + paging_js_1.PAGING_NOTE,
|
|
31
|
+
inputSchema: paging_js_1.pagingInput,
|
|
39
32
|
outputSchema: misc_js_1.ulpUserListOutputSchema,
|
|
40
33
|
annotations: safety_js_1.READ_ONLY,
|
|
41
|
-
}, async () => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
return (0, responses_js_1.formatError)(err);
|
|
48
|
-
}
|
|
34
|
+
}, async ({ limit, offset }) => {
|
|
35
|
+
const data = await client.get("/ulp-users");
|
|
36
|
+
return (0, responses_js_1.formatSuccess)((0, paging_js_1.pageOf)(data, limit, offset));
|
|
49
37
|
});
|
|
50
38
|
server.registerTool("protect_get_ulp_user", {
|
|
51
39
|
description: "Get details for a specific UniFi Identity (ULP) user by ID. Returns: id, modelKey, firstName, lastName, fullName, email, status.",
|
|
52
|
-
inputSchema: { id:
|
|
40
|
+
inputSchema: { id: (0, params_js_1.resourceId)().describe("ULP user ID") },
|
|
53
41
|
outputSchema: misc_js_1.ulpUserOutputSchema,
|
|
54
42
|
annotations: safety_js_1.READ_ONLY,
|
|
55
43
|
}, async ({ id }) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
return (0, responses_js_1.formatError)(err);
|
|
62
|
-
}
|
|
44
|
+
const data = await client.get((0, url_js_1.safePath) `/ulp-users/${id}`);
|
|
45
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
63
46
|
});
|
|
64
47
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The largest serialised tool result. The ceilings below keep payloads near
|
|
3
|
+
* this, but a result's text copy is JSON-escaped again inside the JSON-RPC
|
|
4
|
+
* line (quotes double at each level), so instrumentTools measures the final
|
|
5
|
+
* result against this and refuses one that would not fit.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_RESULT_BYTES: number;
|
|
8
|
+
/**
|
|
9
|
+
* The most of a JSON response that is read. The bound is on memory, not on
|
|
10
|
+
* what is returned (a result carries its JSON twice, as text and as
|
|
11
|
+
* structuredContent, so half a message): bounding the response by that would
|
|
12
|
+
* fail a large site's list call however small a page it asked for.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_RESPONSE_BYTES: number;
|
|
15
|
+
/** A snapshot reaches the client base64-encoded, inside its result. */
|
|
16
|
+
export declare const MAX_SNAPSHOT_BYTES: number;
|
|
17
|
+
/** An upload arrives from the client base64-encoded. */
|
|
18
|
+
export declare const MAX_UPLOAD_BASE64_CHARS: number;
|
|
19
|
+
/** The server's stdio read buffer (see READ_HEADROOM). */
|
|
20
|
+
export declare const SERVER_READ_BUFFER_BYTES: number;
|
|
21
|
+
/**
|
|
22
|
+
* How much of an error body is read. Only its start reaches the model, so a
|
|
23
|
+
* proxy's error page or a runaway body is not buffered whole.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MAX_ERROR_BODY_BYTES: number;
|
|
26
|
+
/** Protect answers JSON calls quickly; a longer wait means something is wrong. */
|
|
27
|
+
export declare const REQUEST_TIMEOUT_MS = 25000;
|
|
28
|
+
/**
|
|
29
|
+
* Snapshots and uploads get the longest budget that still ends before the MCP
|
|
30
|
+
* SDK client's default request timeout. It used to equal that timeout, so a
|
|
31
|
+
* slow snapshot always lost the race.
|
|
32
|
+
*/
|
|
33
|
+
export declare const BINARY_TIMEOUT_MS: number;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BINARY_TIMEOUT_MS = exports.REQUEST_TIMEOUT_MS = exports.MAX_ERROR_BODY_BYTES = exports.SERVER_READ_BUFFER_BYTES = exports.MAX_UPLOAD_BASE64_CHARS = exports.MAX_SNAPSHOT_BYTES = exports.MAX_RESPONSE_BYTES = exports.MAX_RESULT_BYTES = void 0;
|
|
4
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/shared/stdio.js");
|
|
5
|
+
const protocol_js_1 = require("@modelcontextprotocol/sdk/shared/protocol.js");
|
|
6
|
+
/**
|
|
7
|
+
* Payload ceilings, all derived from what one MCP stdio message can carry.
|
|
8
|
+
*
|
|
9
|
+
* Over stdio each JSON-RPC message is one line, and the SDK's reader closes
|
|
10
|
+
* the transport once a line exceeds STDIO_DEFAULT_MAX_BUFFER_SIZE: an SDK
|
|
11
|
+
* client disconnects (every later call fails with "Not connected"), and this
|
|
12
|
+
* server's own transport closing ends the process. So nothing this server
|
|
13
|
+
* returns may exceed that, and what it accepts is checked before it could.
|
|
14
|
+
*/
|
|
15
|
+
/** Room kept for the JSON-RPC envelope and the rest of a result around its payload. */
|
|
16
|
+
const ENVELOPE_BYTES = 64 * 1024;
|
|
17
|
+
/** Base64 encodes every 3 bytes as 4 characters. */
|
|
18
|
+
const BASE64_CHARS_PER_BYTE = 4 / 3;
|
|
19
|
+
/** The largest payload, in bytes or base64 characters, one message can carry. */
|
|
20
|
+
const MAX_PAYLOAD = stdio_js_1.STDIO_DEFAULT_MAX_BUFFER_SIZE - ENVELOPE_BYTES;
|
|
21
|
+
/**
|
|
22
|
+
* The largest serialised tool result. The ceilings below keep payloads near
|
|
23
|
+
* this, but a result's text copy is JSON-escaped again inside the JSON-RPC
|
|
24
|
+
* line (quotes double at each level), so instrumentTools measures the final
|
|
25
|
+
* result against this and refuses one that would not fit.
|
|
26
|
+
*/
|
|
27
|
+
exports.MAX_RESULT_BYTES = MAX_PAYLOAD;
|
|
28
|
+
/**
|
|
29
|
+
* How many times one message a JSON response may be. A list tool returns
|
|
30
|
+
* only the page it was asked for, so the response it pages can be larger than
|
|
31
|
+
* any result; instrumentTools still refuses a result that would not fit.
|
|
32
|
+
*/
|
|
33
|
+
const RESPONSE_HEADROOM = 4;
|
|
34
|
+
/**
|
|
35
|
+
* The most of a JSON response that is read. The bound is on memory, not on
|
|
36
|
+
* what is returned (a result carries its JSON twice, as text and as
|
|
37
|
+
* structuredContent, so half a message): bounding the response by that would
|
|
38
|
+
* fail a large site's list call however small a page it asked for.
|
|
39
|
+
*/
|
|
40
|
+
exports.MAX_RESPONSE_BYTES = stdio_js_1.STDIO_DEFAULT_MAX_BUFFER_SIZE * RESPONSE_HEADROOM;
|
|
41
|
+
/** Room for a result's own wrapping around a payload (content type, mimeType). */
|
|
42
|
+
const RESULT_WRAPPER_BYTES = 1024;
|
|
43
|
+
/** A snapshot reaches the client base64-encoded, inside its result. */
|
|
44
|
+
exports.MAX_SNAPSHOT_BYTES = Math.floor((MAX_PAYLOAD - RESULT_WRAPPER_BYTES) / BASE64_CHARS_PER_BYTE);
|
|
45
|
+
/** An upload arrives from the client base64-encoded. */
|
|
46
|
+
exports.MAX_UPLOAD_BASE64_CHARS = MAX_PAYLOAD;
|
|
47
|
+
/**
|
|
48
|
+
* How much larger than one message the server's own stdio reader accepts.
|
|
49
|
+
* An upload over MAX_UPLOAD_BASE64_CHARS then still reaches input validation
|
|
50
|
+
* and is refused with a message, instead of closing the transport and taking
|
|
51
|
+
* the server down mid-session. Only a message beyond this still does that.
|
|
52
|
+
*/
|
|
53
|
+
const READ_HEADROOM = 4;
|
|
54
|
+
/** The server's stdio read buffer (see READ_HEADROOM). */
|
|
55
|
+
exports.SERVER_READ_BUFFER_BYTES = stdio_js_1.STDIO_DEFAULT_MAX_BUFFER_SIZE * READ_HEADROOM;
|
|
56
|
+
/**
|
|
57
|
+
* How much of an error body is read. Only its start reaches the model, so a
|
|
58
|
+
* proxy's error page or a runaway body is not buffered whole.
|
|
59
|
+
*/
|
|
60
|
+
exports.MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
61
|
+
/** Protect answers JSON calls quickly; a longer wait means something is wrong. */
|
|
62
|
+
exports.REQUEST_TIMEOUT_MS = 25_000;
|
|
63
|
+
/**
|
|
64
|
+
* Time left for the MCP client between our timeout and its own, so the
|
|
65
|
+
* server's error (which names the call, and says whether a write may have
|
|
66
|
+
* landed) reaches the model instead of a bare client-side timeout.
|
|
67
|
+
*/
|
|
68
|
+
const CLIENT_TIMEOUT_MARGIN_MS = 15_000;
|
|
69
|
+
/**
|
|
70
|
+
* Snapshots and uploads get the longest budget that still ends before the MCP
|
|
71
|
+
* SDK client's default request timeout. It used to equal that timeout, so a
|
|
72
|
+
* slow snapshot always lost the race.
|
|
73
|
+
*/
|
|
74
|
+
exports.BINARY_TIMEOUT_MS = protocol_js_1.DEFAULT_REQUEST_TIMEOUT_MSEC - CLIENT_TIMEOUT_MARGIN_MS;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Optional paging for list tools. Protect's list endpoints return every item,
|
|
4
|
+
* so a large site can produce a result bigger than a host will pass to the
|
|
5
|
+
* model; these slice it server-side. With neither given, a list tool returns
|
|
6
|
+
* exactly what Protect returned.
|
|
7
|
+
*/
|
|
8
|
+
export declare const pagingInput: {
|
|
9
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
offset: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
};
|
|
12
|
+
/** Appended to every list tool's description. */
|
|
13
|
+
export declare const PAGING_NOTE = " On a large site, use limit and offset to page; a paged result also returns totalCount and offset.";
|
|
14
|
+
/** The page of a list that limit/offset select, or the list unchanged when neither is set. */
|
|
15
|
+
export declare function pageOf(data: unknown, limit: number | undefined, offset: number | undefined): unknown;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PAGING_NOTE = exports.pagingInput = void 0;
|
|
4
|
+
exports.pageOf = pageOf;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
/**
|
|
7
|
+
* Optional paging for list tools. Protect's list endpoints return every item,
|
|
8
|
+
* so a large site can produce a result bigger than a host will pass to the
|
|
9
|
+
* model; these slice it server-side. With neither given, a list tool returns
|
|
10
|
+
* exactly what Protect returned.
|
|
11
|
+
*/
|
|
12
|
+
exports.pagingInput = {
|
|
13
|
+
limit: zod_1.z.number().int().min(1).optional().describe("Return at most this many items"),
|
|
14
|
+
offset: zod_1.z.number().int().min(0).optional().describe("Skip this many items first (default 0)"),
|
|
15
|
+
};
|
|
16
|
+
/** Appended to every list tool's description. */
|
|
17
|
+
exports.PAGING_NOTE = " On a large site, use limit and offset to page; a paged result also returns totalCount and offset.";
|
|
18
|
+
/** The page of a list that limit/offset select, or the list unchanged when neither is set. */
|
|
19
|
+
function pageOf(data, limit, offset) {
|
|
20
|
+
if ((limit === undefined && offset === undefined) || !Array.isArray(data))
|
|
21
|
+
return data;
|
|
22
|
+
const start = offset ?? 0;
|
|
23
|
+
return {
|
|
24
|
+
result: data.slice(start, limit === undefined ? undefined : start + limit),
|
|
25
|
+
totalCount: data.length,
|
|
26
|
+
offset: start,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** An Integration API resource ID, as returned by the list and get tools. */
|
|
3
|
+
export declare function resourceId(): z.ZodString;
|
|
4
|
+
/**
|
|
5
|
+
* An arm profile ID: a resource ID that is not one of the literal routes
|
|
6
|
+
* beside it, in any case, since a router may match paths case-insensitively.
|
|
7
|
+
*/
|
|
8
|
+
export declare function armProfileId(): z.ZodString;
|
|
9
|
+
/**
|
|
10
|
+
* An alarm manager webhook ID. These are user-defined in the alarm manager,
|
|
11
|
+
* so any text is allowed (safePath encodes it) except a bare dot segment.
|
|
12
|
+
*/
|
|
13
|
+
export declare function webhookId(): z.ZodString;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resourceId = resourceId;
|
|
4
|
+
exports.armProfileId = armProfileId;
|
|
5
|
+
exports.webhookId = webhookId;
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
/**
|
|
8
|
+
* Protect resource IDs are 24-character hex ObjectIds or UUIDs. The pattern
|
|
9
|
+
* allows that charset with headroom, and nothing else: IDs are interpolated
|
|
10
|
+
* into request paths, and while safePath encodes `/ ? # % \`, a bare `.` or
|
|
11
|
+
* `..` survives encoding and URL parsing then folds it out of the path.
|
|
12
|
+
*/
|
|
13
|
+
const RESOURCE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
+
/** An Integration API resource ID, as returned by the list and get tools. */
|
|
15
|
+
function resourceId() {
|
|
16
|
+
return zod_1.z.string().regex(RESOURCE_ID, "Use an ID returned by a list or get tool");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Literal routes that sit beside /arm-profiles/{id}. An "ID" naming one would
|
|
20
|
+
* reach that endpoint instead: PATCH /arm-profiles/settings is the set-current
|
|
21
|
+
* call, which requires confirm, so update_arm_profile must not reach it.
|
|
22
|
+
*/
|
|
23
|
+
const ARM_PROFILE_ROUTES = new Set(["settings", "enable", "disable"]);
|
|
24
|
+
/**
|
|
25
|
+
* An arm profile ID: a resource ID that is not one of the literal routes
|
|
26
|
+
* beside it, in any case, since a router may match paths case-insensitively.
|
|
27
|
+
*/
|
|
28
|
+
function armProfileId() {
|
|
29
|
+
return resourceId().refine((id) => !ARM_PROFILE_ROUTES.has(id.toLowerCase()), "Use an arm profile ID from protect_list_arm_profiles");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An alarm manager webhook ID. These are user-defined in the alarm manager,
|
|
33
|
+
* so any text is allowed (safePath encodes it) except a bare dot segment.
|
|
34
|
+
*/
|
|
35
|
+
function webhookId() {
|
|
36
|
+
return zod_1.z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.refine((id) => id !== "." && id !== "..", "A webhook ID cannot be . or ..");
|
|
40
|
+
}
|
package/dist/utils/responses.js
CHANGED
|
@@ -10,9 +10,8 @@ function formatSuccess(data) {
|
|
|
10
10
|
? data
|
|
11
11
|
: { result: data };
|
|
12
12
|
return {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
],
|
|
13
|
+
// Compact: indentation is whitespace the model pays for in tokens.
|
|
14
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
16
15
|
structuredContent: structured,
|
|
17
16
|
};
|
|
18
17
|
}
|
package/dist/utils/safety.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
/** Annotation constants for tool safety classification */
|
|
2
3
|
export declare const READ_ONLY: {
|
|
3
4
|
readonly readOnlyHint: true;
|
|
@@ -23,10 +24,45 @@ export declare function formatDryRun(method: string, path: string, body?: unknow
|
|
|
23
24
|
structuredContent: Record<string, unknown>;
|
|
24
25
|
};
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* The `confirm` input of a gated tool. Optional in the schema: a dry run
|
|
28
|
+
* changes nothing, so it never needs confirming, and the handler checks
|
|
29
|
+
* confirm only on the real call (see requireConfirmation).
|
|
28
30
|
*/
|
|
29
|
-
export declare function
|
|
31
|
+
export declare function confirmInput(action: string): z.ZodOptional<z.ZodBoolean>;
|
|
32
|
+
/** The `dryRun` input every write tool takes; instrumentTools keys on its name. */
|
|
33
|
+
export declare function dryRunInput(): z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
/**
|
|
35
|
+
* A confirm gate: the tool's `confirm` input and the handler's check, built
|
|
36
|
+
* from one action text so the two cannot drift apart. Call `check` after the
|
|
37
|
+
* dry-run branch.
|
|
38
|
+
*/
|
|
39
|
+
export declare function confirmGate(action: string): {
|
|
40
|
+
readonly input: z.ZodOptional<z.ZodBoolean>;
|
|
41
|
+
readonly check: (confirm: boolean | undefined) => {
|
|
42
|
+
content: {
|
|
43
|
+
type: "text";
|
|
44
|
+
text: string;
|
|
45
|
+
}[];
|
|
46
|
+
isError: boolean;
|
|
47
|
+
} | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Guard for a gated action, checked after the dry-run branch. Returns an
|
|
51
|
+
* error result if `confirm` is not `true`, otherwise `null`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function requireConfirmation(confirm: boolean | undefined, action: string): {
|
|
54
|
+
content: {
|
|
55
|
+
type: "text";
|
|
56
|
+
text: string;
|
|
57
|
+
}[];
|
|
58
|
+
isError: boolean;
|
|
59
|
+
} | null;
|
|
60
|
+
/**
|
|
61
|
+
* Confirmation for an update that is routine in general but, for these
|
|
62
|
+
* settings, weakens alarm coverage. Returns null when the update touches none
|
|
63
|
+
* of `fields`. Call it after the dry-run branch, like requireConfirmation.
|
|
64
|
+
*/
|
|
65
|
+
export declare function requireConfirmationForFields(settings: Record<string, unknown>, fields: readonly string[], confirm: boolean | undefined): {
|
|
30
66
|
content: {
|
|
31
67
|
type: "text";
|
|
32
68
|
text: string;
|