@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 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],
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- 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;
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
- const code = url.searchParams.get("code");
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
- 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;
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
- 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}`);
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
- const tokenData = await tokenResponse.json();
248
+ };
249
+ try {
250
+ const token = await exchangeCodeForToken(code);
232
251
  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);
252
+ return {
253
+ status: 200,
254
+ html: SUCCESS_PAGE,
255
+ outcome: {
256
+ ok: true,
257
+ token
258
+ }
259
+ };
238
260
  } 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);
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 = /^@([^/]+)\/(.+)$/.exec(name);
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
- 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();
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 (!/^\d+$/.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
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 (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;
692
- i++;
693
- } else if (arg === "--transport" && next) {
694
- result.transport = next;
765
+ if (valued && next) {
766
+ valued(result, next);
695
767
  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++;
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
- transport,
745
- host,
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(/\s+/).length;
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) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(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 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
- }
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 = locale ? `/${locale}/categories` : "/categories";
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 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));
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 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, {
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 ? `\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._` : ""}`)
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
- 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.");
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 (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
- }
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
- 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
- }
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 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
- }
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 ${sourceUpdated} | target ${targetUpdated}`,
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 ${+(MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2)} MB per-image limit`;
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 (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
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
- 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
- }
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 formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2794
- const after = result?.ticket ?? {};
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
- const afterFields = [after.fields ?? after.custom_fields ?? []].flat();
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
- 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 ?? []);
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
- 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
- });
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
- 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();
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