@fruggr/zendesk-mcp-server 2.16.0 → 2.16.1
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/dist/index.js +376 -269
- package/package.json +2 -3
- package/dist/index.d.ts +0 -2
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -47,14 +47,22 @@ const REDACTED_KEYS = /* @__PURE__ */ new Set([
|
|
|
47
47
|
"bearer"
|
|
48
48
|
]);
|
|
49
49
|
const isSensitive = (key) => REDACTED_KEYS.has(key.toLowerCase().replace(/[_-]/g, ""));
|
|
50
|
-
const redactValue = (value) => {
|
|
51
|
-
if (
|
|
52
|
-
if (value
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
const redactValue = (value, path = /* @__PURE__ */ new WeakSet()) => {
|
|
51
|
+
if (typeof value === "function") return "[function]";
|
|
52
|
+
if (!value || typeof value !== "object") return value;
|
|
53
|
+
if (path.has(value)) return "[circular]";
|
|
54
|
+
path.add(value);
|
|
55
|
+
const out = Array.isArray(value) ? value.map((item) => redactValue(item, path)) : Object.fromEntries(Object.entries(value).map(([key, val]) => [key, isSensitive(key) ? "[REDACTED]" : redactValue(val, path)]));
|
|
56
|
+
path.delete(value);
|
|
57
|
+
return out;
|
|
58
|
+
};
|
|
59
|
+
const sanitise = (fields) => {
|
|
60
|
+
if (!fields) return {};
|
|
61
|
+
try {
|
|
62
|
+
return redactValue(fields);
|
|
63
|
+
} catch {
|
|
64
|
+
return { fields: "[unredactable]" };
|
|
56
65
|
}
|
|
57
|
-
return value;
|
|
58
66
|
};
|
|
59
67
|
const renderValue = (value) => {
|
|
60
68
|
if (typeof value === "string") return value;
|
|
@@ -82,8 +90,10 @@ const createLogger = (level) => {
|
|
|
82
90
|
let server;
|
|
83
91
|
const emit = (lvl, event, fields) => {
|
|
84
92
|
if (SEVERITY[lvl] < min) return;
|
|
85
|
-
const safe =
|
|
86
|
-
|
|
93
|
+
const safe = sanitise(fields);
|
|
94
|
+
try {
|
|
95
|
+
console.error(formatLine(lvl, event, safe));
|
|
96
|
+
} catch {}
|
|
87
97
|
if (server) try {
|
|
88
98
|
server.sendLoggingMessage({
|
|
89
99
|
level: MCP_LEVEL[lvl],
|
|
@@ -154,6 +164,8 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
|
|
|
154
164
|
* browser response; without escaping these are a reflected-XSS sink.
|
|
155
165
|
*/
|
|
156
166
|
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
167
|
+
const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
|
|
168
|
+
const SUCCESS_PAGE = "<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>";
|
|
157
169
|
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
158
170
|
const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
159
171
|
/**
|
|
@@ -179,69 +191,92 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
179
191
|
});
|
|
180
192
|
let authTimeout;
|
|
181
193
|
let callbackServer;
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
const exchangeCodeForToken = async (code) => {
|
|
195
|
+
const callbackPort = callbackServer.address().port;
|
|
196
|
+
const tokenBody = new URLSearchParams({
|
|
197
|
+
grant_type: "authorization_code",
|
|
198
|
+
code,
|
|
199
|
+
client_id: oauthClientId,
|
|
200
|
+
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
201
|
+
code_verifier: codeVerifier
|
|
202
|
+
});
|
|
203
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
206
|
+
body: tokenBody.toString()
|
|
207
|
+
});
|
|
208
|
+
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
209
|
+
if (!tokenResponse.ok) {
|
|
210
|
+
const errorBody = await tokenResponse.text();
|
|
211
|
+
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
188
212
|
}
|
|
189
|
-
|
|
213
|
+
return await tokenResponse.json();
|
|
214
|
+
};
|
|
215
|
+
const finishRequest = (res, { status, html, outcome }) => {
|
|
216
|
+
res.writeHead(status, { "Content-Type": "text/html" });
|
|
217
|
+
res.end(html);
|
|
218
|
+
clearTimeout(authTimeout);
|
|
219
|
+
callbackServer.close();
|
|
220
|
+
if (outcome.ok) resolveToken(outcome.token);
|
|
221
|
+
else rejectToken(outcome.error);
|
|
222
|
+
};
|
|
223
|
+
const resolveCallback = async (url) => {
|
|
190
224
|
const error = url.searchParams.get("error");
|
|
225
|
+
const code = url.searchParams.get("code");
|
|
191
226
|
logger.debug("oauth_callback_received", {
|
|
192
227
|
hasCode: Boolean(code),
|
|
193
228
|
hasError: Boolean(error)
|
|
194
229
|
});
|
|
195
230
|
if (error) {
|
|
196
231
|
const desc = url.searchParams.get("error_description") ?? error;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
206
|
-
res.end("<html><body><h1>Missing authorization code</h1></body></html>");
|
|
207
|
-
clearTimeout(authTimeout);
|
|
208
|
-
callbackServer.close();
|
|
209
|
-
rejectToken(/* @__PURE__ */ new Error("Missing authorization code in callback"));
|
|
210
|
-
return;
|
|
232
|
+
return {
|
|
233
|
+
status: 400,
|
|
234
|
+
html: errorPage("Authentication failed", desc),
|
|
235
|
+
outcome: {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: /* @__PURE__ */ new Error(`OAuth error: ${desc}`)
|
|
238
|
+
}
|
|
239
|
+
};
|
|
211
240
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
redirect_uri: `http://localhost:${callbackPort}/callback`,
|
|
219
|
-
code_verifier: codeVerifier
|
|
220
|
-
});
|
|
221
|
-
const tokenResponse = await fetch(tokenUrl, {
|
|
222
|
-
method: "POST",
|
|
223
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
224
|
-
body: tokenBody.toString()
|
|
225
|
-
});
|
|
226
|
-
logger.debug("oauth_token_exchange", { status: tokenResponse.status });
|
|
227
|
-
if (!tokenResponse.ok) {
|
|
228
|
-
const errorBody = await tokenResponse.text();
|
|
229
|
-
throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
|
|
241
|
+
if (!code) return {
|
|
242
|
+
status: 400,
|
|
243
|
+
html: errorPage("Missing authorization code"),
|
|
244
|
+
outcome: {
|
|
245
|
+
ok: false,
|
|
246
|
+
error: /* @__PURE__ */ new Error("Missing authorization code in callback")
|
|
230
247
|
}
|
|
231
|
-
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const token = await exchangeCodeForToken(code);
|
|
232
251
|
logger.info("oauth_authenticated");
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
252
|
+
return {
|
|
253
|
+
status: 200,
|
|
254
|
+
html: SUCCESS_PAGE,
|
|
255
|
+
outcome: {
|
|
256
|
+
ok: true,
|
|
257
|
+
token
|
|
258
|
+
}
|
|
259
|
+
};
|
|
238
260
|
} catch (err) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
261
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
262
|
+
return {
|
|
263
|
+
status: 500,
|
|
264
|
+
html: errorPage("Token exchange failed", detail),
|
|
265
|
+
outcome: {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: err
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
callbackServer = createServer(async (req, res) => {
|
|
273
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
274
|
+
if (url.pathname !== "/callback") {
|
|
275
|
+
res.writeHead(404);
|
|
276
|
+
res.end("Not found");
|
|
277
|
+
return;
|
|
244
278
|
}
|
|
279
|
+
finishRequest(res, await resolveCallback(url));
|
|
245
280
|
});
|
|
246
281
|
const requestedPort = config.callbackPort ?? 27439;
|
|
247
282
|
const onStartError = (err) => {
|
|
@@ -384,9 +419,10 @@ const isWindows = process.platform === "win32";
|
|
|
384
419
|
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
385
420
|
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
386
421
|
*/
|
|
422
|
+
const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
|
|
387
423
|
const appDirSegments = () => {
|
|
388
424
|
const { name } = readPackageInfo();
|
|
389
|
-
const scoped =
|
|
425
|
+
const scoped = SCOPED_PACKAGE_NAME.exec(name);
|
|
390
426
|
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
391
427
|
};
|
|
392
428
|
const configDir = () => {
|
|
@@ -522,20 +558,23 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
522
558
|
throw err;
|
|
523
559
|
});
|
|
524
560
|
};
|
|
561
|
+
const refreshIfPossible = async () => {
|
|
562
|
+
const current = token;
|
|
563
|
+
if (!current?.refreshToken) return void 0;
|
|
564
|
+
if (refreshing === void 0) refreshing = tryRefresh(current).finally(() => {
|
|
565
|
+
refreshing = void 0;
|
|
566
|
+
});
|
|
567
|
+
return refreshing;
|
|
568
|
+
};
|
|
525
569
|
const getToken = async () => {
|
|
526
|
-
if (refreshing) await refreshing;
|
|
570
|
+
if (refreshing !== void 0) await refreshing;
|
|
527
571
|
if (token && !needsRefresh(token)) {
|
|
528
572
|
logger.debug("oauth_token_cache_hit");
|
|
529
573
|
return token.accessToken;
|
|
530
574
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
535
|
-
const refreshed = await refreshing;
|
|
536
|
-
if (refreshed) return refreshed;
|
|
537
|
-
}
|
|
538
|
-
if (!starting) starting = beginAuth();
|
|
575
|
+
const refreshed = await refreshIfPossible();
|
|
576
|
+
if (refreshed) return refreshed;
|
|
577
|
+
if (starting === void 0) starting = beginAuth();
|
|
539
578
|
const url = authorizeUrl ?? await starting;
|
|
540
579
|
throw createAuthRequiredError(url);
|
|
541
580
|
};
|
|
@@ -657,76 +696,96 @@ const ConfigSchema = z.object({
|
|
|
657
696
|
corsOrigins: z.array(z.string().url().transform((value) => new URL(value).origin).refine((origin) => origin !== "null", { message: "CORS origin must be an http(s) URL with a host" })).default([]),
|
|
658
697
|
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
659
698
|
});
|
|
699
|
+
const DIGITS_ONLY = /^\d+$/;
|
|
660
700
|
const parsePort = (raw, label) => {
|
|
661
|
-
if (
|
|
701
|
+
if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
662
702
|
return Number(raw);
|
|
663
703
|
};
|
|
664
704
|
const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
|
|
705
|
+
const appendTo = (key) => (result, value) => {
|
|
706
|
+
const list = result[key] ?? [];
|
|
707
|
+
list.push(value);
|
|
708
|
+
result[key] = list;
|
|
709
|
+
};
|
|
710
|
+
const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
|
|
711
|
+
["--read-only", (result) => {
|
|
712
|
+
result.readOnly = true;
|
|
713
|
+
}],
|
|
714
|
+
["--no-topology", (result) => {
|
|
715
|
+
result.topology = false;
|
|
716
|
+
}],
|
|
717
|
+
["--no-promoted-articles", (result) => {
|
|
718
|
+
result.promotedArticles = false;
|
|
719
|
+
}],
|
|
720
|
+
["--dev", (result) => {
|
|
721
|
+
result.dev = true;
|
|
722
|
+
}]
|
|
723
|
+
]);
|
|
724
|
+
const VALUED_FLAGS = /* @__PURE__ */ new Map([
|
|
725
|
+
["--mode", (result, value) => {
|
|
726
|
+
result.mode = value;
|
|
727
|
+
}],
|
|
728
|
+
["--hc-resource-scheme", (result, value) => {
|
|
729
|
+
result.hcResourceScheme = value;
|
|
730
|
+
}],
|
|
731
|
+
["--log-level", (result, value) => {
|
|
732
|
+
result.logLevel = value;
|
|
733
|
+
}],
|
|
734
|
+
["--transport", (result, value) => {
|
|
735
|
+
result.transport = value;
|
|
736
|
+
}],
|
|
737
|
+
["--host", (result, value) => {
|
|
738
|
+
result.host = value;
|
|
739
|
+
}],
|
|
740
|
+
["--public-url", (result, value) => {
|
|
741
|
+
result.publicUrl = value;
|
|
742
|
+
}],
|
|
743
|
+
["--port", (result, value) => {
|
|
744
|
+
result.port = parsePort(value, "--port");
|
|
745
|
+
}],
|
|
746
|
+
["--callback-port", (result, value) => {
|
|
747
|
+
result.callbackPort = parsePort(value, "--callback-port");
|
|
748
|
+
}],
|
|
749
|
+
["--namespace", appendTo("namespaces")],
|
|
750
|
+
["--tool", appendTo("tools")],
|
|
751
|
+
["--cors-origin", appendTo("corsOrigins")]
|
|
752
|
+
]);
|
|
665
753
|
const parseCliArgs = (args) => {
|
|
666
754
|
const result = {};
|
|
667
|
-
let positionalIndex = 0;
|
|
668
755
|
for (let i = 0; i < args.length; i++) {
|
|
669
756
|
const arg = args[i];
|
|
670
757
|
if (arg === void 0) continue;
|
|
758
|
+
const standalone = STANDALONE_FLAGS.get(arg);
|
|
759
|
+
if (standalone) {
|
|
760
|
+
standalone(result);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const valued = VALUED_FLAGS.get(arg);
|
|
671
764
|
const next = args[i + 1];
|
|
672
|
-
if (
|
|
673
|
-
result
|
|
674
|
-
i++;
|
|
675
|
-
} else if (arg === "--read-only") result.readOnly = true;
|
|
676
|
-
else if (arg === "--no-topology") result.topology = false;
|
|
677
|
-
else if (arg === "--no-promoted-articles") result.promotedArticles = false;
|
|
678
|
-
else if (arg === "--hc-resource-scheme" && next) {
|
|
679
|
-
result.hcResourceScheme = next;
|
|
680
|
-
i++;
|
|
681
|
-
} else if (arg === "--dev") result.dev = true;
|
|
682
|
-
else if (arg === "--namespace" && next) {
|
|
683
|
-
result.namespaces = result.namespaces ?? [];
|
|
684
|
-
result.namespaces.push(next);
|
|
685
|
-
i++;
|
|
686
|
-
} else if (arg === "--tool" && next) {
|
|
687
|
-
result.tools = result.tools ?? [];
|
|
688
|
-
result.tools.push(next);
|
|
689
|
-
i++;
|
|
690
|
-
} else if (arg === "--log-level" && next) {
|
|
691
|
-
result.logLevel = next;
|
|
692
|
-
i++;
|
|
693
|
-
} else if (arg === "--transport" && next) {
|
|
694
|
-
result.transport = next;
|
|
765
|
+
if (valued && next) {
|
|
766
|
+
valued(result, next);
|
|
695
767
|
i++;
|
|
696
|
-
|
|
697
|
-
result.host = next;
|
|
698
|
-
i++;
|
|
699
|
-
} else if (arg === "--port" && next) {
|
|
700
|
-
result.port = parsePort(next, "--port");
|
|
701
|
-
i++;
|
|
702
|
-
} else if (arg === "--public-url" && next) {
|
|
703
|
-
result.publicUrl = next;
|
|
704
|
-
i++;
|
|
705
|
-
} else if (arg === "--cors-origin" && next) {
|
|
706
|
-
result.corsOrigins = result.corsOrigins ?? [];
|
|
707
|
-
result.corsOrigins.push(next);
|
|
708
|
-
i++;
|
|
709
|
-
} else if (arg === "--callback-port" && next) {
|
|
710
|
-
result.callbackPort = parsePort(next, "--callback-port");
|
|
711
|
-
i++;
|
|
712
|
-
} else if (!arg.startsWith("-") && positionalIndex === 0) {
|
|
713
|
-
result.subdomain = arg;
|
|
714
|
-
positionalIndex++;
|
|
768
|
+
continue;
|
|
715
769
|
}
|
|
770
|
+
if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
|
|
716
771
|
}
|
|
717
772
|
return result;
|
|
718
773
|
};
|
|
774
|
+
const resolveTransportSettings = (cli) => {
|
|
775
|
+
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
776
|
+
return {
|
|
777
|
+
transport: cli.transport ?? process.env["TRANSPORT"] ?? "stdio",
|
|
778
|
+
host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
|
|
779
|
+
port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
|
|
780
|
+
publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
|
|
781
|
+
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
782
|
+
};
|
|
783
|
+
};
|
|
719
784
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
720
785
|
const cli = parseCliArgs(argv);
|
|
721
786
|
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
722
787
|
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
723
788
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
724
|
-
const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
|
|
725
|
-
const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
|
|
726
|
-
const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
|
|
727
|
-
const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
|
|
728
|
-
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
729
|
-
const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
|
|
730
789
|
const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
731
790
|
const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
|
|
732
791
|
return ConfigSchema.parse({
|
|
@@ -741,12 +800,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
741
800
|
promotedArticles: cli.promotedArticles ?? true,
|
|
742
801
|
hcResourceScheme,
|
|
743
802
|
dev: cli.dev ?? false,
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
port,
|
|
747
|
-
publicUrl,
|
|
748
|
-
corsOrigins,
|
|
749
|
-
callbackPort
|
|
803
|
+
callbackPort,
|
|
804
|
+
...resolveTransportSettings(cli)
|
|
750
805
|
});
|
|
751
806
|
};
|
|
752
807
|
//#endregion
|
|
@@ -893,10 +948,11 @@ const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
|
893
948
|
"h2",
|
|
894
949
|
"h3"
|
|
895
950
|
]);
|
|
951
|
+
const WHITESPACE_RUN = /\s+/;
|
|
896
952
|
const countWords = (text) => {
|
|
897
953
|
const trimmed = text.trim();
|
|
898
954
|
if (!trimmed) return 0;
|
|
899
|
-
return trimmed.split(
|
|
955
|
+
return trimmed.split(WHITESPACE_RUN).length;
|
|
900
956
|
};
|
|
901
957
|
const textOf = (html) => {
|
|
902
958
|
if (!html) return "";
|
|
@@ -1000,7 +1056,11 @@ const formatTicket = (ticket) => [
|
|
|
1000
1056
|
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
1001
1057
|
ticket.description ? `\n${ticket.description}` : ""
|
|
1002
1058
|
].filter(Boolean).join("\n");
|
|
1003
|
-
const formatConditionValue = (value) =>
|
|
1059
|
+
const formatConditionValue = (value) => {
|
|
1060
|
+
if (value === null || value === void 0) return "";
|
|
1061
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1062
|
+
return String(value);
|
|
1063
|
+
};
|
|
1004
1064
|
const formatSlaPolicy = (policy) => {
|
|
1005
1065
|
const conditions = [...policy.filter.all.map((c) => `all: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim()), ...policy.filter.any.map((c) => `any: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim())];
|
|
1006
1066
|
const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
|
|
@@ -1493,20 +1553,20 @@ const fetchTopology = async (subdomain, token) => {
|
|
|
1493
1553
|
currentUser: meRes.user
|
|
1494
1554
|
};
|
|
1495
1555
|
};
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1556
|
+
const renderOversizedTreeNotice = (data) => {
|
|
1557
|
+
const reasons = [];
|
|
1558
|
+
if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
|
|
1559
|
+
if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
|
|
1560
|
+
return [
|
|
1561
|
+
`Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
|
|
1562
|
+
data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
|
|
1563
|
+
...data.categories.map(formatCategory),
|
|
1564
|
+
"",
|
|
1565
|
+
...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
|
|
1566
|
+
"Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
|
|
1567
|
+
];
|
|
1568
|
+
};
|
|
1569
|
+
const renderCategoryTree = (data) => {
|
|
1510
1570
|
const byCategory = /* @__PURE__ */ new Map();
|
|
1511
1571
|
for (const section of data.sections) {
|
|
1512
1572
|
const list = byCategory.get(section.category_id) ?? [];
|
|
@@ -1520,6 +1580,7 @@ const renderTree = (data) => {
|
|
|
1520
1580
|
}
|
|
1521
1581
|
return lines.length ? lines : ["_(no categories)_"];
|
|
1522
1582
|
};
|
|
1583
|
+
const renderTree = (data) => data.categoriesHasMore || data.sectionsHasMore ? renderOversizedTreeNotice(data) : renderCategoryTree(data);
|
|
1523
1584
|
/**
|
|
1524
1585
|
* Render an admin-gated section as one of three states so the LLM never mistakes
|
|
1525
1586
|
* "you can't see this" for "there are none": the formatted list, `_(none)_` when
|
|
@@ -1686,6 +1747,60 @@ const largeArticleHint = (body, sectionCount) => {
|
|
|
1686
1747
|
].join("\n");
|
|
1687
1748
|
};
|
|
1688
1749
|
const autoSortNotice = (sectionId, applied) => [applied === void 0 ? `Section #${sectionId} looks like it is sorted automatically, so a manual reorder would have no visible effect.` : `Wrote ${applied} article position(s), but the display order of section #${sectionId} did not change — the section is sorted automatically, so positions are ignored.`, "To order its articles manually: in Guide, open the section, choose \"Edit section\", set \"Order articles by\" to Manual, then re-run this tool."].join(" ");
|
|
1750
|
+
const renderFreshnessLine = (sourceUpdatedAt, targetUpdatedAt, targetLocale) => {
|
|
1751
|
+
const srcMs = Date.parse(sourceUpdatedAt);
|
|
1752
|
+
const tgtMs = Date.parse(targetUpdatedAt);
|
|
1753
|
+
const label = `- **Freshness (target ${targetLocale})**`;
|
|
1754
|
+
if (!Number.isFinite(srcMs) || !Number.isFinite(tgtMs)) return `${label}: unknown (could not compare edit timestamps).`;
|
|
1755
|
+
if (srcMs <= tgtMs) return `${label}: up to date (source has not been edited since this translation).`;
|
|
1756
|
+
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
1757
|
+
return `${label}: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
1758
|
+
};
|
|
1759
|
+
const renderOutdatedLine = (translations, targetLocale) => {
|
|
1760
|
+
const targetLocaleKey = targetLocale.toLowerCase();
|
|
1761
|
+
const entry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
1762
|
+
const label = `- **Zendesk outdated flag (target ${targetLocale})**`;
|
|
1763
|
+
if (entry?.outdated === void 0) return `${label}: unknown.`;
|
|
1764
|
+
if (entry.outdated) return `${label}: yes — explicitly marked out of date in Guide.`;
|
|
1765
|
+
return `${label}: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).`;
|
|
1766
|
+
};
|
|
1767
|
+
const renderStructureLine = (sourceSections, targetSections) => {
|
|
1768
|
+
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
1769
|
+
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
1770
|
+
return sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
|
|
1771
|
+
};
|
|
1772
|
+
const sectionRowStatus = (source, target) => {
|
|
1773
|
+
if (!target) return "missing";
|
|
1774
|
+
if (!source) return "extra";
|
|
1775
|
+
return "ok";
|
|
1776
|
+
};
|
|
1777
|
+
const renderSectionRows = (sourceSections, targetSections) => {
|
|
1778
|
+
const rows = ["| Idx | Heading | Status | Source words | Target words |", "| --- | --- | --- | --- | --- |"];
|
|
1779
|
+
const maxLen = Math.max(sourceSections.length, targetSections.length);
|
|
1780
|
+
for (let i = 0; i < maxLen; i += 1) {
|
|
1781
|
+
const src = sourceSections[i];
|
|
1782
|
+
const tgt = targetSections[i];
|
|
1783
|
+
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
1784
|
+
const status = sectionRowStatus(src, tgt);
|
|
1785
|
+
rows.push(`| ${i} | ${heading} | ${status} | ${src?.wordCount ?? 0} | ${tgt?.wordCount ?? 0} |`);
|
|
1786
|
+
}
|
|
1787
|
+
return rows;
|
|
1788
|
+
};
|
|
1789
|
+
const localePrefix = (locale) => locale ? `/${locale}` : "";
|
|
1790
|
+
const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
|
|
1791
|
+
const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
|
|
1792
|
+
const scanCostNote = (truncated, pagesScanned, cost) => {
|
|
1793
|
+
if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
|
|
1794
|
+
if (pagesScanned > 1) return `\n\n_Note: this scan cost ${cost}; this tool performs a fresh scan every call (no caching), so avoid calling it again right away._`;
|
|
1795
|
+
return "";
|
|
1796
|
+
};
|
|
1797
|
+
const needsReferenceArticle = (target) => target === "before" || target === "after";
|
|
1798
|
+
const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
|
|
1799
|
+
const needsReference = needsReferenceArticle(target);
|
|
1800
|
+
if (needsReference && referenceArticleId === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
|
|
1801
|
+
if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
1802
|
+
if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
|
|
1803
|
+
};
|
|
1689
1804
|
const createHelpCenterTools = (ctx) => {
|
|
1690
1805
|
const { subdomain, getToken } = ctx;
|
|
1691
1806
|
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
@@ -1703,6 +1818,26 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1703
1818
|
} while (cursor);
|
|
1704
1819
|
return order;
|
|
1705
1820
|
};
|
|
1821
|
+
const assertReferenceInSection = async (effective, referenceArticleId, articleId, sectionId, token) => {
|
|
1822
|
+
if (effective.some((a) => a.id === referenceArticleId)) return;
|
|
1823
|
+
let detail = "was not found";
|
|
1824
|
+
try {
|
|
1825
|
+
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${referenceArticleId}`);
|
|
1826
|
+
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
1827
|
+
} catch {}
|
|
1828
|
+
throw new Error(`Reference article #${referenceArticleId} ${detail}. It must be in the same section (#${sectionId}) as article #${articleId}.`);
|
|
1829
|
+
};
|
|
1830
|
+
const applyPositionWrites = async (writes, articleId, token) => {
|
|
1831
|
+
let applied = 0;
|
|
1832
|
+
for (const write of writes) try {
|
|
1833
|
+
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
1834
|
+
applied += 1;
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1837
|
+
throw new Error(`Reorder of article #${articleId} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
|
|
1838
|
+
}
|
|
1839
|
+
return applied;
|
|
1840
|
+
};
|
|
1706
1841
|
return [
|
|
1707
1842
|
{
|
|
1708
1843
|
name: "search_articles",
|
|
@@ -1785,7 +1920,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1785
1920
|
handler: async (params) => {
|
|
1786
1921
|
const { locale, page_size, cursor } = params;
|
|
1787
1922
|
const token = await getToken();
|
|
1788
|
-
const path =
|
|
1923
|
+
const path = `${localePrefix(locale)}/categories`;
|
|
1789
1924
|
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
1790
1925
|
const categories = response.categories ?? [];
|
|
1791
1926
|
return { content: [{
|
|
@@ -1815,8 +1950,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1815
1950
|
handler: async (params) => {
|
|
1816
1951
|
const { category_id, locale, page_size, cursor } = params;
|
|
1817
1952
|
const token = await getToken();
|
|
1818
|
-
const
|
|
1819
|
-
const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
|
|
1953
|
+
const response = await helpCenterGet(subdomain, token, sectionListPath(category_id, locale), buildCursorParams(page_size, cursor));
|
|
1820
1954
|
const sections = response.sections ?? [];
|
|
1821
1955
|
return { content: [{
|
|
1822
1956
|
type: "text",
|
|
@@ -1853,8 +1987,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1853
1987
|
handler: async (params) => {
|
|
1854
1988
|
const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
|
|
1855
1989
|
const token = await getToken();
|
|
1856
|
-
const
|
|
1857
|
-
const response = await helpCenterGet(subdomain, token, path, {
|
|
1990
|
+
const response = await helpCenterGet(subdomain, token, articleListPath(section_id, locale), {
|
|
1858
1991
|
...buildCursorParams(page_size, cursor),
|
|
1859
1992
|
sort_by,
|
|
1860
1993
|
sort_order
|
|
@@ -1896,7 +2029,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1896
2029
|
const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
|
|
1897
2030
|
return { content: [{
|
|
1898
2031
|
type: "text",
|
|
1899
|
-
text: truncateIfNeeded(`${header}\n\n${body}${truncated
|
|
2032
|
+
text: truncateIfNeeded(`${header}\n\n${body}${scanCostNote(truncated, pagesScanned, cost)}`)
|
|
1900
2033
|
}] };
|
|
1901
2034
|
}
|
|
1902
2035
|
},
|
|
@@ -2108,23 +2241,14 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2108
2241
|
},
|
|
2109
2242
|
handler: async (params) => {
|
|
2110
2243
|
const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
if (!needsReference && reference_article_id !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
2114
|
-
if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
|
|
2244
|
+
assertReorderParamsCoherent(article_id, target, reference_article_id);
|
|
2245
|
+
const needsReference = needsReferenceArticle(target);
|
|
2115
2246
|
const token = await getToken();
|
|
2116
2247
|
const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
|
|
2117
2248
|
const sectionId = article.section_id;
|
|
2118
2249
|
const locale = article.source_locale;
|
|
2119
2250
|
const effective = await fetchSectionOrder(sectionId, locale, token);
|
|
2120
|
-
if (
|
|
2121
|
-
let detail = "was not found";
|
|
2122
|
-
try {
|
|
2123
|
-
const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
|
|
2124
|
-
detail = `is in section #${ref.section_id}, not section #${sectionId}`;
|
|
2125
|
-
} catch {}
|
|
2126
|
-
throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
|
|
2127
|
-
}
|
|
2251
|
+
if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
|
|
2128
2252
|
const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
|
|
2129
2253
|
const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
|
|
2130
2254
|
if (writes.length === 0) return { content: [{
|
|
@@ -2139,14 +2263,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2139
2263
|
type: "text",
|
|
2140
2264
|
text: `Reordering article #${article_id} to ${targetLabel} would reposition ${writes.length} articles in section #${sectionId}, above the safety threshold of ${REORDER_CONFIRM_THRESHOLD}. Re-run with confirm: true to proceed.`
|
|
2141
2265
|
}] };
|
|
2142
|
-
|
|
2143
|
-
for (const write of writes) try {
|
|
2144
|
-
await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
|
|
2145
|
-
applied += 1;
|
|
2146
|
-
} catch (error) {
|
|
2147
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
2148
|
-
throw new Error(`Reorder of article #${article_id} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
|
|
2149
|
-
}
|
|
2266
|
+
const applied = await applyPositionWrites(writes, article_id, token);
|
|
2150
2267
|
if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
|
|
2151
2268
|
type: "text",
|
|
2152
2269
|
text: autoSortNotice(sectionId, applied)
|
|
@@ -2458,40 +2575,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2458
2575
|
]);
|
|
2459
2576
|
const sourceSections = parseSections(sourceRes.translation.body);
|
|
2460
2577
|
const targetSections = parseSections(targetRes.translation.body);
|
|
2461
|
-
const
|
|
2462
|
-
const
|
|
2463
|
-
const
|
|
2464
|
-
const
|
|
2465
|
-
const tgtMs = Date.parse(targetUpdated);
|
|
2466
|
-
const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
|
|
2467
|
-
let freshnessLine;
|
|
2468
|
-
if (comparable && srcMs > tgtMs) {
|
|
2469
|
-
const days = Math.floor((srcMs - tgtMs) / 864e5);
|
|
2470
|
-
freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
|
|
2471
|
-
} else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
|
|
2472
|
-
else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
|
|
2473
|
-
const targetLocaleKey = target_locale.toLowerCase();
|
|
2474
|
-
const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
|
|
2475
|
-
const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
|
|
2476
|
-
const outdatedLine = outdated === "yes" ? `- **Zendesk outdated flag (target ${target_locale})**: yes — explicitly marked out of date in Guide.` : outdated === "no" ? `- **Zendesk outdated flag (target ${target_locale})**: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).` : `- **Zendesk outdated flag (target ${target_locale})**: unknown.`;
|
|
2477
|
-
const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
|
|
2478
|
-
const targetTags = targetSections.map((s) => s.headingTag).join(",");
|
|
2479
|
-
const structureLine = sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
|
|
2480
|
-
const rows = [];
|
|
2481
|
-
rows.push(`| Idx | Heading | Status | Source words | Target words |`);
|
|
2482
|
-
rows.push(`| --- | --- | --- | --- | --- |`);
|
|
2483
|
-
for (let i = 0; i < maxLen; i += 1) {
|
|
2484
|
-
const src = sourceSections[i];
|
|
2485
|
-
const tgt = targetSections[i];
|
|
2486
|
-
const heading = src?.heading ?? tgt?.heading ?? "";
|
|
2487
|
-
const sourceWords = src?.wordCount ?? 0;
|
|
2488
|
-
const targetWords = tgt?.wordCount ?? 0;
|
|
2489
|
-
let status;
|
|
2490
|
-
if (!tgt) status = "missing";
|
|
2491
|
-
else if (!src) status = "extra";
|
|
2492
|
-
else status = "ok";
|
|
2493
|
-
rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
|
|
2494
|
-
}
|
|
2578
|
+
const freshnessLine = renderFreshnessLine(sourceRes.translation.updated_at, targetRes.translation.updated_at, target_locale);
|
|
2579
|
+
const outdatedLine = renderOutdatedLine(translations, target_locale);
|
|
2580
|
+
const structureLine = renderStructureLine(sourceSections, targetSections);
|
|
2581
|
+
const rows = renderSectionRows(sourceSections, targetSections);
|
|
2495
2582
|
return { content: [{
|
|
2496
2583
|
type: "text",
|
|
2497
2584
|
text: [
|
|
@@ -2500,7 +2587,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2500
2587
|
freshnessLine,
|
|
2501
2588
|
outdatedLine,
|
|
2502
2589
|
structureLine,
|
|
2503
|
-
`- **Updated**: source ${
|
|
2590
|
+
`- **Updated**: source ${sourceRes.translation.updated_at} | target ${targetRes.translation.updated_at}`,
|
|
2504
2591
|
`- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
|
|
2505
2592
|
"",
|
|
2506
2593
|
"_Word counts are informational: a length difference between languages is normal, not a divergence._",
|
|
@@ -2596,6 +2683,7 @@ const createSearchTools = (ctx) => {
|
|
|
2596
2683
|
};
|
|
2597
2684
|
//#endregion
|
|
2598
2685
|
//#region src/tools/tickets.ts
|
|
2686
|
+
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2));
|
|
2599
2687
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
2600
2688
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
2601
2689
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -2624,6 +2712,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
2624
2712
|
}
|
|
2625
2713
|
return all;
|
|
2626
2714
|
};
|
|
2715
|
+
const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
2716
|
+
const attachments = [];
|
|
2717
|
+
for (const id of ids) try {
|
|
2718
|
+
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
2719
|
+
attachments.push(attachment);
|
|
2720
|
+
} catch (error) {
|
|
2721
|
+
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
2722
|
+
}
|
|
2723
|
+
return attachments;
|
|
2724
|
+
};
|
|
2627
2725
|
const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
2628
2726
|
const blocks = [];
|
|
2629
2727
|
let embeddedCount = 0;
|
|
@@ -2637,7 +2735,7 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2637
2735
|
continue;
|
|
2638
2736
|
}
|
|
2639
2737
|
let skipReason = null;
|
|
2640
|
-
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${
|
|
2738
|
+
if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
|
|
2641
2739
|
else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
|
|
2642
2740
|
if (skipReason) {
|
|
2643
2741
|
blocks.push({
|
|
@@ -2659,9 +2757,10 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
2659
2757
|
}
|
|
2660
2758
|
return blocks;
|
|
2661
2759
|
};
|
|
2760
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
2662
2761
|
const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
2663
2762
|
const day = ticket.created_at.slice(0, 10);
|
|
2664
|
-
if (
|
|
2763
|
+
if (!ISO_DAY.test(day)) return void 0;
|
|
2665
2764
|
const shiftDay = (offset) => {
|
|
2666
2765
|
const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
|
|
2667
2766
|
d.setUTCDate(d.getUTCDate() + offset);
|
|
@@ -2731,23 +2830,24 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
|
|
|
2731
2830
|
const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
|
|
2732
2831
|
return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
2733
2832
|
};
|
|
2833
|
+
const addPositiveId = (set, raw) => {
|
|
2834
|
+
const n = Number(raw);
|
|
2835
|
+
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2836
|
+
};
|
|
2837
|
+
const collectEventIds = (event, userIds, groupIds) => {
|
|
2838
|
+
if (event.type !== "Change" && event.type !== "Create") return;
|
|
2839
|
+
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2840
|
+
if (!entity) return;
|
|
2841
|
+
const set = entity === "user" ? userIds : groupIds;
|
|
2842
|
+
addPositiveId(set, event.value);
|
|
2843
|
+
addPositiveId(set, event.previous_value);
|
|
2844
|
+
};
|
|
2734
2845
|
const collectAuditIds = (audits) => {
|
|
2735
2846
|
const userIds = /* @__PURE__ */ new Set();
|
|
2736
2847
|
const groupIds = /* @__PURE__ */ new Set();
|
|
2737
|
-
const addId = (set, raw) => {
|
|
2738
|
-
const n = Number(raw);
|
|
2739
|
-
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2740
|
-
};
|
|
2741
2848
|
for (const audit of audits) {
|
|
2742
|
-
|
|
2743
|
-
for (const event of audit.events)
|
|
2744
|
-
if (event.type !== "Change" && event.type !== "Create") continue;
|
|
2745
|
-
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2746
|
-
if (!entity) continue;
|
|
2747
|
-
const set = entity === "user" ? userIds : groupIds;
|
|
2748
|
-
addId(set, event.value);
|
|
2749
|
-
addId(set, event.previous_value);
|
|
2750
|
-
}
|
|
2849
|
+
addPositiveId(userIds, audit.author_id);
|
|
2850
|
+
for (const event of audit.events) collectEventIds(event, userIds, groupIds);
|
|
2751
2851
|
}
|
|
2752
2852
|
return {
|
|
2753
2853
|
userIds: [...userIds],
|
|
@@ -2790,12 +2890,11 @@ const diffLine = (label, before, after) => {
|
|
|
2790
2890
|
const a = shownValue(after);
|
|
2791
2891
|
return b === a ? null : `- **${label}**: ${b} → ${a}`;
|
|
2792
2892
|
};
|
|
2793
|
-
const
|
|
2794
|
-
|
|
2795
|
-
const beforeObj = before
|
|
2796
|
-
const comment = after.comment ?? result?.comment;
|
|
2893
|
+
const asRecord = (value) => value ?? {};
|
|
2894
|
+
const diffStandardFields = (before, after) => {
|
|
2895
|
+
const beforeObj = asRecord(before);
|
|
2797
2896
|
const changes = [];
|
|
2798
|
-
for (const [key, afterVal] of Object.entries(after)) {
|
|
2897
|
+
for (const [key, afterVal] of Object.entries(asRecord(after))) {
|
|
2799
2898
|
if (DIFF_SKIP_KEYS.has(key)) continue;
|
|
2800
2899
|
const beforeVal = beforeObj[key];
|
|
2801
2900
|
if (valuesEqual(beforeVal, afterVal)) continue;
|
|
@@ -2808,12 +2907,22 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
|
2808
2907
|
const line = diffLine(key, beforeVal, afterVal);
|
|
2809
2908
|
if (line) changes.push(line);
|
|
2810
2909
|
}
|
|
2811
|
-
|
|
2910
|
+
return changes;
|
|
2911
|
+
};
|
|
2912
|
+
const diffCustomFields = (before, after) => {
|
|
2913
|
+
const afterFields = [after?.fields ?? after?.custom_fields ?? []].flat();
|
|
2812
2914
|
const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
|
|
2915
|
+
const changes = [];
|
|
2813
2916
|
for (const f of afterFields) {
|
|
2814
2917
|
const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
|
|
2815
2918
|
if (line) changes.push(line);
|
|
2816
2919
|
}
|
|
2920
|
+
return changes;
|
|
2921
|
+
};
|
|
2922
|
+
const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
2923
|
+
const after = result?.ticket ?? {};
|
|
2924
|
+
const comment = after.comment ?? result?.comment;
|
|
2925
|
+
const changes = [...diffStandardFields(before, after), ...diffCustomFields(before, after)];
|
|
2817
2926
|
const lines = [
|
|
2818
2927
|
`# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
|
|
2819
2928
|
"",
|
|
@@ -2939,16 +3048,7 @@ const createTicketTools = (ctx) => {
|
|
|
2939
3048
|
handler: async (params) => {
|
|
2940
3049
|
const { ticket_id, attachment_ids } = params;
|
|
2941
3050
|
const token = await getToken();
|
|
2942
|
-
|
|
2943
|
-
if (attachment_ids && attachment_ids.length > 0) {
|
|
2944
|
-
attachments = [];
|
|
2945
|
-
for (const id of attachment_ids) try {
|
|
2946
|
-
const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
|
|
2947
|
-
attachments.push(attachment);
|
|
2948
|
-
} catch (error) {
|
|
2949
|
-
if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
|
|
2950
|
-
}
|
|
2951
|
-
} else attachments = (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
3051
|
+
const attachments = attachment_ids && attachment_ids.length > 0 ? await fetchAttachmentsByIds(subdomain, token, attachment_ids) : (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
|
|
2952
3052
|
if (attachments.length === 0) return { content: [{
|
|
2953
3053
|
type: "text",
|
|
2954
3054
|
text: `No attachments found on ticket #${ticket_id}.`
|
|
@@ -3753,6 +3853,10 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3753
3853
|
case "single":
|
|
3754
3854
|
registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
|
|
3755
3855
|
break;
|
|
3856
|
+
default: {
|
|
3857
|
+
const unhandled = config.mode;
|
|
3858
|
+
throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
|
|
3859
|
+
}
|
|
3756
3860
|
}
|
|
3757
3861
|
if (helpCenterContextEnabled(config)) {
|
|
3758
3862
|
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
@@ -3979,6 +4083,7 @@ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
|
3979
4083
|
"::",
|
|
3980
4084
|
"*"
|
|
3981
4085
|
]);
|
|
4086
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
3982
4087
|
const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
|
|
3983
4088
|
"https://chatgpt.com",
|
|
3984
4089
|
"https://chat.openai.com",
|
|
@@ -4054,7 +4159,7 @@ const handleCorsPreflight = (req, res, extraOrigins) => {
|
|
|
4054
4159
|
return true;
|
|
4055
4160
|
};
|
|
4056
4161
|
const resolveResourceUrl = (config, logger = silentLogger) => {
|
|
4057
|
-
if (config.publicUrl) return config.publicUrl.replace(
|
|
4162
|
+
if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
|
|
4058
4163
|
if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
|
|
4059
4164
|
logger.warn("public_url_unset", {
|
|
4060
4165
|
host: config.host,
|
|
@@ -4111,6 +4216,14 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
|
|
|
4111
4216
|
jsonrpc: "2.0"
|
|
4112
4217
|
}));
|
|
4113
4218
|
};
|
|
4219
|
+
const failRequest = (res, err) => {
|
|
4220
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
4221
|
+
if (!res.headersSent) {
|
|
4222
|
+
sendJsonRpcError(res, 500, -32603, message);
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
if (!res.writableEnded) res.end();
|
|
4226
|
+
};
|
|
4114
4227
|
const sendUnauthorized = (res, resource) => {
|
|
4115
4228
|
const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
4116
4229
|
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
|
|
@@ -4181,6 +4294,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4181
4294
|
const sessions = /* @__PURE__ */ new Map();
|
|
4182
4295
|
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
|
|
4183
4296
|
const maxBodyBytes = options.maxBodyBytes ?? 4194304;
|
|
4297
|
+
const dispatchToSession = async (req, res, sessionId, bearer) => {
|
|
4298
|
+
const session = sessions.get(sessionId);
|
|
4299
|
+
if (!session) return false;
|
|
4300
|
+
session.auth.bearer = bearer;
|
|
4301
|
+
session.lastActivityAt = Date.now();
|
|
4302
|
+
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
4303
|
+
ok: true,
|
|
4304
|
+
value: void 0
|
|
4305
|
+
};
|
|
4306
|
+
if (!body.ok) {
|
|
4307
|
+
respondBodyError(req, res, body);
|
|
4308
|
+
return true;
|
|
4309
|
+
}
|
|
4310
|
+
await session.transport.handleRequest(req, res, body.value);
|
|
4311
|
+
return true;
|
|
4312
|
+
};
|
|
4184
4313
|
const handleMcpRequest = async (req, res) => {
|
|
4185
4314
|
const bearer = extractBearer(req);
|
|
4186
4315
|
if (!bearer) {
|
|
@@ -4188,23 +4317,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4188
4317
|
return;
|
|
4189
4318
|
}
|
|
4190
4319
|
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
4191
|
-
if (sessionId)
|
|
4192
|
-
const session = sessions.get(sessionId);
|
|
4193
|
-
if (session) {
|
|
4194
|
-
session.auth.bearer = bearer;
|
|
4195
|
-
session.lastActivityAt = Date.now();
|
|
4196
|
-
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
4197
|
-
ok: true,
|
|
4198
|
-
value: void 0
|
|
4199
|
-
};
|
|
4200
|
-
if (!body.ok) {
|
|
4201
|
-
respondBodyError(req, res, body);
|
|
4202
|
-
return;
|
|
4203
|
-
}
|
|
4204
|
-
await session.transport.handleRequest(req, res, body.value);
|
|
4205
|
-
return;
|
|
4206
|
-
}
|
|
4207
|
-
}
|
|
4320
|
+
if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
|
|
4208
4321
|
if (req.method !== "POST") {
|
|
4209
4322
|
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
4210
4323
|
return;
|
|
@@ -4236,24 +4349,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4236
4349
|
await server.connect(transport);
|
|
4237
4350
|
await transport.handleRequest(req, res, body.value);
|
|
4238
4351
|
};
|
|
4352
|
+
const staticGetRoutes = {
|
|
4353
|
+
"/.well-known/oauth-protected-resource": metadata.protectedResource,
|
|
4354
|
+
"/.well-known/oauth-authorization-server": metadata.authorizationServer,
|
|
4355
|
+
"/healthz": {
|
|
4356
|
+
status: "ok",
|
|
4357
|
+
subdomain: config.subdomain
|
|
4358
|
+
}
|
|
4359
|
+
};
|
|
4239
4360
|
const requestListener = async (req, res) => {
|
|
4240
4361
|
try {
|
|
4241
4362
|
if (handleCorsPreflight(req, res, config.corsOrigins)) return;
|
|
4242
4363
|
applyCorsHeaders(req, res, config.corsOrigins);
|
|
4243
4364
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
}
|
|
4248
|
-
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
4249
|
-
sendJson(res, 200, metadata.authorizationServer);
|
|
4250
|
-
return;
|
|
4251
|
-
}
|
|
4252
|
-
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
4253
|
-
sendJson(res, 200, {
|
|
4254
|
-
status: "ok",
|
|
4255
|
-
subdomain: config.subdomain
|
|
4256
|
-
});
|
|
4365
|
+
const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
|
|
4366
|
+
if (staticRoute) {
|
|
4367
|
+
sendJson(res, 200, staticRoute);
|
|
4257
4368
|
return;
|
|
4258
4369
|
}
|
|
4259
4370
|
if (url.pathname === "/mcp") {
|
|
@@ -4266,9 +4377,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4266
4377
|
path: url.pathname
|
|
4267
4378
|
}));
|
|
4268
4379
|
} catch (err) {
|
|
4269
|
-
|
|
4270
|
-
if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
|
|
4271
|
-
else if (!res.writableEnded) res.end();
|
|
4380
|
+
failRequest(res, err);
|
|
4272
4381
|
}
|
|
4273
4382
|
};
|
|
4274
4383
|
const httpServer = createServer((req, res) => {
|
|
@@ -4345,5 +4454,3 @@ main().catch((error) => {
|
|
|
4345
4454
|
});
|
|
4346
4455
|
//#endregion
|
|
4347
4456
|
export {};
|
|
4348
|
-
|
|
4349
|
-
//# sourceMappingURL=index.js.map
|