@owine/unifi-network-mcp 2.15.0 → 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/LICENSE +21 -0
- package/README.md +226 -73
- package/dist/client.d.ts +13 -1
- package/dist/client.js +142 -16
- package/dist/config.d.ts +11 -0
- package/dist/config.js +102 -3
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +171 -0
- package/dist/index.js +15 -4
- package/dist/instructions.d.ts +9 -0
- package/dist/instructions.js +32 -0
- package/dist/tls.d.ts +14 -0
- package/dist/tls.js +58 -0
- package/dist/tools/acl.js +123 -50
- package/dist/tools/client-history.js +20 -10
- package/dist/tools/clients.js +10 -9
- package/dist/tools/devices.js +21 -20
- package/dist/tools/dns-policies.js +27 -27
- package/dist/tools/firewall.d.ts +2 -1
- package/dist/tools/firewall.js +200 -75
- package/dist/tools/hotspot.js +35 -28
- package/dist/tools/index.d.ts +13 -1
- package/dist/tools/index.js +63 -4
- package/dist/tools/networks.d.ts +2 -1
- package/dist/tools/networks.js +44 -31
- package/dist/tools/sites.js +2 -2
- package/dist/tools/supporting.js +22 -21
- package/dist/tools/switching.js +16 -15
- package/dist/tools/system.js +1 -1
- package/dist/tools/traffic-matching.d.ts +2 -1
- package/dist/tools/traffic-matching.js +22 -20
- package/dist/tools/wifi.js +76 -30
- package/dist/utils/full-put.d.ts +11 -2
- package/dist/utils/full-put.js +25 -2
- package/dist/utils/output-schemas.d.ts +26 -4
- package/dist/utils/output-schemas.js +80 -48
- package/dist/utils/params.d.ts +7 -0
- package/dist/utils/params.js +12 -0
- package/dist/utils/responses.d.ts +2 -0
- package/dist/utils/responses.js +28 -2
- package/dist/utils/safety.d.ts +35 -1
- package/dist/utils/safety.js +53 -3
- package/dist/utils/secrets.js +3 -0
- package/dist/utils/wan-exposure.d.ts +69 -0
- package/dist/utils/wan-exposure.js +245 -0
- package/dist/write-log.d.ts +52 -0
- package/dist/write-log.js +178 -0
- package/dist/write-tools.d.ts +27 -0
- package/dist/write-tools.js +50 -0
- package/package.json +17 -13
package/dist/client.js
CHANGED
|
@@ -1,42 +1,145 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.NetworkClient = void 0;
|
|
4
|
+
exports.withCallSignal = withCallSignal;
|
|
5
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
6
|
+
// undici's own fetch, not the global one: Node's built-in fetch bundles an
|
|
7
|
+
// older undici whose handlers the Agent from tls.ts (undici 8) rejects.
|
|
8
|
+
const undici_1 = require("undici");
|
|
9
|
+
const config_js_1 = require("./config.js");
|
|
10
|
+
const guard_js_1 = require("./guard.js");
|
|
11
|
+
const tls_js_1 = require("./tls.js");
|
|
12
|
+
const write_log_js_1 = require("./write-log.js");
|
|
13
|
+
// Well under undici's 300s default, so a stalled controller fails the call
|
|
14
|
+
// before the MCP host gives up on it.
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
16
|
+
const HISTORY_TIMEOUT_MS = 25_000;
|
|
17
|
+
// What undici reports when the console closed the connection under a
|
|
18
|
+
// request, typically a kept-alive socket it had already timed out.
|
|
19
|
+
const SOCKET_CLOSED = ["UND_ERR_SOCKET", "ECONNRESET", "EPIPE"];
|
|
20
|
+
function socketClosed(err) {
|
|
21
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
22
|
+
return cause instanceof Error && "code" in cause && typeof cause.code === "string" && SOCKET_CLOSED.includes(cause.code);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A request that failed in flight, worded so the model knows whether retrying
|
|
26
|
+
* is safe: a timed-out or cut-off write may already have been applied, and
|
|
27
|
+
* repeating a create would duplicate it. The timeout also covers reading the
|
|
28
|
+
* body, so this applies to the whole exchange, not only to fetch().
|
|
29
|
+
*/
|
|
30
|
+
function inFlightError(read, method, timeoutMs, err) {
|
|
31
|
+
const hint = read
|
|
32
|
+
? "Retry when the console responds."
|
|
33
|
+
: `The ${method} may still have been applied; check the resource before retrying.`;
|
|
34
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
35
|
+
return new Error(`UniFi controller did not respond within ${timeoutMs / 1000}s. ${hint}`, { cause: err });
|
|
36
|
+
}
|
|
37
|
+
if (!read && socketClosed(err)) {
|
|
38
|
+
return new Error(`The UniFi controller closed the connection during the ${method}. ${hint}`, { cause: err });
|
|
39
|
+
}
|
|
40
|
+
return err;
|
|
41
|
+
}
|
|
42
|
+
// The abort signal of the MCP tool call a request is made for. Without it a
|
|
43
|
+
// cancelled (or abandoned) call keeps its request running, and a write lands
|
|
44
|
+
// after the host has already reported the call as failed.
|
|
45
|
+
const callSignal = new node_async_hooks_1.AsyncLocalStorage();
|
|
46
|
+
/** Run fn so that every request it makes is aborted when signal is. */
|
|
47
|
+
function withCallSignal(signal, fn) {
|
|
48
|
+
return signal ? callSignal.run(signal, fn) : fn();
|
|
49
|
+
}
|
|
50
|
+
const ERROR_BODY_LIMIT = 500;
|
|
51
|
+
/**
|
|
52
|
+
* The useful part of an error body for the model: the API's documented
|
|
53
|
+
* message, code and request ID, or a capped excerpt of anything else (a proxy
|
|
54
|
+
* or UniFi OS HTML error page would otherwise land in the context whole).
|
|
55
|
+
*/
|
|
56
|
+
function describeErrorBody(text) {
|
|
57
|
+
try {
|
|
58
|
+
const body = JSON.parse(text);
|
|
59
|
+
if (body && typeof body === "object" && "message" in body && typeof body.message === "string") {
|
|
60
|
+
const details = [
|
|
61
|
+
"code" in body && typeof body.code === "string" ? body.code : undefined,
|
|
62
|
+
"requestId" in body && typeof body.requestId === "string" ? `request ${body.requestId}` : undefined,
|
|
63
|
+
].filter((detail) => detail !== undefined);
|
|
64
|
+
const message = capped(body.message);
|
|
65
|
+
return details.length > 0 ? `${message} (${details.join("; ")})` : message;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not JSON: fall through to the excerpt.
|
|
70
|
+
}
|
|
71
|
+
return capped(text);
|
|
72
|
+
}
|
|
73
|
+
function capped(text) {
|
|
74
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
75
|
+
return collapsed.length > ERROR_BODY_LIMIT ? `${collapsed.slice(0, ERROR_BODY_LIMIT)}…` : collapsed;
|
|
76
|
+
}
|
|
77
|
+
function requestSignal(timeoutMs) {
|
|
78
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
79
|
+
const caller = callSignal.getStore();
|
|
80
|
+
return caller ? AbortSignal.any([caller, timeout]) : timeout;
|
|
81
|
+
}
|
|
4
82
|
class NetworkClient {
|
|
5
|
-
baseUrl;
|
|
6
83
|
headers;
|
|
7
84
|
networkUrl;
|
|
85
|
+
dispatcher;
|
|
86
|
+
policy;
|
|
8
87
|
constructor(config) {
|
|
9
88
|
// Cloud Connector only exists behind api.ui.com. A console ID set against a
|
|
10
89
|
// local controller is ignored rather than fatal: the other tools work fine
|
|
11
90
|
// against that controller, and taking the whole server down helps nobody.
|
|
12
|
-
const useConnector = config.consoleId !== undefined && config.host
|
|
91
|
+
const useConnector = config.consoleId !== undefined && (0, config_js_1.isSiteManagerHost)(config.host);
|
|
13
92
|
if (config.consoleId !== undefined && !useConnector) {
|
|
14
93
|
console.error(`Ignoring UNIFI_NETWORK_CONSOLE_ID: Cloud Connector requires host api.ui.com, but host is ${config.host}.`);
|
|
15
94
|
}
|
|
16
95
|
const consolePath = useConnector ? `/v1/connector/consoles/${config.consoleId}` : "";
|
|
17
96
|
this.networkUrl = `https://${config.host}${consolePath}/proxy/network`;
|
|
18
|
-
this.baseUrl = `${this.networkUrl}/integration/v1`;
|
|
19
97
|
this.headers = {
|
|
20
98
|
"X-API-KEY": config.apiKey,
|
|
21
99
|
"Content-Type": "application/json",
|
|
22
100
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
101
|
+
this.dispatcher = (0, tls_js_1.consoleDispatcher)(config);
|
|
102
|
+
this.policy = { readOnly: config.readOnly, ...(config.writeTools ? { writeTools: config.writeTools } : {}) };
|
|
26
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* path is relative to the Integration API, as the tools pass it. The guard
|
|
106
|
+
* sees it as the canonical path under the Network application; the host and
|
|
107
|
+
* any Cloud Connector prefix are added only once it has passed.
|
|
108
|
+
*/
|
|
27
109
|
async request(method, path, body) {
|
|
28
|
-
const
|
|
110
|
+
const canonical = `/integration/v1${path}`;
|
|
111
|
+
this.guard(method, canonical);
|
|
112
|
+
const url = `${this.networkUrl}${canonical}`;
|
|
113
|
+
const send = () => this.send(method, url, body).catch((err) => {
|
|
114
|
+
throw inFlightError(method === "GET", method, REQUEST_TIMEOUT_MS, err);
|
|
115
|
+
});
|
|
116
|
+
// A connection the console closed while it sat idle fails the next request
|
|
117
|
+
// on it. Nothing reached the console, and a read changes nothing anyway, so
|
|
118
|
+
// a read goes out once more on a fresh connection.
|
|
119
|
+
return method === "GET"
|
|
120
|
+
? send().catch((err) => {
|
|
121
|
+
if (socketClosed(err))
|
|
122
|
+
return send();
|
|
123
|
+
throw err;
|
|
124
|
+
})
|
|
125
|
+
: send();
|
|
126
|
+
}
|
|
127
|
+
async send(method, url, body) {
|
|
29
128
|
const options = {
|
|
30
129
|
method,
|
|
31
130
|
headers: this.headers,
|
|
131
|
+
redirect: "error",
|
|
132
|
+
signal: requestSignal(REQUEST_TIMEOUT_MS),
|
|
133
|
+
dispatcher: this.dispatcher,
|
|
32
134
|
};
|
|
33
135
|
if (body !== undefined) {
|
|
34
136
|
options.body = JSON.stringify(body);
|
|
35
137
|
}
|
|
36
|
-
const response = await fetch(url, options);
|
|
138
|
+
const response = await (0, undici_1.fetch)(url, options);
|
|
139
|
+
if (method !== "GET")
|
|
140
|
+
(0, write_log_js_1.noteWriteStatus)(response.status);
|
|
37
141
|
if (!response.ok) {
|
|
38
|
-
|
|
39
|
-
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
142
|
+
throw new Error(`HTTP ${response.status}: ${describeErrorBody(await response.text())}`);
|
|
40
143
|
}
|
|
41
144
|
const contentType = response.headers.get("content-type") ?? "";
|
|
42
145
|
if (contentType.includes("application/json")) {
|
|
@@ -44,6 +147,19 @@ class NetworkClient {
|
|
|
44
147
|
}
|
|
45
148
|
return response.text();
|
|
46
149
|
}
|
|
150
|
+
/** Check a request against the guard before anything is sent, and note writes for the write log. */
|
|
151
|
+
guard(method, path) {
|
|
152
|
+
try {
|
|
153
|
+
const route = (0, guard_js_1.guardRequest)(method, path, this.policy);
|
|
154
|
+
if (route.tools !== undefined)
|
|
155
|
+
(0, write_log_js_1.noteWriteRequest)(method, path);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err instanceof guard_js_1.GuardError)
|
|
159
|
+
(0, write_log_js_1.noteRefusal)("request_guard", method, path);
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
47
163
|
async get(path) {
|
|
48
164
|
return this.request("GET", path);
|
|
49
165
|
}
|
|
@@ -86,11 +202,20 @@ class NetworkClient {
|
|
|
86
202
|
}
|
|
87
203
|
}
|
|
88
204
|
async historyRequest(path, body) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
205
|
+
const method = body === undefined ? "GET" : "POST";
|
|
206
|
+
this.guard(method, path);
|
|
207
|
+
// Both history endpoints are reads, the POST included.
|
|
208
|
+
return this.readHistory(method, path, body).catch((err) => {
|
|
209
|
+
throw inFlightError(true, method, HISTORY_TIMEOUT_MS, err);
|
|
93
210
|
});
|
|
211
|
+
}
|
|
212
|
+
async readHistory(method, path, body) {
|
|
213
|
+
const options = {
|
|
214
|
+
method, headers: this.headers,
|
|
215
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
216
|
+
redirect: "error", signal: requestSignal(HISTORY_TIMEOUT_MS), dispatcher: this.dispatcher,
|
|
217
|
+
};
|
|
218
|
+
const response = await (0, undici_1.fetch)(`${this.networkUrl}${path}`, options);
|
|
94
219
|
if (!response.ok) {
|
|
95
220
|
// Drain before throwing: an unread body pins the socket until GC, so a
|
|
96
221
|
// run of failures (say, a mis-scoped API key) would leak connections.
|
|
@@ -102,9 +227,10 @@ class NetworkClient {
|
|
|
102
227
|
throw new Error("Controller history returned non-JSON data; verify controller authentication");
|
|
103
228
|
}
|
|
104
229
|
// Bound the body before parsing: controller session records can be large.
|
|
105
|
-
|
|
106
|
-
if (!reader)
|
|
230
|
+
if (response.body === null)
|
|
107
231
|
throw new Error("Controller history returned no response body");
|
|
232
|
+
// undici types the body as an untyped stream; a fetch body is always bytes.
|
|
233
|
+
const reader = response.body.getReader();
|
|
108
234
|
const chunks = [];
|
|
109
235
|
let bytes = 0;
|
|
110
236
|
try {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Whether host is api.ui.com however it is written: URL parsing lower-cases
|
|
4
|
+
* it and drops a port, and a trailing dot names the same host. The request
|
|
5
|
+
* goes to api.ui.com in each case, so each must get the same treatment.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isSiteManagerHost(host: string): boolean;
|
|
2
8
|
declare const configSchema: z.ZodObject<{
|
|
3
9
|
host: z.ZodString;
|
|
4
10
|
apiKey: z.ZodString;
|
|
5
11
|
consoleId: z.ZodOptional<z.ZodString>;
|
|
6
12
|
verifySsl: z.ZodDefault<z.ZodBoolean>;
|
|
7
13
|
readOnly: z.ZodDefault<z.ZodBoolean>;
|
|
14
|
+
writeTools: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>>;
|
|
15
|
+
allowWanExposure: z.ZodOptional<z.ZodLiteral<true>>;
|
|
16
|
+
wanZoneIds: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodString>, z.ZodTransform<string[], string[]>>>;
|
|
17
|
+
writeLog: z.ZodOptional<z.ZodString>;
|
|
18
|
+
caCert: z.ZodOptional<z.ZodString>;
|
|
8
19
|
}, z.core.$strip>;
|
|
9
20
|
export type Config = z.infer<typeof configSchema>;
|
|
10
21
|
export declare function loadConfig(): Config;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSiteManagerHost = isSiteManagerHost;
|
|
3
4
|
exports.loadConfig = loadConfig;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
4
7
|
const zod_1 = require("zod");
|
|
8
|
+
const tls_js_1 = require("./tls.js");
|
|
9
|
+
const responses_js_1 = require("./utils/responses.js");
|
|
10
|
+
const write_tools_js_1 = require("./write-tools.js");
|
|
11
|
+
function holdsCertificate(pem) {
|
|
12
|
+
try {
|
|
13
|
+
return (0, tls_js_1.pemCertificates)(pem).length > 0;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether host is api.ui.com however it is written: URL parsing lower-cases
|
|
21
|
+
* it and drops a port, and a trailing dot names the same host. The request
|
|
22
|
+
* goes to api.ui.com in each case, so each must get the same treatment.
|
|
23
|
+
*/
|
|
24
|
+
function isSiteManagerHost(host) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(`https://${host}`).hostname.replace(/\.$/, "") === "api.ui.com";
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
33
|
const configSchema = zod_1.z.object({
|
|
6
34
|
host: zod_1.z.string().min(1).describe("UniFi Network host (IP or hostname)"),
|
|
7
35
|
apiKey: zod_1.z.string().min(1).describe("UniFi Network API key"),
|
|
@@ -18,8 +46,59 @@ const configSchema = zod_1.z.object({
|
|
|
18
46
|
.boolean()
|
|
19
47
|
.default(true)
|
|
20
48
|
.describe("When true, only read-only tools are registered"),
|
|
49
|
+
writeTools: zod_1.z.string()
|
|
50
|
+
.transform((value, ctx) => {
|
|
51
|
+
const { tools, unknown } = (0, write_tools_js_1.parseWriteTools)(value);
|
|
52
|
+
if (unknown.length > 0) {
|
|
53
|
+
ctx.addIssue({
|
|
54
|
+
code: "custom",
|
|
55
|
+
message: `UNIFI_NETWORK_WRITE_TOOLS names unknown write tools or groups: ${unknown.join(", ")}. Groups: ${Object.keys(write_tools_js_1.WRITE_TOOL_GROUPS).join(", ")}. Tools: ${write_tools_js_1.WRITE_TOOLS.join(", ")}. Use "none" for no write tools.`,
|
|
56
|
+
});
|
|
57
|
+
return zod_1.z.NEVER;
|
|
58
|
+
}
|
|
59
|
+
return tools;
|
|
60
|
+
})
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("The write tools to register; undefined registers them all"),
|
|
63
|
+
allowWanExposure: zod_1.z
|
|
64
|
+
.literal(true)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("When true, writes that could let traffic in from the WAN fall back to the confirm flow instead of being refused"),
|
|
67
|
+
wanZoneIds: zod_1.z
|
|
68
|
+
.array(zod_1.z.string().regex(UUID, "UNIFI_NETWORK_WAN_ZONE_IDS must be a comma-separated list of firewall zone IDs (UUIDs)"))
|
|
69
|
+
.transform((ids) => ids.map((id) => id.toLowerCase()))
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Firewall zone IDs to treat as the WAN, in addition to the system-defined External zone"),
|
|
72
|
+
writeLog: zod_1.z.string()
|
|
73
|
+
.refine(node_path_1.isAbsolute, "UNIFI_NETWORK_WRITE_LOG must be an absolute path (MCP hosts do not expand ~)")
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("JSON Lines file every write tool call is appended to"),
|
|
76
|
+
caCert: zod_1.z.string()
|
|
77
|
+
.refine(holdsCertificate, "UNIFI_NETWORK_CA_CERT must name a PEM file containing a certificate")
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("PEM certificate(s) to trust for the console, e.g. its own self-signed certificate"),
|
|
80
|
+
}).refine((config) => config.verifySsl || config.caCert === undefined, {
|
|
81
|
+
path: ["caCert"],
|
|
82
|
+
message: "UNIFI_NETWORK_CA_CERT pins a certificate to verify against; it cannot be combined with UNIFI_NETWORK_VERIFY_SSL=false",
|
|
83
|
+
}).refine((config) => config.verifySsl || !isSiteManagerHost(config.host), {
|
|
84
|
+
// Opting out of verification is for local consoles with self-signed
|
|
85
|
+
// certificates. A Site Manager key reaches every console on the account
|
|
86
|
+
// and crosses the internet, so it never goes out unverified.
|
|
87
|
+
path: ["verifySsl"],
|
|
88
|
+
message: "TLS verification cannot be disabled for api.ui.com; unset UNIFI_NETWORK_VERIFY_SSL",
|
|
21
89
|
});
|
|
22
90
|
function loadConfig() {
|
|
91
|
+
const caCertPath = process.env.UNIFI_NETWORK_CA_CERT;
|
|
92
|
+
let caCert;
|
|
93
|
+
if (caCertPath) {
|
|
94
|
+
try {
|
|
95
|
+
caCert = (0, node_fs_1.readFileSync)(caCertPath, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`Configuration error: cannot read UNIFI_NETWORK_CA_CERT (${caCertPath}): ${(0, responses_js_1.errorMessage)(err)}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
23
102
|
const result = configSchema.safeParse({
|
|
24
103
|
host: process.env.UNIFI_NETWORK_HOST,
|
|
25
104
|
apiKey: process.env.UNIFI_NETWORK_API_KEY,
|
|
@@ -27,10 +106,30 @@ function loadConfig() {
|
|
|
27
106
|
? { consoleId: process.env.UNIFI_NETWORK_CONSOLE_ID } : {}),
|
|
28
107
|
verifySsl: process.env.UNIFI_NETWORK_VERIFY_SSL?.toLowerCase() !== "false",
|
|
29
108
|
readOnly: process.env.UNIFI_NETWORK_READ_ONLY?.toLowerCase() !== "false",
|
|
109
|
+
...(caCert !== undefined ? { caCert } : {}),
|
|
110
|
+
...(process.env.UNIFI_NETWORK_ALLOW_WAN_EXPOSURE?.toLowerCase() === "true"
|
|
111
|
+
? { allowWanExposure: true } : {}),
|
|
112
|
+
...(process.env.UNIFI_NETWORK_WAN_ZONE_IDS
|
|
113
|
+
? { wanZoneIds: process.env.UNIFI_NETWORK_WAN_ZONE_IDS.split(",").map((id) => id.trim()).filter((id) => id !== "") } : {}),
|
|
114
|
+
...(process.env.UNIFI_NETWORK_WRITE_LOG
|
|
115
|
+
? { writeLog: process.env.UNIFI_NETWORK_WRITE_LOG } : {}),
|
|
116
|
+
...(process.env.UNIFI_NETWORK_WRITE_TOOLS !== undefined
|
|
117
|
+
? { writeTools: process.env.UNIFI_NETWORK_WRITE_TOOLS } : {}),
|
|
30
118
|
});
|
|
31
119
|
if (!result.success) {
|
|
32
|
-
console.error(
|
|
33
|
-
process.exit(1);
|
|
120
|
+
console.error(`Configuration error:\n${zod_1.z.prettifyError(result.error)}`);
|
|
121
|
+
return process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const config = result.data;
|
|
124
|
+
if (config.readOnly && config.writeTools !== undefined) {
|
|
125
|
+
// Still validated above, so a typo shows up before writes are turned on.
|
|
126
|
+
console.error("Ignoring UNIFI_NETWORK_WRITE_TOOLS: the server is read-only, so no write tools load. Set UNIFI_NETWORK_READ_ONLY=false to use it.");
|
|
127
|
+
delete config.writeTools;
|
|
128
|
+
}
|
|
129
|
+
if (config.readOnly && config.writeLog !== undefined) {
|
|
130
|
+
// Nothing would be logged, and opening the file could stop the server.
|
|
131
|
+
console.error("Ignoring UNIFI_NETWORK_WRITE_LOG: the server is read-only, so there are no write calls to log. Set UNIFI_NETWORK_READ_ONLY=false to use it.");
|
|
132
|
+
delete config.writeLog;
|
|
34
133
|
}
|
|
35
|
-
return
|
|
134
|
+
return config;
|
|
36
135
|
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request guard: every request NetworkClient makes is checked here before
|
|
3
|
+
* fetch is called, against a fixed allowlist of the endpoints the tools in
|
|
4
|
+
* src/tools/ use. Anything else is refused and nothing is sent, so a tool bug,
|
|
5
|
+
* a smuggled ID or a misled model cannot reach the rest of the console with
|
|
6
|
+
* this API key (the legacy controller API, Protect, another console behind the
|
|
7
|
+
* Cloud Connector).
|
|
8
|
+
*
|
|
9
|
+
* Paths are canonical and relative to the Network application: they start
|
|
10
|
+
* /integration/v1, /v2/api or /api/s, never with the Cloud Connector prefix,
|
|
11
|
+
* which NetworkClient adds only after the guard has passed.
|
|
12
|
+
*/
|
|
13
|
+
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
14
|
+
export type GuardReason = "malformed" | "method" | "not_allowlisted" | "query" | "read_only" | "tool_disabled";
|
|
15
|
+
export declare class GuardError extends Error {
|
|
16
|
+
readonly reason: GuardReason;
|
|
17
|
+
constructor(reason: GuardReason, message: string);
|
|
18
|
+
}
|
|
19
|
+
export interface Route {
|
|
20
|
+
method: Method;
|
|
21
|
+
/** Canonical path; each {id} is one ID segment. */
|
|
22
|
+
template: string;
|
|
23
|
+
/** Query parameters the endpoint may be sent; any other is refused. */
|
|
24
|
+
query?: readonly string[];
|
|
25
|
+
/** Query parameters that must be present. */
|
|
26
|
+
requiredQuery?: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* The write tools that send this request. A route without tools is a read,
|
|
29
|
+
* allowed whatever write tools are enabled — including stat/session's POST,
|
|
30
|
+
* which only queries statistics.
|
|
31
|
+
*/
|
|
32
|
+
tools?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
export interface GuardPolicy {
|
|
35
|
+
readOnly: boolean;
|
|
36
|
+
/** Enabled write tools; undefined enables every write tool. */
|
|
37
|
+
writeTools?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
/** Every request the tools make. Keep in step with src/tools/; tests/guard.test.ts sweeps every tool against it. */
|
|
40
|
+
export declare const ROUTES: readonly Route[];
|
|
41
|
+
/**
|
|
42
|
+
* Check one request and return the route it matched. Throws GuardError,
|
|
43
|
+
* before anything is sent, for a malformed path, an unknown endpoint, an
|
|
44
|
+
* unexpected query parameter, or a write the policy does not allow.
|
|
45
|
+
*/
|
|
46
|
+
export declare function guardRequest(method: string, path: string, policy: GuardPolicy): Route;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The request guard: every request NetworkClient makes is checked here before
|
|
4
|
+
* fetch is called, against a fixed allowlist of the endpoints the tools in
|
|
5
|
+
* src/tools/ use. Anything else is refused and nothing is sent, so a tool bug,
|
|
6
|
+
* a smuggled ID or a misled model cannot reach the rest of the console with
|
|
7
|
+
* this API key (the legacy controller API, Protect, another console behind the
|
|
8
|
+
* Cloud Connector).
|
|
9
|
+
*
|
|
10
|
+
* Paths are canonical and relative to the Network application: they start
|
|
11
|
+
* /integration/v1, /v2/api or /api/s, never with the Cloud Connector prefix,
|
|
12
|
+
* which NetworkClient adds only after the guard has passed.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.ROUTES = exports.GuardError = void 0;
|
|
16
|
+
exports.guardRequest = guardRequest;
|
|
17
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
18
|
+
class GuardError extends Error {
|
|
19
|
+
reason;
|
|
20
|
+
constructor(reason, message) {
|
|
21
|
+
super(`Request refused by the request guard: ${message}`);
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.name = "GuardError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.GuardError = GuardError;
|
|
27
|
+
// The charset resourceId() and the site-reference check allow. "ordering" is
|
|
28
|
+
// never an ID, in any case: excluding it keeps /policies/{id} and /acl-rules/{id} from also
|
|
29
|
+
// matching the ordering endpoints, which belong to other tools.
|
|
30
|
+
const ID = "(?![Oo][Rr][Dd][Ee][Rr][Ii][Nn][Gg](?:/|$))[A-Za-z0-9_-]{1,64}";
|
|
31
|
+
const PAGE = ["offset", "limit", "filter"];
|
|
32
|
+
const ZONE_PAIR = ["sourceFirewallZoneId", "destinationFirewallZoneId"];
|
|
33
|
+
const v1 = (template) => `/integration/v1${template}`;
|
|
34
|
+
/** Every request the tools make. Keep in step with src/tools/; tests/guard.test.ts sweeps every tool against it. */
|
|
35
|
+
exports.ROUTES = [
|
|
36
|
+
// System, sites, supporting
|
|
37
|
+
{ method: "GET", template: v1("/info") },
|
|
38
|
+
{ method: "GET", template: v1("/sites"), query: PAGE },
|
|
39
|
+
{ method: "GET", template: v1("/sites/{id}/wans"), query: PAGE },
|
|
40
|
+
{ method: "GET", template: v1("/sites/{id}/vpn/site-to-site-tunnels"), query: PAGE },
|
|
41
|
+
{ method: "GET", template: v1("/sites/{id}/vpn/servers"), query: PAGE },
|
|
42
|
+
{ method: "GET", template: v1("/sites/{id}/radius/profiles"), query: PAGE },
|
|
43
|
+
{ method: "GET", template: v1("/sites/{id}/device-tags"), query: PAGE },
|
|
44
|
+
{ method: "GET", template: v1("/dpi/categories"), query: PAGE },
|
|
45
|
+
{ method: "GET", template: v1("/dpi/applications"), query: PAGE },
|
|
46
|
+
{ method: "GET", template: v1("/countries"), query: PAGE },
|
|
47
|
+
// Devices
|
|
48
|
+
{ method: "GET", template: v1("/sites/{id}/devices"), query: PAGE },
|
|
49
|
+
{ method: "GET", template: v1("/sites/{id}/devices/{id}") },
|
|
50
|
+
{ method: "GET", template: v1("/sites/{id}/devices/{id}/statistics/latest") },
|
|
51
|
+
{ method: "GET", template: v1("/pending-devices"), query: PAGE },
|
|
52
|
+
{ method: "POST", template: v1("/sites/{id}/devices"), tools: ["unifi_adopt_device"] },
|
|
53
|
+
{ method: "DELETE", template: v1("/sites/{id}/devices/{id}"), tools: ["unifi_remove_device"] },
|
|
54
|
+
{ method: "POST", template: v1("/sites/{id}/devices/{id}/actions"), tools: ["unifi_restart_device"] },
|
|
55
|
+
{ method: "POST", template: v1("/sites/{id}/devices/{id}/interfaces/ports/{id}/actions"), tools: ["unifi_power_cycle_port"] },
|
|
56
|
+
// Clients
|
|
57
|
+
{ method: "GET", template: v1("/sites/{id}/clients"), query: PAGE },
|
|
58
|
+
{ method: "GET", template: v1("/sites/{id}/clients/{id}") },
|
|
59
|
+
{ method: "POST", template: v1("/sites/{id}/clients/{id}/actions"), tools: ["unifi_authorize_guest", "unifi_unauthorize_guest"] },
|
|
60
|
+
// Client history: controller routes outside the Integration API
|
|
61
|
+
{ method: "GET", template: "/v2/api/site/{id}/clients/history", query: ["withinHours"], requiredQuery: ["withinHours"] },
|
|
62
|
+
{ method: "POST", template: "/api/s/{id}/stat/session" },
|
|
63
|
+
// Networks
|
|
64
|
+
{ method: "GET", template: v1("/sites/{id}/networks"), query: PAGE },
|
|
65
|
+
{ method: "GET", template: v1("/sites/{id}/networks/{id}") },
|
|
66
|
+
{ method: "GET", template: v1("/sites/{id}/networks/{id}/references") },
|
|
67
|
+
{ method: "POST", template: v1("/sites/{id}/networks"), tools: ["unifi_create_network"] },
|
|
68
|
+
{ method: "PUT", template: v1("/sites/{id}/networks/{id}"), tools: ["unifi_update_network"] },
|
|
69
|
+
{ method: "DELETE", template: v1("/sites/{id}/networks/{id}"), query: ["force"], tools: ["unifi_delete_network"] },
|
|
70
|
+
// WiFi
|
|
71
|
+
{ method: "GET", template: v1("/sites/{id}/wifi/broadcasts"), query: PAGE },
|
|
72
|
+
{ method: "GET", template: v1("/sites/{id}/wifi/broadcasts/{id}") },
|
|
73
|
+
{ method: "POST", template: v1("/sites/{id}/wifi/broadcasts"), tools: ["unifi_create_wifi"] },
|
|
74
|
+
{ method: "PUT", template: v1("/sites/{id}/wifi/broadcasts/{id}"), tools: ["unifi_update_wifi"] },
|
|
75
|
+
{ method: "DELETE", template: v1("/sites/{id}/wifi/broadcasts/{id}"), query: ["force"], tools: ["unifi_delete_wifi"] },
|
|
76
|
+
// Hotspot vouchers
|
|
77
|
+
{ method: "GET", template: v1("/sites/{id}/hotspot/vouchers"), query: PAGE },
|
|
78
|
+
{ method: "GET", template: v1("/sites/{id}/hotspot/vouchers/{id}") },
|
|
79
|
+
{ method: "POST", template: v1("/sites/{id}/hotspot/vouchers"), tools: ["unifi_create_voucher"] },
|
|
80
|
+
{ method: "DELETE", template: v1("/sites/{id}/hotspot/vouchers/{id}"), tools: ["unifi_delete_voucher"] },
|
|
81
|
+
{ method: "DELETE", template: v1("/sites/{id}/hotspot/vouchers"), query: ["filter"], requiredQuery: ["filter"], tools: ["unifi_bulk_delete_vouchers"] },
|
|
82
|
+
// Firewall zones and policies
|
|
83
|
+
{ method: "GET", template: v1("/sites/{id}/firewall/zones"), query: PAGE },
|
|
84
|
+
{ method: "GET", template: v1("/sites/{id}/firewall/zones/{id}") },
|
|
85
|
+
{ method: "POST", template: v1("/sites/{id}/firewall/zones"), tools: ["unifi_create_firewall_zone"] },
|
|
86
|
+
{ method: "PUT", template: v1("/sites/{id}/firewall/zones/{id}"), tools: ["unifi_update_firewall_zone"] },
|
|
87
|
+
{ method: "DELETE", template: v1("/sites/{id}/firewall/zones/{id}"), tools: ["unifi_delete_firewall_zone"] },
|
|
88
|
+
{ method: "GET", template: v1("/sites/{id}/firewall/policies"), query: PAGE },
|
|
89
|
+
{ method: "GET", template: v1("/sites/{id}/firewall/policies/{id}") },
|
|
90
|
+
{ method: "GET", template: v1("/sites/{id}/firewall/policies/ordering"), query: ZONE_PAIR, requiredQuery: ZONE_PAIR },
|
|
91
|
+
{ method: "POST", template: v1("/sites/{id}/firewall/policies"), tools: ["unifi_create_firewall_policy"] },
|
|
92
|
+
{ method: "PUT", template: v1("/sites/{id}/firewall/policies/{id}"), tools: ["unifi_update_firewall_policy"] },
|
|
93
|
+
{ method: "PATCH", template: v1("/sites/{id}/firewall/policies/{id}"), tools: ["unifi_patch_firewall_policy"] },
|
|
94
|
+
{ method: "DELETE", template: v1("/sites/{id}/firewall/policies/{id}"), tools: ["unifi_delete_firewall_policy"] },
|
|
95
|
+
{ method: "PUT", template: v1("/sites/{id}/firewall/policies/ordering"), query: ZONE_PAIR, requiredQuery: ZONE_PAIR, tools: ["unifi_reorder_firewall_policies"] },
|
|
96
|
+
// ACL rules
|
|
97
|
+
{ method: "GET", template: v1("/sites/{id}/acl-rules"), query: PAGE },
|
|
98
|
+
{ method: "GET", template: v1("/sites/{id}/acl-rules/{id}") },
|
|
99
|
+
{ method: "GET", template: v1("/sites/{id}/acl-rules/ordering") },
|
|
100
|
+
{ method: "POST", template: v1("/sites/{id}/acl-rules"), tools: ["unifi_create_acl_rule"] },
|
|
101
|
+
{ method: "PUT", template: v1("/sites/{id}/acl-rules/{id}"), tools: ["unifi_update_acl_rule"] },
|
|
102
|
+
{ method: "DELETE", template: v1("/sites/{id}/acl-rules/{id}"), tools: ["unifi_delete_acl_rule"] },
|
|
103
|
+
{ method: "PUT", template: v1("/sites/{id}/acl-rules/ordering"), tools: ["unifi_reorder_acl_rules"] },
|
|
104
|
+
// Switching
|
|
105
|
+
{ method: "GET", template: v1("/sites/{id}/switching/switch-stacks"), query: PAGE },
|
|
106
|
+
{ method: "GET", template: v1("/sites/{id}/switching/switch-stacks/{id}") },
|
|
107
|
+
{ method: "GET", template: v1("/sites/{id}/switching/mc-lag-domains"), query: PAGE },
|
|
108
|
+
{ method: "GET", template: v1("/sites/{id}/switching/mc-lag-domains/{id}") },
|
|
109
|
+
{ method: "GET", template: v1("/sites/{id}/switching/lags"), query: PAGE },
|
|
110
|
+
{ method: "GET", template: v1("/sites/{id}/switching/lags/{id}") },
|
|
111
|
+
// DNS policies
|
|
112
|
+
{ method: "GET", template: v1("/sites/{id}/dns/policies"), query: PAGE },
|
|
113
|
+
{ method: "GET", template: v1("/sites/{id}/dns/policies/{id}") },
|
|
114
|
+
{ method: "POST", template: v1("/sites/{id}/dns/policies"), tools: ["unifi_create_dns_policy"] },
|
|
115
|
+
{ method: "PUT", template: v1("/sites/{id}/dns/policies/{id}"), tools: ["unifi_update_dns_policy"] },
|
|
116
|
+
{ method: "DELETE", template: v1("/sites/{id}/dns/policies/{id}"), tools: ["unifi_delete_dns_policy"] },
|
|
117
|
+
// Traffic matching lists
|
|
118
|
+
{ method: "GET", template: v1("/sites/{id}/traffic-matching-lists"), query: PAGE },
|
|
119
|
+
{ method: "GET", template: v1("/sites/{id}/traffic-matching-lists/{id}") },
|
|
120
|
+
{ method: "POST", template: v1("/sites/{id}/traffic-matching-lists"), tools: ["unifi_create_traffic_matching_list"] },
|
|
121
|
+
{ method: "PUT", template: v1("/sites/{id}/traffic-matching-lists/{id}"), tools: ["unifi_update_traffic_matching_list"] },
|
|
122
|
+
{ method: "DELETE", template: v1("/sites/{id}/traffic-matching-lists/{id}"), tools: ["unifi_delete_traffic_matching_list"] },
|
|
123
|
+
];
|
|
124
|
+
const COMPILED = exports.ROUTES.map((route) => ({
|
|
125
|
+
route,
|
|
126
|
+
pattern: new RegExp(`^${route.template.replaceAll("{id}", ID)}$`),
|
|
127
|
+
}));
|
|
128
|
+
/**
|
|
129
|
+
* Check one request and return the route it matched. Throws GuardError,
|
|
130
|
+
* before anything is sent, for a malformed path, an unknown endpoint, an
|
|
131
|
+
* unexpected query parameter, or a write the policy does not allow.
|
|
132
|
+
*/
|
|
133
|
+
function guardRequest(method, path, policy) {
|
|
134
|
+
if (!METHODS.includes(method)) {
|
|
135
|
+
throw new GuardError("method", `method ${JSON.stringify(method)} is not used by any tool`);
|
|
136
|
+
}
|
|
137
|
+
const queryStart = path.indexOf("?");
|
|
138
|
+
const pathname = queryStart === -1 ? path : path.slice(0, queryStart);
|
|
139
|
+
const query = queryStart === -1 ? "" : path.slice(queryStart + 1);
|
|
140
|
+
// URL parsing would fold `..`, `\` and encoded segments out of the path, and
|
|
141
|
+
// `#` truncates it; `;` carries path parameters some servers honour. The
|
|
142
|
+
// tools never need any of these in a path, so refuse rather than normalise.
|
|
143
|
+
// eslint-disable-next-line no-control-regex
|
|
144
|
+
if (/\.\.|\/\/|%|\\|;|#|[\u0000-\u001f\u007f]/.test(pathname) || /#|[\u0000-\u001f\u007f]/.test(query)) {
|
|
145
|
+
throw new GuardError("malformed", `path ${JSON.stringify(pathname)} contains a sequence no tool sends`);
|
|
146
|
+
}
|
|
147
|
+
const match = COMPILED.find(({ route, pattern }) => route.method === method && pattern.test(pathname));
|
|
148
|
+
if (!match) {
|
|
149
|
+
throw new GuardError("not_allowlisted", `${method} ${pathname} is not an endpoint any tool uses`);
|
|
150
|
+
}
|
|
151
|
+
const { route } = match;
|
|
152
|
+
const keys = [...new URLSearchParams(query).keys()];
|
|
153
|
+
const unexpected = keys.filter((key) => !(route.query ?? []).includes(key));
|
|
154
|
+
if (unexpected.length > 0) {
|
|
155
|
+
throw new GuardError("query", `${method} ${pathname} does not take ${unexpected.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
const missing = (route.requiredQuery ?? []).filter((key) => !keys.includes(key));
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
throw new GuardError("query", `${method} ${pathname} needs ${missing.join(", ")}`);
|
|
160
|
+
}
|
|
161
|
+
if (route.tools !== undefined) {
|
|
162
|
+
if (policy.readOnly) {
|
|
163
|
+
throw new GuardError("read_only", `${method} ${pathname} is a write, and the server is read-only`);
|
|
164
|
+
}
|
|
165
|
+
const enabled = policy.writeTools;
|
|
166
|
+
if (enabled !== undefined && !route.tools.some((tool) => enabled.includes(tool))) {
|
|
167
|
+
throw new GuardError("tool_disabled", `${method} ${pathname} belongs to ${route.tools.join(", ")}, which UNIFI_NETWORK_WRITE_TOOLS does not enable`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return route;
|
|
171
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,23 +7,34 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
7
7
|
const config_js_1 = require("./config.js");
|
|
8
8
|
const client_js_1 = require("./client.js");
|
|
9
9
|
const index_js_1 = require("./tools/index.js");
|
|
10
|
+
const instructions_js_1 = require("./instructions.js");
|
|
11
|
+
const write_log_js_1 = require("./write-log.js");
|
|
10
12
|
// Read at runtime rather than importing: package.json sits outside rootDir,
|
|
11
13
|
// so a static import would emit dist/src/ and break the published layout.
|
|
12
14
|
const { version } = (0, node_module_1.createRequire)(__filename)("../package.json");
|
|
13
15
|
async function main() {
|
|
14
16
|
const config = (0, config_js_1.loadConfig)();
|
|
17
|
+
const writeLog = config.writeLog === undefined ? undefined : (0, write_log_js_1.openWriteLog)(config.writeLog, config.apiKey);
|
|
15
18
|
const client = new client_js_1.NetworkClient(config);
|
|
16
|
-
const server = new mcp_js_1.McpServer({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const server = new mcp_js_1.McpServer({ name: "unifi-network", title: "UniFi Network", version }, { instructions: (0, instructions_js_1.serverInstructions)(config.readOnly, { writeTools: config.writeTools, allowWanExposure: config.allowWanExposure === true }) });
|
|
20
|
+
(0, index_js_1.registerAllTools)(server, client, config.readOnly, {
|
|
21
|
+
writeTools: config.writeTools,
|
|
22
|
+
wan: { allowExposure: config.allowWanExposure === true, zoneIds: config.wanZoneIds ?? [] },
|
|
23
|
+
...(writeLog ? { writeLog } : {}),
|
|
19
24
|
});
|
|
20
|
-
(0, index_js_1.registerAllTools)(server, client, config.readOnly);
|
|
21
25
|
if (config.readOnly) {
|
|
22
26
|
console.error("UniFi Network MCP server running on stdio (READ-ONLY mode)");
|
|
23
27
|
}
|
|
28
|
+
else if (config.writeTools !== undefined) {
|
|
29
|
+
const tools = config.writeTools.length > 0 ? config.writeTools.join(", ") : "none";
|
|
30
|
+
console.error(`UniFi Network MCP server running on stdio (write tools: ${tools})`);
|
|
31
|
+
}
|
|
24
32
|
else {
|
|
25
33
|
console.error("UniFi Network MCP server running on stdio");
|
|
26
34
|
}
|
|
35
|
+
if (!config.readOnly && config.allowWanExposure === true) {
|
|
36
|
+
console.error("WAN-exposure refusals are off (UNIFI_NETWORK_ALLOW_WAN_EXPOSURE=true): such writes only need confirm.");
|
|
37
|
+
}
|
|
27
38
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
28
39
|
await server.connect(transport);
|
|
29
40
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server instructions: guidance that applies across tools. MCP hosts place
|
|
3
|
+
* these in the model's context once, so cross-tool workflow lives here rather
|
|
4
|
+
* than being restated in tool descriptions.
|
|
5
|
+
*/
|
|
6
|
+
export declare function serverInstructions(readOnly: boolean, { writeTools, allowWanExposure }?: {
|
|
7
|
+
writeTools?: readonly string[];
|
|
8
|
+
allowWanExposure?: boolean;
|
|
9
|
+
}): string;
|