@fruggr/zendesk-mcp-server 2.16.0 → 2.17.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/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 (Array.isArray(value)) return value.map(redactValue);
52
- if (value && typeof value === "object") {
53
- const out = {};
54
- for (const [key, val] of Object.entries(value)) out[key] = isSensitive(key) ? "[REDACTED]" : redactValue(val);
55
- return out;
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 = fields ? redactValue(fields) : {};
86
- console.error(formatLine(lvl, event, safe));
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],
@@ -115,7 +125,8 @@ const positiveIntEnv = (name, fallback) => {
115
125
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
116
126
  };
117
127
  const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
118
- const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
128
+ const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
129
+ const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
119
130
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
120
131
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
121
132
  const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
@@ -127,7 +138,7 @@ const getOAuthUrls = (subdomain) => ({
127
138
  });
128
139
  //#endregion
129
140
  //#region src/auth/browser-oauth.ts
130
- const AUTH_TIMEOUT_MS = 300 * 1e3;
141
+ const AUTH_TIMEOUT_MS = 3e5;
131
142
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
132
143
  const detectWsl = () => {
133
144
  if (process.platform !== "linux") return false;
@@ -154,6 +165,8 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
154
165
  * browser response; without escaping these are a reflected-XSS sink.
155
166
  */
156
167
  const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
168
+ const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
169
+ 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
170
  const generateCodeVerifier = () => randomBytes(32).toString("base64url");
158
171
  const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
159
172
  /**
@@ -179,69 +192,92 @@ const startBrowserAuth = (config, logger = silentLogger) => {
179
192
  });
180
193
  let authTimeout;
181
194
  let callbackServer;
182
- callbackServer = createServer(async (req, res) => {
183
- const url = new URL(req.url ?? "/", `http://localhost`);
184
- if (url.pathname !== "/callback") {
185
- res.writeHead(404);
186
- res.end("Not found");
187
- return;
195
+ const exchangeCodeForToken = async (code) => {
196
+ const callbackPort = callbackServer.address().port;
197
+ const tokenBody = new URLSearchParams({
198
+ grant_type: "authorization_code",
199
+ code,
200
+ client_id: oauthClientId,
201
+ redirect_uri: `http://localhost:${callbackPort}/callback`,
202
+ code_verifier: codeVerifier
203
+ });
204
+ const tokenResponse = await fetch(tokenUrl, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
207
+ body: tokenBody.toString()
208
+ });
209
+ logger.debug("oauth_token_exchange", { status: tokenResponse.status });
210
+ if (!tokenResponse.ok) {
211
+ const errorBody = await tokenResponse.text();
212
+ throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
188
213
  }
189
- const code = url.searchParams.get("code");
214
+ return await tokenResponse.json();
215
+ };
216
+ const finishRequest = (res, { status, html, outcome }) => {
217
+ res.writeHead(status, { "Content-Type": "text/html" });
218
+ res.end(html);
219
+ clearTimeout(authTimeout);
220
+ callbackServer.close();
221
+ if (outcome.ok) resolveToken(outcome.token);
222
+ else rejectToken(outcome.error);
223
+ };
224
+ const resolveCallback = async (url) => {
190
225
  const error = url.searchParams.get("error");
226
+ const code = url.searchParams.get("code");
191
227
  logger.debug("oauth_callback_received", {
192
228
  hasCode: Boolean(code),
193
229
  hasError: Boolean(error)
194
230
  });
195
231
  if (error) {
196
232
  const desc = url.searchParams.get("error_description") ?? error;
197
- res.writeHead(400, { "Content-Type": "text/html" });
198
- res.end(`<html><body><h1>Authentication failed</h1><p>${escapeHtml(desc)}</p></body></html>`);
199
- clearTimeout(authTimeout);
200
- callbackServer.close();
201
- rejectToken(/* @__PURE__ */ new Error(`OAuth error: ${desc}`));
202
- return;
203
- }
204
- if (!code) {
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;
233
+ return {
234
+ status: 400,
235
+ html: errorPage("Authentication failed", desc),
236
+ outcome: {
237
+ ok: false,
238
+ error: /* @__PURE__ */ new Error(`OAuth error: ${desc}`)
239
+ }
240
+ };
211
241
  }
212
- try {
213
- const callbackPort = callbackServer.address().port;
214
- const tokenBody = new URLSearchParams({
215
- grant_type: "authorization_code",
216
- code,
217
- client_id: oauthClientId,
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}`);
242
+ if (!code) return {
243
+ status: 400,
244
+ html: errorPage("Missing authorization code"),
245
+ outcome: {
246
+ ok: false,
247
+ error: /* @__PURE__ */ new Error("Missing authorization code in callback")
230
248
  }
231
- const tokenData = await tokenResponse.json();
249
+ };
250
+ try {
251
+ const token = await exchangeCodeForToken(code);
232
252
  logger.info("oauth_authenticated");
233
- res.writeHead(200, { "Content-Type": "text/html" });
234
- res.end("<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>");
235
- clearTimeout(authTimeout);
236
- callbackServer.close();
237
- resolveToken(tokenData);
253
+ return {
254
+ status: 200,
255
+ html: SUCCESS_PAGE,
256
+ outcome: {
257
+ ok: true,
258
+ token
259
+ }
260
+ };
238
261
  } catch (err) {
239
- res.writeHead(500, { "Content-Type": "text/html" });
240
- res.end(`<html><body><h1>Token exchange failed</h1><p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p></body></html>`);
241
- clearTimeout(authTimeout);
242
- callbackServer.close();
243
- rejectToken(err);
262
+ const detail = err instanceof Error ? err.message : String(err);
263
+ return {
264
+ status: 500,
265
+ html: errorPage("Token exchange failed", detail),
266
+ outcome: {
267
+ ok: false,
268
+ error: err
269
+ }
270
+ };
271
+ }
272
+ };
273
+ callbackServer = createServer(async (req, res) => {
274
+ const url = new URL(req.url ?? "/", `http://localhost`);
275
+ if (url.pathname !== "/callback") {
276
+ res.writeHead(404);
277
+ res.end("Not found");
278
+ return;
244
279
  }
280
+ finishRequest(res, await resolveCallback(url));
245
281
  });
246
282
  const requestedPort = config.callbackPort ?? 27439;
247
283
  const onStartError = (err) => {
@@ -384,15 +420,20 @@ const isWindows = process.platform === "win32";
384
420
  * (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
385
421
  * vendor-namespaced and can't collide with another `zendesk-mcp-server`.
386
422
  */
423
+ const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
387
424
  const appDirSegments = () => {
388
425
  const { name } = readPackageInfo();
389
- const scoped = /^@([^/]+)\/(.+)$/.exec(name);
426
+ const scoped = SCOPED_PACKAGE_NAME.exec(name);
390
427
  return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
391
428
  };
392
429
  const configDir = () => {
393
430
  const segments = appDirSegments();
394
- if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
395
- return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
431
+ if (isWindows) {
432
+ const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
433
+ return join(base, ...segments);
434
+ }
435
+ const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
436
+ return join(base, ...segments);
396
437
  };
397
438
  const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
398
439
  /**
@@ -443,7 +484,7 @@ const clearToken = (path, logger = silentLogger) => {
443
484
  //#endregion
444
485
  //#region src/auth/token-store.ts
445
486
  const EXPIRY_SKEW_MS = 6e4;
446
- const SCHEDULED_REFRESH_MS = 14400 * 1e3;
487
+ const SCHEDULED_REFRESH_MS = 144e5;
447
488
  const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
448
489
  name: "AuthRequiredError",
449
490
  authorizeUrl
@@ -522,20 +563,23 @@ const createTokenStore = (config, logger = silentLogger) => {
522
563
  throw err;
523
564
  });
524
565
  };
566
+ const refreshIfPossible = async () => {
567
+ const current = token;
568
+ if (!current?.refreshToken) return void 0;
569
+ if (refreshing === void 0) refreshing = tryRefresh(current).finally(() => {
570
+ refreshing = void 0;
571
+ });
572
+ return refreshing;
573
+ };
525
574
  const getToken = async () => {
526
- if (refreshing) await refreshing;
575
+ if (refreshing !== void 0) await refreshing;
527
576
  if (token && !needsRefresh(token)) {
528
577
  logger.debug("oauth_token_cache_hit");
529
578
  return token.accessToken;
530
579
  }
531
- if (token?.refreshToken) {
532
- if (!refreshing) refreshing = tryRefresh(token).finally(() => {
533
- refreshing = void 0;
534
- });
535
- const refreshed = await refreshing;
536
- if (refreshed) return refreshed;
537
- }
538
- if (!starting) starting = beginAuth();
580
+ const refreshed = await refreshIfPossible();
581
+ if (refreshed) return refreshed;
582
+ if (starting === void 0) starting = beginAuth();
539
583
  const url = authorizeUrl ?? await starting;
540
584
  throw createAuthRequiredError(url);
541
585
  };
@@ -657,76 +701,96 @@ const ConfigSchema = z.object({
657
701
  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
702
  callbackPort: z.number().int().min(1).max(65535).optional()
659
703
  });
704
+ const DIGITS_ONLY = /^\d+$/;
660
705
  const parsePort = (raw, label) => {
661
- if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
706
+ if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
662
707
  return Number(raw);
663
708
  };
664
709
  const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
710
+ const appendTo = (key) => (result, value) => {
711
+ const list = result[key] ?? [];
712
+ list.push(value);
713
+ result[key] = list;
714
+ };
715
+ const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
716
+ ["--read-only", (result) => {
717
+ result.readOnly = true;
718
+ }],
719
+ ["--no-topology", (result) => {
720
+ result.topology = false;
721
+ }],
722
+ ["--no-promoted-articles", (result) => {
723
+ result.promotedArticles = false;
724
+ }],
725
+ ["--dev", (result) => {
726
+ result.dev = true;
727
+ }]
728
+ ]);
729
+ const VALUED_FLAGS = /* @__PURE__ */ new Map([
730
+ ["--mode", (result, value) => {
731
+ result.mode = value;
732
+ }],
733
+ ["--hc-resource-scheme", (result, value) => {
734
+ result.hcResourceScheme = value;
735
+ }],
736
+ ["--log-level", (result, value) => {
737
+ result.logLevel = value;
738
+ }],
739
+ ["--transport", (result, value) => {
740
+ result.transport = value;
741
+ }],
742
+ ["--host", (result, value) => {
743
+ result.host = value;
744
+ }],
745
+ ["--public-url", (result, value) => {
746
+ result.publicUrl = value;
747
+ }],
748
+ ["--port", (result, value) => {
749
+ result.port = parsePort(value, "--port");
750
+ }],
751
+ ["--callback-port", (result, value) => {
752
+ result.callbackPort = parsePort(value, "--callback-port");
753
+ }],
754
+ ["--namespace", appendTo("namespaces")],
755
+ ["--tool", appendTo("tools")],
756
+ ["--cors-origin", appendTo("corsOrigins")]
757
+ ]);
665
758
  const parseCliArgs = (args) => {
666
759
  const result = {};
667
- let positionalIndex = 0;
668
760
  for (let i = 0; i < args.length; i++) {
669
761
  const arg = args[i];
670
762
  if (arg === void 0) continue;
763
+ const standalone = STANDALONE_FLAGS.get(arg);
764
+ if (standalone) {
765
+ standalone(result);
766
+ continue;
767
+ }
768
+ const valued = VALUED_FLAGS.get(arg);
671
769
  const next = args[i + 1];
672
- if (arg === "--mode" && next) {
673
- result.mode = next;
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;
770
+ if (valued && next) {
771
+ valued(result, next);
692
772
  i++;
693
- } else if (arg === "--transport" && next) {
694
- result.transport = next;
695
- i++;
696
- } else if (arg === "--host" && next) {
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++;
773
+ continue;
715
774
  }
775
+ if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
716
776
  }
717
777
  return result;
718
778
  };
779
+ const resolveTransportSettings = (cli) => {
780
+ const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
781
+ return {
782
+ transport: cli.transport ?? process.env["TRANSPORT"] ?? "stdio",
783
+ host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
784
+ port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
785
+ publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
786
+ corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
787
+ };
788
+ };
719
789
  const loadConfig = (argv = process.argv.slice(2)) => {
720
790
  const cli = parseCliArgs(argv);
721
791
  const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
722
792
  const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
723
793
  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
794
  const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
731
795
  const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
732
796
  return ConfigSchema.parse({
@@ -741,12 +805,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
741
805
  promotedArticles: cli.promotedArticles ?? true,
742
806
  hcResourceScheme,
743
807
  dev: cli.dev ?? false,
744
- transport,
745
- host,
746
- port,
747
- publicUrl,
748
- corsOrigins,
749
- callbackPort
808
+ callbackPort,
809
+ ...resolveTransportSettings(cli)
750
810
  });
751
811
  };
752
812
  //#endregion
@@ -893,10 +953,11 @@ const HEADING_LEVELS = /* @__PURE__ */ new Set([
893
953
  "h2",
894
954
  "h3"
895
955
  ]);
956
+ const WHITESPACE_RUN = /\s+/;
896
957
  const countWords = (text) => {
897
958
  const trimmed = text.trim();
898
959
  if (!trimmed) return 0;
899
- return trimmed.split(/\s+/).length;
960
+ return trimmed.split(WHITESPACE_RUN).length;
900
961
  };
901
962
  const textOf = (html) => {
902
963
  if (!html) return "";
@@ -1000,7 +1061,11 @@ const formatTicket = (ticket) => [
1000
1061
  `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
1001
1062
  ticket.description ? `\n${ticket.description}` : ""
1002
1063
  ].filter(Boolean).join("\n");
1003
- const formatConditionValue = (value) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
1064
+ const formatConditionValue = (value) => {
1065
+ if (value === null || value === void 0) return "";
1066
+ if (typeof value === "object") return JSON.stringify(value);
1067
+ return String(value);
1068
+ };
1004
1069
  const formatSlaPolicy = (policy) => {
1005
1070
  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
1071
  const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
@@ -1199,6 +1264,13 @@ const formatTranslation = (translation) => [
1199
1264
  "",
1200
1265
  translation.body
1201
1266
  ].join("\n");
1267
+ const formatNodeTranslationSummary = (translation) => [
1268
+ `## Translation: ${translation.locale} (${translation.id})`,
1269
+ `- **Name**: ${translation.title}`,
1270
+ `- **Description**: ${translation.body ? "set" : "empty"}`,
1271
+ `- **Draft**: ${translation.draft}`,
1272
+ `- **Updated**: ${translation.updated_at}`
1273
+ ].join("\n");
1202
1274
  const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
1203
1275
  const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
1204
1276
  const formatView = (view, count) => {
@@ -1299,12 +1371,14 @@ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOUR
1299
1371
  * character limit. Reuses the same formatting as the `get_article` tool.
1300
1372
  */
1301
1373
  const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1302
- const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${id}` : `/articles/${id}`);
1303
- return truncateIfNeeded([
1374
+ const path = locale ? `/${locale}/articles/${id}` : `/articles/${id}`;
1375
+ const { article } = await helpCenterGet(subdomain, token, path);
1376
+ const text = [
1304
1377
  formatArticleSummary(article),
1305
1378
  "",
1306
1379
  htmlToMarkdown(article.body)
1307
- ].join("\n"));
1380
+ ].join("\n");
1381
+ return truncateIfNeeded(text);
1308
1382
  };
1309
1383
  /**
1310
1384
  * Build an article-resources provider. `listPromoted` holds a memoized-promise
@@ -1493,20 +1567,20 @@ const fetchTopology = async (subdomain, token) => {
1493
1567
  currentUser: meRes.user
1494
1568
  };
1495
1569
  };
1496
- const renderTree = (data) => {
1497
- if (data.categoriesHasMore || data.sectionsHasMore) {
1498
- const reasons = [];
1499
- if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
1500
- if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
1501
- return [
1502
- `Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
1503
- data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
1504
- ...data.categories.map(formatCategory),
1505
- "",
1506
- ...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
1507
- "Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
1508
- ];
1509
- }
1570
+ const renderOversizedTreeNotice = (data) => {
1571
+ const reasons = [];
1572
+ if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
1573
+ if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
1574
+ return [
1575
+ `Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
1576
+ data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
1577
+ ...data.categories.map(formatCategory),
1578
+ "",
1579
+ ...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
1580
+ "Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
1581
+ ];
1582
+ };
1583
+ const renderCategoryTree = (data) => {
1510
1584
  const byCategory = /* @__PURE__ */ new Map();
1511
1585
  for (const section of data.sections) {
1512
1586
  const list = byCategory.get(section.category_id) ?? [];
@@ -1520,6 +1594,7 @@ const renderTree = (data) => {
1520
1594
  }
1521
1595
  return lines.length ? lines : ["_(no categories)_"];
1522
1596
  };
1597
+ const renderTree = (data) => data.categoriesHasMore || data.sectionsHasMore ? renderOversizedTreeNotice(data) : renderCategoryTree(data);
1523
1598
  /**
1524
1599
  * Render an admin-gated section as one of three states so the LLM never mistakes
1525
1600
  * "you can't see this" for "there are none": the formatted list, `_(none)_` when
@@ -1531,7 +1606,7 @@ const renderAdminSection = (items, denied, deniedNote) => {
1531
1606
  };
1532
1607
  /** Render the topology as a compact Markdown document for the LLM context. */
1533
1608
  const formatTopology = (data) => {
1534
- return truncateIfNeeded([
1609
+ const text = [
1535
1610
  `# Zendesk Help Center topology — ${data.subdomain}`,
1536
1611
  "",
1537
1612
  `**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
@@ -1548,7 +1623,8 @@ const formatTopology = (data) => {
1548
1623
  "",
1549
1624
  "## Permission groups",
1550
1625
  ...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
1551
- ].join("\n"));
1626
+ ].join("\n");
1627
+ return truncateIfNeeded(text);
1552
1628
  };
1553
1629
  /**
1554
1630
  * Build a topology provider holding a memoized-promise cache (TTL
@@ -1676,6 +1752,117 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
1676
1752
  //#region src/tools/help-center.ts
1677
1753
  const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
1678
1754
  const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
1755
+ const NODE_LABEL = {
1756
+ sections: "section",
1757
+ categories: "category"
1758
+ };
1759
+ const listNodeTranslations = (subdomain, token, kind, nodeId, locale) => helpCenterGet(subdomain, token, `/${kind}/${nodeId}/translations`, locale ? { locales: locale.toLowerCase() } : void 0).then((res) => res.translations ?? []);
1760
+ const findTranslation = (translations, locale) => {
1761
+ const wanted = locale.toLowerCase();
1762
+ return translations.find((t) => t.locale.toLowerCase() === wanted);
1763
+ };
1764
+ /**
1765
+ * Create-or-update a section/category translation in one call. The POST-vs-PUT
1766
+ * probe spares the caller a listing round-trip, or the 400 a duplicate POST
1767
+ * returns. Only the fields passed are sent on update, so omitting `description`
1768
+ * never blanks it and omitting `draft` never (un)publishes by accident.
1769
+ */
1770
+ const upsertNodeTranslation = async (subdomain, token, kind, nodeId, input) => {
1771
+ const { locale, name, description, draft } = input;
1772
+ const existing = findTranslation(await listNodeTranslations(subdomain, token, kind, nodeId, locale), locale);
1773
+ if (!existing) {
1774
+ if (name === void 0) throw new Error(`${NODE_LABEL[kind]} #${nodeId} has no "${locale}" translation yet, so one has to be created and "name" is required. Pass the localized name, or call list_${NODE_LABEL[kind]}_translations to see which locales already exist.`);
1775
+ const { translation } = await helpCenterPost(subdomain, token, `/${kind}/${nodeId}/translations`, { translation: {
1776
+ locale,
1777
+ title: name,
1778
+ body: description ?? "",
1779
+ draft: draft ?? false
1780
+ } });
1781
+ return {
1782
+ translation,
1783
+ created: true
1784
+ };
1785
+ }
1786
+ const updates = {};
1787
+ if (name !== void 0) updates["title"] = name;
1788
+ if (description !== void 0) updates["body"] = description;
1789
+ if (draft !== void 0) updates["draft"] = draft;
1790
+ if (Object.keys(updates).length === 0) throw new Error(`Nothing to write: ${NODE_LABEL[kind]} #${nodeId} already has a "${existing.locale}" translation, so pass at least one of "name", "description" or "draft" to change it (draft: false publishes it).`);
1791
+ const { translation } = await helpCenterPut(subdomain, token, `/${kind}/${nodeId}/translations/${existing.locale}`, { translation: updates });
1792
+ return {
1793
+ translation,
1794
+ created: false
1795
+ };
1796
+ };
1797
+ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
1798
+ `Translation ${created ? "created" : "updated"} for ${NODE_LABEL[kind]} #${nodeId} in "${translation.locale}" (${translation.draft ? "draft, not visible to end users" : "published"}).`,
1799
+ "",
1800
+ formatNodeTranslationSummary(translation)
1801
+ ].join("\n");
1802
+ const GAP_REASON_TEXT = {
1803
+ missing: "no translation",
1804
+ draft: "draft translation (not published)"
1805
+ };
1806
+ const classifyGap = (node, translations, locale) => {
1807
+ const translation = findTranslation(translations, locale);
1808
+ if (!translation) return {
1809
+ id: node.id,
1810
+ name: node.name,
1811
+ reason: "missing"
1812
+ };
1813
+ return translation.draft ? {
1814
+ id: node.id,
1815
+ name: node.name,
1816
+ reason: "draft"
1817
+ } : null;
1818
+ };
1819
+ const renderGapLines = (heading, gaps, scanned, found) => {
1820
+ const header = `## ${heading} (${scanned} scanned)`;
1821
+ if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
1822
+ if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none scanned — the ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap was spent before this level; ${found} left unchecked, see the note below)_`];
1823
+ return [header, "_(none — every one scanned has a published translation)_"];
1824
+ };
1825
+ const GAP_SCAN_WAVE_SIZE = 5;
1826
+ const probeInWaves = async (nodes, probe) => {
1827
+ const gaps = [];
1828
+ for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
1829
+ const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
1830
+ for (const gap of wave) if (gap !== null) gaps.push(gap);
1831
+ }
1832
+ return gaps;
1833
+ };
1834
+ const fetchGapCategories = async (subdomain, token, categoryId) => {
1835
+ if (categoryId !== void 0) {
1836
+ const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
1837
+ return {
1838
+ categories: [category],
1839
+ hasMore: false
1840
+ };
1841
+ }
1842
+ const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
1843
+ const categories = response.categories ?? [];
1844
+ return {
1845
+ categories,
1846
+ hasMore: extractPaginationMeta(response, categories.length).has_more
1847
+ };
1848
+ };
1849
+ const renderGapReport = (report) => {
1850
+ const { locale, categoryGaps, sectionGaps, scanned, found } = report;
1851
+ const gapCount = categoryGaps.length + sectionGaps.length;
1852
+ const capped = scanned.categories < found.categories || scanned.sections < found.sections;
1853
+ return truncateIfNeeded([
1854
+ `# Translation gaps — "${locale}"`,
1855
+ "",
1856
+ ...report.activeLocales.some((l) => l.toLowerCase() === locale.toLowerCase()) ? [] : [`> ⚠ "${locale}" is not an active locale of this Help Center (active: ${report.activeLocales.join(", ")}), so everything below reads as untranslated. Check the spelling, or activate the language in Guide first.`, ""],
1857
+ ...renderGapLines("Categories", categoryGaps, scanned.categories, found.categories),
1858
+ "",
1859
+ ...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
1860
+ "",
1861
+ gapCount === 0 ? `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.` : `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`,
1862
+ ...capped ? ["", `_Note: the scan stopped at its ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap, covering ${scanned.categories}/${found.categories} categories and ${scanned.sections}/${found.sections} sections. The rest were not checked — narrow the scan with category_id, or raise ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES._`] : [],
1863
+ ...report.listingIncomplete ? ["", `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
1864
+ ].join("\n"));
1865
+ };
1679
1866
  const largeArticleHint = (body, sectionCount) => {
1680
1867
  if (body.length < 3e3 && sectionCount < 4) return null;
1681
1868
  return [
@@ -1686,6 +1873,60 @@ const largeArticleHint = (body, sectionCount) => {
1686
1873
  ].join("\n");
1687
1874
  };
1688
1875
  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(" ");
1876
+ const renderFreshnessLine = (sourceUpdatedAt, targetUpdatedAt, targetLocale) => {
1877
+ const srcMs = Date.parse(sourceUpdatedAt);
1878
+ const tgtMs = Date.parse(targetUpdatedAt);
1879
+ const label = `- **Freshness (target ${targetLocale})**`;
1880
+ if (!Number.isFinite(srcMs) || !Number.isFinite(tgtMs)) return `${label}: unknown (could not compare edit timestamps).`;
1881
+ if (srcMs <= tgtMs) return `${label}: up to date (source has not been edited since this translation).`;
1882
+ const days = Math.floor((srcMs - tgtMs) / 864e5);
1883
+ return `${label}: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
1884
+ };
1885
+ const renderOutdatedLine = (translations, targetLocale) => {
1886
+ const targetLocaleKey = targetLocale.toLowerCase();
1887
+ const entry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
1888
+ const label = `- **Zendesk outdated flag (target ${targetLocale})**`;
1889
+ if (entry?.outdated === void 0) return `${label}: unknown.`;
1890
+ if (entry.outdated) return `${label}: yes — explicitly marked out of date in Guide.`;
1891
+ return `${label}: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).`;
1892
+ };
1893
+ const renderStructureLine = (sourceSections, targetSections) => {
1894
+ const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
1895
+ const targetTags = targetSections.map((s) => s.headingTag).join(",");
1896
+ 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.`;
1897
+ };
1898
+ const sectionRowStatus = (source, target) => {
1899
+ if (!target) return "missing";
1900
+ if (!source) return "extra";
1901
+ return "ok";
1902
+ };
1903
+ const renderSectionRows = (sourceSections, targetSections) => {
1904
+ const rows = ["| Idx | Heading | Status | Source words | Target words |", "| --- | --- | --- | --- | --- |"];
1905
+ const maxLen = Math.max(sourceSections.length, targetSections.length);
1906
+ for (let i = 0; i < maxLen; i += 1) {
1907
+ const src = sourceSections[i];
1908
+ const tgt = targetSections[i];
1909
+ const heading = src?.heading ?? tgt?.heading ?? "";
1910
+ const status = sectionRowStatus(src, tgt);
1911
+ rows.push(`| ${i} | ${heading} | ${status} | ${src?.wordCount ?? 0} | ${tgt?.wordCount ?? 0} |`);
1912
+ }
1913
+ return rows;
1914
+ };
1915
+ const localePrefix = (locale) => locale ? `/${locale}` : "";
1916
+ const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
1917
+ const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
1918
+ const scanCostNote = (truncated, pagesScanned, cost) => {
1919
+ 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._`;
1920
+ 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._`;
1921
+ return "";
1922
+ };
1923
+ const needsReferenceArticle = (target) => target === "before" || target === "after";
1924
+ const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
1925
+ const needsReference = needsReferenceArticle(target);
1926
+ if (needsReference && referenceArticleId === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
1927
+ if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
1928
+ if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
1929
+ };
1689
1930
  const createHelpCenterTools = (ctx) => {
1690
1931
  const { subdomain, getToken } = ctx;
1691
1932
  const fetchSectionOrder = async (sectionId, locale, token) => {
@@ -1703,6 +1944,26 @@ const createHelpCenterTools = (ctx) => {
1703
1944
  } while (cursor);
1704
1945
  return order;
1705
1946
  };
1947
+ const assertReferenceInSection = async (effective, referenceArticleId, articleId, sectionId, token) => {
1948
+ if (effective.some((a) => a.id === referenceArticleId)) return;
1949
+ let detail = "was not found";
1950
+ try {
1951
+ const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${referenceArticleId}`);
1952
+ detail = `is in section #${ref.section_id}, not section #${sectionId}`;
1953
+ } catch {}
1954
+ throw new Error(`Reference article #${referenceArticleId} ${detail}. It must be in the same section (#${sectionId}) as article #${articleId}.`);
1955
+ };
1956
+ const applyPositionWrites = async (writes, articleId, token) => {
1957
+ let applied = 0;
1958
+ for (const write of writes) try {
1959
+ await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
1960
+ applied += 1;
1961
+ } catch (error) {
1962
+ const reason = error instanceof Error ? error.message : String(error);
1963
+ 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 });
1964
+ }
1965
+ return applied;
1966
+ };
1706
1967
  return [
1707
1968
  {
1708
1969
  name: "search_articles",
@@ -1759,9 +2020,10 @@ const createHelpCenterTools = (ctx) => {
1759
2020
  const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
1760
2021
  const { article } = await helpCenterGet(subdomain, token, path);
1761
2022
  const translations = await listTranslations(subdomain, token, article_id);
2023
+ const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
1762
2024
  return { content: [{
1763
2025
  type: "text",
1764
- text: truncateIfNeeded((largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`)
2026
+ text: truncateIfNeeded(text)
1765
2027
  }] };
1766
2028
  }
1767
2029
  },
@@ -1785,7 +2047,7 @@ const createHelpCenterTools = (ctx) => {
1785
2047
  handler: async (params) => {
1786
2048
  const { locale, page_size, cursor } = params;
1787
2049
  const token = await getToken();
1788
- const path = locale ? `/${locale}/categories` : "/categories";
2050
+ const path = `${localePrefix(locale)}/categories`;
1789
2051
  const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
1790
2052
  const categories = response.categories ?? [];
1791
2053
  return { content: [{
@@ -1815,8 +2077,7 @@ const createHelpCenterTools = (ctx) => {
1815
2077
  handler: async (params) => {
1816
2078
  const { category_id, locale, page_size, cursor } = params;
1817
2079
  const token = await getToken();
1818
- const path = category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections";
1819
- const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
2080
+ const response = await helpCenterGet(subdomain, token, sectionListPath(category_id, locale), buildCursorParams(page_size, cursor));
1820
2081
  const sections = response.sections ?? [];
1821
2082
  return { content: [{
1822
2083
  type: "text",
@@ -1853,8 +2114,7 @@ const createHelpCenterTools = (ctx) => {
1853
2114
  handler: async (params) => {
1854
2115
  const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
1855
2116
  const token = await getToken();
1856
- const path = section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles";
1857
- const response = await helpCenterGet(subdomain, token, path, {
2117
+ const response = await helpCenterGet(subdomain, token, articleListPath(section_id, locale), {
1858
2118
  ...buildCursorParams(page_size, cursor),
1859
2119
  sort_by,
1860
2120
  sort_order
@@ -1869,9 +2129,10 @@ const createHelpCenterTools = (ctx) => {
1869
2129
  return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
1870
2130
  }));
1871
2131
  const meta = extractPaginationMeta(response, articles.length);
2132
+ const text = [meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n");
1872
2133
  return { content: [{
1873
2134
  type: "text",
1874
- text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
2135
+ text: truncateIfNeeded(text)
1875
2136
  }] };
1876
2137
  }
1877
2138
  },
@@ -1894,9 +2155,10 @@ const createHelpCenterTools = (ctx) => {
1894
2155
  const header = `Promoted (featured) articles: ${articles.length}`;
1895
2156
  const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
1896
2157
  const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
2158
+ const note = scanCostNote(truncated, pagesScanned, cost);
1897
2159
  return { content: [{
1898
2160
  type: "text",
1899
- text: truncateIfNeeded(`${header}\n\n${body}${truncated ? `\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._` : pagesScanned > 1 ? `\n\n_Note: this scan cost ${cost}; this tool performs a fresh scan every call (no caching), so avoid calling it again right away._` : ""}`)
2161
+ text: truncateIfNeeded(`${header}\n\n${body}${note}`)
1900
2162
  }] };
1901
2163
  }
1902
2164
  },
@@ -1916,9 +2178,10 @@ const createHelpCenterTools = (ctx) => {
1916
2178
  handler: async (params) => {
1917
2179
  const { article_id } = params;
1918
2180
  const token = await getToken();
2181
+ const translations = await listTranslations(subdomain, token, article_id);
1919
2182
  return { content: [{
1920
2183
  type: "text",
1921
- text: formatList(await listTranslations(subdomain, token, article_id), formatTranslationSummary)
2184
+ text: formatList(translations, formatTranslationSummary)
1922
2185
  }] };
1923
2186
  }
1924
2187
  },
@@ -1985,6 +2248,160 @@ const createHelpCenterTools = (ctx) => {
1985
2248
  }] };
1986
2249
  }
1987
2250
  },
2251
+ {
2252
+ name: "list_section_translations",
2253
+ namespace: "help_center",
2254
+ readOnly: true,
2255
+ title: "List Section Translations",
2256
+ description: "List the translations of a Help Center section: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a section looks wrong in a locale, because list_sections with that locale cannot settle it: a section with no translation is omitted from it, while a section whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_section_translation; to sweep every category and section at once, use find_translation_gaps.",
2257
+ inputSchema: z.object({ section_id: z.number().int().describe("Section ID — the numeric id of the Help Center section. Obtain it from list_sections or the zendesk-hc://topology resource.") }),
2258
+ annotations: {
2259
+ readOnlyHint: true,
2260
+ destructiveHint: false,
2261
+ idempotentHint: true,
2262
+ openWorldHint: true
2263
+ },
2264
+ handler: async (params) => {
2265
+ const { section_id } = params;
2266
+ const token = await getToken();
2267
+ const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
2268
+ return { content: [{
2269
+ type: "text",
2270
+ text: formatList(translations, formatNodeTranslationSummary)
2271
+ }] };
2272
+ }
2273
+ },
2274
+ {
2275
+ name: "list_category_translations",
2276
+ namespace: "help_center",
2277
+ readOnly: true,
2278
+ title: "List Category Translations",
2279
+ description: "List the translations of a Help Center category: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a category looks wrong in a locale, because list_categories with that locale cannot settle it: a category with no translation is omitted from it, while a category whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_category_translation; to sweep every category and section at once, use find_translation_gaps.",
2280
+ inputSchema: z.object({ category_id: z.number().int().describe("Category ID — the numeric id of the Help Center category. Obtain it from list_categories or the zendesk-hc://topology resource.") }),
2281
+ annotations: {
2282
+ readOnlyHint: true,
2283
+ destructiveHint: false,
2284
+ idempotentHint: true,
2285
+ openWorldHint: true
2286
+ },
2287
+ handler: async (params) => {
2288
+ const { category_id } = params;
2289
+ const token = await getToken();
2290
+ const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
2291
+ return { content: [{
2292
+ type: "text",
2293
+ text: formatList(translations, formatNodeTranslationSummary)
2294
+ }] };
2295
+ }
2296
+ },
2297
+ {
2298
+ name: "find_translation_gaps",
2299
+ namespace: "help_center",
2300
+ readOnly: true,
2301
+ title: "Find Help Center Translation Gaps",
2302
+ description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs one extra request per node scanned, capped (the report says so when the cap bites) — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
2303
+ inputSchema: z.object({
2304
+ locale: z.string().describe("Locale to audit, e.g. \"fr\" or \"de\" — usually a non-default active locale of the Help Center (zendesk-hc://topology lists them). A locale that is not active is reported as a warning, since every node would then look untranslated."),
2305
+ category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs one request per category and per section.")
2306
+ }),
2307
+ annotations: {
2308
+ readOnlyHint: true,
2309
+ destructiveHint: false,
2310
+ idempotentHint: true,
2311
+ openWorldHint: true
2312
+ },
2313
+ handler: async (params) => {
2314
+ const { locale, category_id } = params;
2315
+ const token = await getToken();
2316
+ const [locales, categoryScope, sectionsRes] = await Promise.all([
2317
+ helpCenterGet(subdomain, token, "/locales"),
2318
+ fetchGapCategories(subdomain, token, category_id),
2319
+ helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
2320
+ ]);
2321
+ const allCategories = categoryScope.categories;
2322
+ const allSections = sectionsRes.sections ?? [];
2323
+ const categories = allCategories.slice(0, TRANSLATION_GAP_SCAN_MAX_NODES);
2324
+ const sections = allSections.slice(0, Math.max(0, TRANSLATION_GAP_SCAN_MAX_NODES - categories.length));
2325
+ const categoryGaps = await probeInWaves(categories, async (category) => classifyGap(category, await listNodeTranslations(subdomain, token, "categories", category.id, locale), locale));
2326
+ const sectionGaps = await probeInWaves(sections, async (section) => classifyGap(section, await listNodeTranslations(subdomain, token, "sections", section.id, locale), locale));
2327
+ return { content: [{
2328
+ type: "text",
2329
+ text: renderGapReport({
2330
+ locale,
2331
+ activeLocales: locales.locales ?? [],
2332
+ categoryGaps,
2333
+ sectionGaps,
2334
+ scanned: {
2335
+ categories: categories.length,
2336
+ sections: sections.length
2337
+ },
2338
+ found: {
2339
+ categories: allCategories.length,
2340
+ sections: allSections.length
2341
+ },
2342
+ listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
2343
+ })
2344
+ }] };
2345
+ }
2346
+ },
2347
+ {
2348
+ name: "set_section_translation",
2349
+ namespace: "help_center",
2350
+ readOnly: false,
2351
+ title: "Create or Update a Section Translation",
2352
+ description: "Create or update the translation of a Help Center section in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a section reachable in a locale where its articles are already translated — a gap find_translation_gaps reports and list_sections cannot explain.",
2353
+ inputSchema: z.object({
2354
+ section_id: z.number().int().describe("Section ID — the numeric id of the section whose translation to write. Obtain it from list_sections, find_translation_gaps or the zendesk-hc://topology resource."),
2355
+ locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_section_translations shows which ones the section already has."),
2356
+ name: z.string().min(1).optional().describe("Localized section name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
2357
+ description: z.string().optional().describe("Localized section description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
2358
+ draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the section visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
2359
+ }),
2360
+ annotations: {
2361
+ readOnlyHint: false,
2362
+ destructiveHint: true,
2363
+ idempotentHint: true,
2364
+ openWorldHint: true
2365
+ },
2366
+ handler: async (params) => {
2367
+ const { section_id, ...input } = params;
2368
+ const token = await getToken();
2369
+ const { translation, created } = await upsertNodeTranslation(subdomain, token, "sections", section_id, input);
2370
+ return { content: [{
2371
+ type: "text",
2372
+ text: nodeTranslationWriteText("sections", section_id, translation, created)
2373
+ }] };
2374
+ }
2375
+ },
2376
+ {
2377
+ name: "set_category_translation",
2378
+ namespace: "help_center",
2379
+ readOnly: false,
2380
+ title: "Create or Update a Category Translation",
2381
+ description: "Create or update the translation of a Help Center category in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a category reachable in a locale where its sections or articles are already translated — a gap find_translation_gaps reports and list_categories cannot explain.",
2382
+ inputSchema: z.object({
2383
+ category_id: z.number().int().describe("Category ID — the numeric id of the category whose translation to write. Obtain it from list_categories, find_translation_gaps or the zendesk-hc://topology resource."),
2384
+ locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_category_translations shows which ones the category already has."),
2385
+ name: z.string().min(1).optional().describe("Localized category name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
2386
+ description: z.string().optional().describe("Localized category description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
2387
+ draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the category visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
2388
+ }),
2389
+ annotations: {
2390
+ readOnlyHint: false,
2391
+ destructiveHint: true,
2392
+ idempotentHint: true,
2393
+ openWorldHint: true
2394
+ },
2395
+ handler: async (params) => {
2396
+ const { category_id, ...input } = params;
2397
+ const token = await getToken();
2398
+ const { translation, created } = await upsertNodeTranslation(subdomain, token, "categories", category_id, input);
2399
+ return { content: [{
2400
+ type: "text",
2401
+ text: nodeTranslationWriteText("categories", category_id, translation, created)
2402
+ }] };
2403
+ }
2404
+ },
1988
2405
  {
1989
2406
  name: "list_permission_groups",
1990
2407
  namespace: "help_center",
@@ -2108,25 +2525,17 @@ const createHelpCenterTools = (ctx) => {
2108
2525
  },
2109
2526
  handler: async (params) => {
2110
2527
  const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
2111
- const needsReference = target === "before" || target === "after";
2112
- if (needsReference && reference_article_id === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
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.");
2528
+ assertReorderParamsCoherent(article_id, target, reference_article_id);
2529
+ const needsReference = needsReferenceArticle(target);
2115
2530
  const token = await getToken();
2116
2531
  const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
2117
2532
  const sectionId = article.section_id;
2118
2533
  const locale = article.source_locale;
2119
2534
  const effective = await fetchSectionOrder(sectionId, locale, token);
2120
- if (needsReference && !effective.some((a) => a.id === reference_article_id)) {
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
- }
2535
+ if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
2128
2536
  const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
2129
- const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
2537
+ const desired = arrangeDesiredOrder(effective, article_id, target, reference_article_id);
2538
+ const writes = computePositionWrites(desired, article_id, normalize);
2130
2539
  if (writes.length === 0) return { content: [{
2131
2540
  type: "text",
2132
2541
  text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
@@ -2139,15 +2548,9 @@ const createHelpCenterTools = (ctx) => {
2139
2548
  type: "text",
2140
2549
  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
2550
  }] };
2142
- let applied = 0;
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
- }
2150
- if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
2551
+ const applied = await applyPositionWrites(writes, article_id, token);
2552
+ const after = await fetchSectionOrder(sectionId, locale, token);
2553
+ if (!isPlacedAsRequested(after, article_id, target, reference_article_id)) return { content: [{
2151
2554
  type: "text",
2152
2555
  text: autoSortNotice(sectionId, applied)
2153
2556
  }] };
@@ -2258,9 +2661,10 @@ const createHelpCenterTools = (ctx) => {
2258
2661
  },
2259
2662
  handler: async () => {
2260
2663
  const token = await getToken();
2664
+ const response = await helpCenterGet(subdomain, token, "/articles/labels");
2261
2665
  return { content: [{
2262
2666
  type: "text",
2263
- text: formatList((await helpCenterGet(subdomain, token, "/articles/labels")).labels ?? [], formatLabel)
2667
+ text: formatList(response.labels ?? [], formatLabel)
2264
2668
  }] };
2265
2669
  }
2266
2670
  },
@@ -2386,14 +2790,15 @@ const createHelpCenterTools = (ctx) => {
2386
2790
  const section = sections[section_index];
2387
2791
  if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
2388
2792
  const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
2793
+ const text = [
2794
+ section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
2795
+ `_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
2796
+ "",
2797
+ content
2798
+ ].join("\n");
2389
2799
  return { content: [{
2390
2800
  type: "text",
2391
- text: truncateIfNeeded([
2392
- section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
2393
- `_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
2394
- "",
2395
- content
2396
- ].join("\n"))
2801
+ text: truncateIfNeeded(text)
2397
2802
  }] };
2398
2803
  }
2399
2804
  },
@@ -2458,40 +2863,10 @@ const createHelpCenterTools = (ctx) => {
2458
2863
  ]);
2459
2864
  const sourceSections = parseSections(sourceRes.translation.body);
2460
2865
  const targetSections = parseSections(targetRes.translation.body);
2461
- const maxLen = Math.max(sourceSections.length, targetSections.length);
2462
- const sourceUpdated = sourceRes.translation.updated_at;
2463
- const targetUpdated = targetRes.translation.updated_at;
2464
- const srcMs = Date.parse(sourceUpdated);
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
- }
2866
+ const freshnessLine = renderFreshnessLine(sourceRes.translation.updated_at, targetRes.translation.updated_at, target_locale);
2867
+ const outdatedLine = renderOutdatedLine(translations, target_locale);
2868
+ const structureLine = renderStructureLine(sourceSections, targetSections);
2869
+ const rows = renderSectionRows(sourceSections, targetSections);
2495
2870
  return { content: [{
2496
2871
  type: "text",
2497
2872
  text: [
@@ -2500,7 +2875,7 @@ const createHelpCenterTools = (ctx) => {
2500
2875
  freshnessLine,
2501
2876
  outdatedLine,
2502
2877
  structureLine,
2503
- `- **Updated**: source ${sourceUpdated} | target ${targetUpdated}`,
2878
+ `- **Updated**: source ${sourceRes.translation.updated_at} | target ${targetRes.translation.updated_at}`,
2504
2879
  `- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
2505
2880
  "",
2506
2881
  "_Word counts are informational: a length difference between languages is normal, not a divergence._",
@@ -2587,15 +2962,18 @@ const createSearchTools = (ctx) => {
2587
2962
  });
2588
2963
  const results = response.results ?? [];
2589
2964
  const meta = extractSearchPaginationMeta(response, per_page, page);
2965
+ const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
2966
+ const body = results.map(formatSearchResult).join("\n\n");
2590
2967
  return { content: [{
2591
2968
  type: "text",
2592
- text: truncateIfNeeded([`Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`, results.map(formatSearchResult).join("\n\n")].filter(Boolean).join("\n\n"))
2969
+ text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n"))
2593
2970
  }] };
2594
2971
  }
2595
2972
  }];
2596
2973
  };
2597
2974
  //#endregion
2598
2975
  //#region src/tools/tickets.ts
2976
+ const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
2599
2977
  const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
2600
2978
  const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
2601
2979
  const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
@@ -2624,6 +3002,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
2624
3002
  }
2625
3003
  return all;
2626
3004
  };
3005
+ const fetchAttachmentsByIds = async (subdomain, token, ids) => {
3006
+ const attachments = [];
3007
+ for (const id of ids) try {
3008
+ const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
3009
+ attachments.push(attachment);
3010
+ } catch (error) {
3011
+ if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
3012
+ }
3013
+ return attachments;
3014
+ };
2627
3015
  const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2628
3016
  const blocks = [];
2629
3017
  let embeddedCount = 0;
@@ -2637,7 +3025,7 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2637
3025
  continue;
2638
3026
  }
2639
3027
  let skipReason = null;
2640
- if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${+(MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2)} MB per-image limit`;
3028
+ if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
2641
3029
  else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
2642
3030
  if (skipReason) {
2643
3031
  blocks.push({
@@ -2659,9 +3047,10 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2659
3047
  }
2660
3048
  return blocks;
2661
3049
  };
3050
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
2662
3051
  const fetchTicketSla = async (subdomain, token, ticket) => {
2663
3052
  const day = ticket.created_at.slice(0, 10);
2664
- if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
3053
+ if (!ISO_DAY.test(day)) return void 0;
2665
3054
  const shiftDay = (offset) => {
2666
3055
  const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
2667
3056
  d.setUTCDate(d.getUTCDate() + offset);
@@ -2731,23 +3120,24 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
2731
3120
  const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
2732
3121
  return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
2733
3122
  };
3123
+ const addPositiveId = (set, raw) => {
3124
+ const n = Number(raw);
3125
+ if (Number.isInteger(n) && n > 0) set.add(n);
3126
+ };
3127
+ const collectEventIds = (event, userIds, groupIds) => {
3128
+ if (event.type !== "Change" && event.type !== "Create") return;
3129
+ const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
3130
+ if (!entity) return;
3131
+ const set = entity === "user" ? userIds : groupIds;
3132
+ addPositiveId(set, event.value);
3133
+ addPositiveId(set, event.previous_value);
3134
+ };
2734
3135
  const collectAuditIds = (audits) => {
2735
3136
  const userIds = /* @__PURE__ */ new Set();
2736
3137
  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
3138
  for (const audit of audits) {
2742
- addId(userIds, audit.author_id);
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
- }
3139
+ addPositiveId(userIds, audit.author_id);
3140
+ for (const event of audit.events) collectEventIds(event, userIds, groupIds);
2751
3141
  }
2752
3142
  return {
2753
3143
  userIds: [...userIds],
@@ -2790,12 +3180,11 @@ const diffLine = (label, before, after) => {
2790
3180
  const a = shownValue(after);
2791
3181
  return b === a ? null : `- **${label}**: ${b} → ${a}`;
2792
3182
  };
2793
- const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2794
- const after = result?.ticket ?? {};
2795
- const beforeObj = before ?? {};
2796
- const comment = after.comment ?? result?.comment;
3183
+ const asRecord = (value) => value ?? {};
3184
+ const diffStandardFields = (before, after) => {
3185
+ const beforeObj = asRecord(before);
2797
3186
  const changes = [];
2798
- for (const [key, afterVal] of Object.entries(after)) {
3187
+ for (const [key, afterVal] of Object.entries(asRecord(after))) {
2799
3188
  if (DIFF_SKIP_KEYS.has(key)) continue;
2800
3189
  const beforeVal = beforeObj[key];
2801
3190
  if (valuesEqual(beforeVal, afterVal)) continue;
@@ -2808,12 +3197,22 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2808
3197
  const line = diffLine(key, beforeVal, afterVal);
2809
3198
  if (line) changes.push(line);
2810
3199
  }
2811
- const afterFields = [after.fields ?? after.custom_fields ?? []].flat();
3200
+ return changes;
3201
+ };
3202
+ const diffCustomFields = (before, after) => {
3203
+ const afterFields = [after?.fields ?? after?.custom_fields ?? []].flat();
2812
3204
  const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
3205
+ const changes = [];
2813
3206
  for (const f of afterFields) {
2814
3207
  const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
2815
3208
  if (line) changes.push(line);
2816
3209
  }
3210
+ return changes;
3211
+ };
3212
+ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
3213
+ const after = result?.ticket ?? {};
3214
+ const comment = after.comment ?? result?.comment;
3215
+ const changes = [...diffStandardFields(before, after), ...diffCustomFields(before, after)];
2817
3216
  const lines = [
2818
3217
  `# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
2819
3218
  "",
@@ -2939,16 +3338,7 @@ const createTicketTools = (ctx) => {
2939
3338
  handler: async (params) => {
2940
3339
  const { ticket_id, attachment_ids } = params;
2941
3340
  const token = await getToken();
2942
- let attachments;
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 ?? []);
3341
+ 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
3342
  if (attachments.length === 0) return { content: [{
2953
3343
  type: "text",
2954
3344
  text: `No attachments found on ticket #${ticket_id}.`
@@ -3202,9 +3592,10 @@ const createTicketTools = (ctx) => {
3202
3592
  const { problem_id } = params;
3203
3593
  const token = await getToken();
3204
3594
  const incidents = (await zendeskGet(subdomain, token, `/tickets/${problem_id}/incidents`)).tickets ?? [];
3595
+ const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
3205
3596
  return { content: [{
3206
3597
  type: "text",
3207
- text: truncateIfNeeded(incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`)
3598
+ text: truncateIfNeeded(text)
3208
3599
  }] };
3209
3600
  }
3210
3601
  },
@@ -3270,9 +3661,10 @@ const createTicketTools = (ctx) => {
3270
3661
  throw error;
3271
3662
  }
3272
3663
  const policies = response.sla_policies ?? [];
3664
+ const meta = extractOffsetPaginationMeta(response, policies.length, per_page, page);
3273
3665
  return { content: [{
3274
3666
  type: "text",
3275
- text: formatList(policies, formatSlaPolicy, extractOffsetPaginationMeta(response, policies.length, per_page, page))
3667
+ text: formatList(policies, formatSlaPolicy, meta)
3276
3668
  }] };
3277
3669
  }
3278
3670
  },
@@ -3406,9 +3798,10 @@ const createTicketTools = (ctx) => {
3406
3798
  const token = await getToken();
3407
3799
  const response = await zendeskGet(subdomain, token, "/macros/active", buildOffsetParams(per_page, page));
3408
3800
  const macros = response.macros ?? [];
3801
+ const meta = extractOffsetPaginationMeta(response, macros.length, per_page, page);
3409
3802
  return { content: [{
3410
3803
  type: "text",
3411
- text: formatList(macros, formatMacro, extractOffsetPaginationMeta(response, macros.length, per_page, page))
3804
+ text: formatList(macros, formatMacro, meta)
3412
3805
  }] };
3413
3806
  }
3414
3807
  },
@@ -3753,6 +4146,10 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3753
4146
  case "single":
3754
4147
  registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
3755
4148
  break;
4149
+ default: {
4150
+ const unhandled = config.mode;
4151
+ throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
4152
+ }
3756
4153
  }
3757
4154
  if (helpCenterContextEnabled(config)) {
3758
4155
  const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
@@ -3840,7 +4237,8 @@ const startStdioTransport = async (server, logger = silentLogger) => {
3840
4237
  };
3841
4238
  //#endregion
3842
4239
  //#region src/dev/reload.ts
3843
- const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "tools");
4240
+ const thisDir = dirname(fileURLToPath(import.meta.url));
4241
+ const toolsDir = join(thisDir, "..", "tools");
3844
4242
  const TOOL_MODULES = [
3845
4243
  {
3846
4244
  file: "tickets.ts",
@@ -3979,6 +4377,7 @@ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
3979
4377
  "::",
3980
4378
  "*"
3981
4379
  ]);
4380
+ const TRAILING_SLASHES = /\/+$/;
3982
4381
  const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
3983
4382
  "https://chatgpt.com",
3984
4383
  "https://chat.openai.com",
@@ -4054,7 +4453,7 @@ const handleCorsPreflight = (req, res, extraOrigins) => {
4054
4453
  return true;
4055
4454
  };
4056
4455
  const resolveResourceUrl = (config, logger = silentLogger) => {
4057
- if (config.publicUrl) return config.publicUrl.replace(/\/+$/, "");
4456
+ if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
4058
4457
  if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
4059
4458
  logger.warn("public_url_unset", {
4060
4459
  host: config.host,
@@ -4111,6 +4510,14 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
4111
4510
  jsonrpc: "2.0"
4112
4511
  }));
4113
4512
  };
4513
+ const failRequest = (res, err) => {
4514
+ const message = err instanceof Error ? err.message : "Internal Server Error";
4515
+ if (!res.headersSent) {
4516
+ sendJsonRpcError(res, 500, -32603, message);
4517
+ return;
4518
+ }
4519
+ if (!res.writableEnded) res.end();
4520
+ };
4114
4521
  const sendUnauthorized = (res, resource) => {
4115
4522
  const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
4116
4523
  sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
@@ -4174,13 +4581,29 @@ const respondBodyError = (req, res, failure) => {
4174
4581
  if (failure.status === 413) if (res.writableFinished) req.destroy();
4175
4582
  else res.once("finish", () => req.destroy());
4176
4583
  };
4177
- const SESSION_IDLE_TIMEOUT_MS = 1800 * 1e3;
4178
- const SESSION_SWEEP_INTERVAL_MS = 60 * 1e3;
4584
+ const SESSION_IDLE_TIMEOUT_MS = 18e5;
4585
+ const SESSION_SWEEP_INTERVAL_MS = 6e4;
4179
4586
  const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
4180
4587
  const metadata = buildOAuthMetadata(config, logger);
4181
4588
  const sessions = /* @__PURE__ */ new Map();
4182
4589
  const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
4183
4590
  const maxBodyBytes = options.maxBodyBytes ?? 4194304;
4591
+ const dispatchToSession = async (req, res, sessionId, bearer) => {
4592
+ const session = sessions.get(sessionId);
4593
+ if (!session) return false;
4594
+ session.auth.bearer = bearer;
4595
+ session.lastActivityAt = Date.now();
4596
+ const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
4597
+ ok: true,
4598
+ value: void 0
4599
+ };
4600
+ if (!body.ok) {
4601
+ respondBodyError(req, res, body);
4602
+ return true;
4603
+ }
4604
+ await session.transport.handleRequest(req, res, body.value);
4605
+ return true;
4606
+ };
4184
4607
  const handleMcpRequest = async (req, res) => {
4185
4608
  const bearer = extractBearer(req);
4186
4609
  if (!bearer) {
@@ -4188,23 +4611,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
4188
4611
  return;
4189
4612
  }
4190
4613
  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
- }
4614
+ if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
4208
4615
  if (req.method !== "POST") {
4209
4616
  sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
4210
4617
  return;
@@ -4236,24 +4643,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
4236
4643
  await server.connect(transport);
4237
4644
  await transport.handleRequest(req, res, body.value);
4238
4645
  };
4646
+ const staticGetRoutes = {
4647
+ "/.well-known/oauth-protected-resource": metadata.protectedResource,
4648
+ "/.well-known/oauth-authorization-server": metadata.authorizationServer,
4649
+ "/healthz": {
4650
+ status: "ok",
4651
+ subdomain: config.subdomain
4652
+ }
4653
+ };
4239
4654
  const requestListener = async (req, res) => {
4240
4655
  try {
4241
4656
  if (handleCorsPreflight(req, res, config.corsOrigins)) return;
4242
4657
  applyCorsHeaders(req, res, config.corsOrigins);
4243
4658
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
4244
- if (url.pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
4245
- sendJson(res, 200, metadata.protectedResource);
4246
- return;
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
- });
4659
+ const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
4660
+ if (staticRoute) {
4661
+ sendJson(res, 200, staticRoute);
4257
4662
  return;
4258
4663
  }
4259
4664
  if (url.pathname === "/mcp") {
@@ -4266,9 +4671,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
4266
4671
  path: url.pathname
4267
4672
  }));
4268
4673
  } catch (err) {
4269
- const message = err instanceof Error ? err.message : "Internal Server Error";
4270
- if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
4271
- else if (!res.writableEnded) res.end();
4674
+ failRequest(res, err);
4272
4675
  }
4273
4676
  };
4274
4677
  const httpServer = createServer((req, res) => {
@@ -4333,7 +4736,8 @@ const main = async () => {
4333
4736
  await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
4334
4737
  return;
4335
4738
  }
4336
- await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
4739
+ const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
4740
+ await startStdioTransport(server, logger);
4337
4741
  return;
4338
4742
  }
4339
4743
  if (config.dev) logger.warn("dev_mode_ignored_http");
@@ -4345,5 +4749,3 @@ main().catch((error) => {
4345
4749
  });
4346
4750
  //#endregion
4347
4751
  export {};
4348
-
4349
- //# sourceMappingURL=index.js.map