@fruggr/zendesk-mcp-server 2.15.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
@@ -7,7 +7,7 @@ import open from "open";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import * as z from "zod/v4";
10
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import * as cheerio from "cheerio";
12
12
  import { toHtml } from "hast-util-to-html";
13
13
  import rehypeParse from "rehype-parse";
@@ -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],
@@ -114,6 +124,7 @@ const positiveIntEnv = (name, fallback) => {
114
124
  const parsed = Number(raw);
115
125
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
116
126
  };
127
+ const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
117
128
  const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
118
129
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
119
130
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
@@ -153,6 +164,8 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
153
164
  * browser response; without escaping these are a reflected-XSS sink.
154
165
  */
155
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>";
156
169
  const generateCodeVerifier = () => randomBytes(32).toString("base64url");
157
170
  const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
158
171
  /**
@@ -178,69 +191,92 @@ const startBrowserAuth = (config, logger = silentLogger) => {
178
191
  });
179
192
  let authTimeout;
180
193
  let callbackServer;
181
- callbackServer = createServer(async (req, res) => {
182
- const url = new URL(req.url ?? "/", `http://localhost`);
183
- if (url.pathname !== "/callback") {
184
- res.writeHead(404);
185
- res.end("Not found");
186
- 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}`);
187
212
  }
188
- 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) => {
189
224
  const error = url.searchParams.get("error");
225
+ const code = url.searchParams.get("code");
190
226
  logger.debug("oauth_callback_received", {
191
227
  hasCode: Boolean(code),
192
228
  hasError: Boolean(error)
193
229
  });
194
230
  if (error) {
195
231
  const desc = url.searchParams.get("error_description") ?? error;
196
- res.writeHead(400, { "Content-Type": "text/html" });
197
- res.end(`<html><body><h1>Authentication failed</h1><p>${escapeHtml(desc)}</p></body></html>`);
198
- clearTimeout(authTimeout);
199
- callbackServer.close();
200
- rejectToken(/* @__PURE__ */ new Error(`OAuth error: ${desc}`));
201
- return;
202
- }
203
- if (!code) {
204
- res.writeHead(400, { "Content-Type": "text/html" });
205
- res.end("<html><body><h1>Missing authorization code</h1></body></html>");
206
- clearTimeout(authTimeout);
207
- callbackServer.close();
208
- rejectToken(/* @__PURE__ */ new Error("Missing authorization code in callback"));
209
- 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
+ };
210
240
  }
211
- try {
212
- const callbackPort = callbackServer.address().port;
213
- const tokenBody = new URLSearchParams({
214
- grant_type: "authorization_code",
215
- code,
216
- client_id: oauthClientId,
217
- redirect_uri: `http://localhost:${callbackPort}/callback`,
218
- code_verifier: codeVerifier
219
- });
220
- const tokenResponse = await fetch(tokenUrl, {
221
- method: "POST",
222
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
223
- body: tokenBody.toString()
224
- });
225
- logger.debug("oauth_token_exchange", { status: tokenResponse.status });
226
- if (!tokenResponse.ok) {
227
- const errorBody = await tokenResponse.text();
228
- 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")
229
247
  }
230
- const tokenData = await tokenResponse.json();
248
+ };
249
+ try {
250
+ const token = await exchangeCodeForToken(code);
231
251
  logger.info("oauth_authenticated");
232
- res.writeHead(200, { "Content-Type": "text/html" });
233
- 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>");
234
- clearTimeout(authTimeout);
235
- callbackServer.close();
236
- resolveToken(tokenData);
252
+ return {
253
+ status: 200,
254
+ html: SUCCESS_PAGE,
255
+ outcome: {
256
+ ok: true,
257
+ token
258
+ }
259
+ };
237
260
  } catch (err) {
238
- res.writeHead(500, { "Content-Type": "text/html" });
239
- res.end(`<html><body><h1>Token exchange failed</h1><p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p></body></html>`);
240
- clearTimeout(authTimeout);
241
- callbackServer.close();
242
- 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;
243
278
  }
279
+ finishRequest(res, await resolveCallback(url));
244
280
  });
245
281
  const requestedPort = config.callbackPort ?? 27439;
246
282
  const onStartError = (err) => {
@@ -383,9 +419,10 @@ const isWindows = process.platform === "win32";
383
419
  * (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
384
420
  * vendor-namespaced and can't collide with another `zendesk-mcp-server`.
385
421
  */
422
+ const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
386
423
  const appDirSegments = () => {
387
424
  const { name } = readPackageInfo();
388
- const scoped = /^@([^/]+)\/(.+)$/.exec(name);
425
+ const scoped = SCOPED_PACKAGE_NAME.exec(name);
389
426
  return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
390
427
  };
391
428
  const configDir = () => {
@@ -521,20 +558,23 @@ const createTokenStore = (config, logger = silentLogger) => {
521
558
  throw err;
522
559
  });
523
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
+ };
524
569
  const getToken = async () => {
525
- if (refreshing) await refreshing;
570
+ if (refreshing !== void 0) await refreshing;
526
571
  if (token && !needsRefresh(token)) {
527
572
  logger.debug("oauth_token_cache_hit");
528
573
  return token.accessToken;
529
574
  }
530
- if (token?.refreshToken) {
531
- if (!refreshing) refreshing = tryRefresh(token).finally(() => {
532
- refreshing = void 0;
533
- });
534
- const refreshed = await refreshing;
535
- if (refreshed) return refreshed;
536
- }
537
- if (!starting) starting = beginAuth();
575
+ const refreshed = await refreshIfPossible();
576
+ if (refreshed) return refreshed;
577
+ if (starting === void 0) starting = beginAuth();
538
578
  const url = authorizeUrl ?? await starting;
539
579
  throw createAuthRequiredError(url);
540
580
  };
@@ -603,6 +643,18 @@ const ConfigSchema = z.object({
603
643
  */
604
644
  topology: z.boolean().default(true),
605
645
  /**
646
+ * Whether to PRE-LIST the promoted ("featured") Help Center articles: the
647
+ * `<scheme>://article/{id}` resource's `list` callback (which scans `/articles`
648
+ * to enumerate the promoted set for `resources/list`) AND the
649
+ * `list_promoted_articles` tool. On by default; an operator disables the
650
+ * pre-listing with `--no-promoted-articles` (e.g. on a very large Help Center
651
+ * where scanning is costly) so the server issues zero preloading requests. This
652
+ * does NOT disable reading a known article by id (`<scheme>://article/{id}` stays
653
+ * registered) — that is cheap and on-demand. Only ever active when the
654
+ * `help_center` namespace itself is active.
655
+ */
656
+ promotedArticles: z.boolean().default(true),
657
+ /**
606
658
  * URI scheme of the Help Center MCP resources (today the topology resource,
607
659
  * `<scheme>://topology`). Defaults to `zendesk-hc`; a deployer can brand it
608
660
  * (`--hc-resource-scheme wiki` / `HC_RESOURCE_SCHEME=wiki`). Strictly a bare
@@ -644,75 +696,96 @@ const ConfigSchema = z.object({
644
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([]),
645
697
  callbackPort: z.number().int().min(1).max(65535).optional()
646
698
  });
699
+ const DIGITS_ONLY = /^\d+$/;
647
700
  const parsePort = (raw, label) => {
648
- 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.`);
649
702
  return Number(raw);
650
703
  };
651
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
+ ]);
652
753
  const parseCliArgs = (args) => {
653
754
  const result = {};
654
- let positionalIndex = 0;
655
755
  for (let i = 0; i < args.length; i++) {
656
756
  const arg = args[i];
657
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);
658
764
  const next = args[i + 1];
659
- if (arg === "--mode" && next) {
660
- result.mode = next;
661
- i++;
662
- } else if (arg === "--read-only") result.readOnly = true;
663
- else if (arg === "--no-topology") result.topology = false;
664
- else if (arg === "--hc-resource-scheme" && next) {
665
- result.hcResourceScheme = next;
666
- i++;
667
- } else if (arg === "--dev") result.dev = true;
668
- else if (arg === "--namespace" && next) {
669
- result.namespaces = result.namespaces ?? [];
670
- result.namespaces.push(next);
671
- i++;
672
- } else if (arg === "--tool" && next) {
673
- result.tools = result.tools ?? [];
674
- result.tools.push(next);
675
- i++;
676
- } else if (arg === "--log-level" && next) {
677
- result.logLevel = next;
678
- i++;
679
- } else if (arg === "--transport" && next) {
680
- result.transport = next;
681
- i++;
682
- } else if (arg === "--host" && next) {
683
- result.host = next;
684
- i++;
685
- } else if (arg === "--port" && next) {
686
- result.port = parsePort(next, "--port");
687
- i++;
688
- } else if (arg === "--public-url" && next) {
689
- result.publicUrl = next;
690
- i++;
691
- } else if (arg === "--cors-origin" && next) {
692
- result.corsOrigins = result.corsOrigins ?? [];
693
- result.corsOrigins.push(next);
694
- i++;
695
- } else if (arg === "--callback-port" && next) {
696
- result.callbackPort = parsePort(next, "--callback-port");
765
+ if (valued && next) {
766
+ valued(result, next);
697
767
  i++;
698
- } else if (!arg.startsWith("-") && positionalIndex === 0) {
699
- result.subdomain = arg;
700
- positionalIndex++;
768
+ continue;
701
769
  }
770
+ if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
702
771
  }
703
772
  return result;
704
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
+ };
705
784
  const loadConfig = (argv = process.argv.slice(2)) => {
706
785
  const cli = parseCliArgs(argv);
707
786
  const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
708
787
  const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
709
788
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
710
- const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
711
- const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
712
- const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
713
- const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
714
- const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
715
- const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
716
789
  const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
717
790
  const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
718
791
  return ConfigSchema.parse({
@@ -724,14 +797,11 @@ const loadConfig = (argv = process.argv.slice(2)) => {
724
797
  namespaces: cli.namespaces,
725
798
  tools: cli.tools,
726
799
  topology: cli.topology ?? true,
800
+ promotedArticles: cli.promotedArticles ?? true,
727
801
  hcResourceScheme,
728
802
  dev: cli.dev ?? false,
729
- transport,
730
- host,
731
- port,
732
- publicUrl,
733
- corsOrigins,
734
- callbackPort
803
+ callbackPort,
804
+ ...resolveTransportSettings(cli)
735
805
  });
736
806
  };
737
807
  //#endregion
@@ -872,44 +942,100 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
872
942
  return response.json();
873
943
  };
874
944
  //#endregion
875
- //#region src/guidance/instructions.ts
876
- /**
877
- * URI of the dynamic Help Center topology resource. Single source of truth for
878
- * every place that cites it (resource registration, `instructions` blob): the
879
- * scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
880
- * the path is fixed. Any future Help Center resource should build its URI the
881
- * same way so the whole surface follows the configured scheme.
882
- */
883
- const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
884
- /**
885
- * Whether the Help Center structural context (init instructions + the
886
- * topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
887
- * feature is enabled (`--no-topology` not set) AND the `help_center` namespace
888
- * is active (no `--namespace` filter, or one that includes it). Shared by the
889
- * instructions builder and the resource registration in `server.ts` so both
890
- * gates stay in sync.
891
- */
892
- const helpCenterContextEnabled = (config) => config.topology && (!config.namespaces?.length || config.namespaces.includes("help_center"));
893
- /**
894
- * The static `instructions` blob sent on `initialize`. Deliberately short and
895
- * I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
896
- * it stays within a tight token budget. The rich, dynamic topology lives in the
897
- * pull-only topology resource (default `zendesk-hc://topology`) referenced here.
898
- */
899
- const buildInstructions = (config) => {
900
- if (!helpCenterContextEnabled(config)) return void 0;
901
- return [
902
- `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
903
- "",
904
- `When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
905
- "it lists the active locales (and the default one), the category → section tree with IDs,",
906
- "the visibility user segments, the permission groups, and your current role.",
907
- "Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
908
- "",
909
- "It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
910
- "user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
911
- "or user_segment_id from an existing article (get_article) instead."
912
- ].join("\n");
945
+ //#region src/utils/article-sections.ts
946
+ const HEADING_LEVELS = /* @__PURE__ */ new Set([
947
+ "h1",
948
+ "h2",
949
+ "h3"
950
+ ]);
951
+ const WHITESPACE_RUN = /\s+/;
952
+ const countWords = (text) => {
953
+ const trimmed = text.trim();
954
+ if (!trimmed) return 0;
955
+ return trimmed.split(WHITESPACE_RUN).length;
956
+ };
957
+ const textOf = (html) => {
958
+ if (!html) return "";
959
+ return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
960
+ };
961
+ const parseSections = (html) => {
962
+ if (!html?.trim()) return [];
963
+ const $ = cheerio.load(html, null, false);
964
+ const children = $.root().contents().toArray();
965
+ const introParts = [];
966
+ const sections = [];
967
+ let current = null;
968
+ for (const node of children) {
969
+ const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
970
+ if (HEADING_LEVELS.has(tagName)) {
971
+ const level = Number.parseInt(tagName.slice(1), 10);
972
+ current = {
973
+ heading: $(node).text().trim(),
974
+ headingTag: tagName,
975
+ level,
976
+ contentParts: []
977
+ };
978
+ sections.push(current);
979
+ continue;
980
+ }
981
+ const outer = $.html(node);
982
+ if (current) current.contentParts.push(outer);
983
+ else introParts.push(outer);
984
+ }
985
+ const result = [];
986
+ if (introParts.length > 0) {
987
+ const introHtml = introParts.join("");
988
+ result.push({
989
+ index: 0,
990
+ heading: "intro",
991
+ headingTag: "",
992
+ level: 0,
993
+ html: introHtml,
994
+ wordCount: countWords(textOf(introHtml))
995
+ });
996
+ }
997
+ for (const s of sections) {
998
+ const sectionHtml = s.contentParts.join("");
999
+ result.push({
1000
+ index: result.length,
1001
+ heading: s.heading,
1002
+ headingTag: s.headingTag,
1003
+ level: s.level,
1004
+ html: sectionHtml,
1005
+ wordCount: countWords(textOf(sectionHtml))
1006
+ });
1007
+ }
1008
+ return result;
1009
+ };
1010
+ const replaceSectionContent = (html, sectionIndex, newHtml) => {
1011
+ const sections = parseSections(html);
1012
+ if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
1013
+ return sections.map((section, idx) => {
1014
+ const content = idx === sectionIndex ? newHtml : section.html;
1015
+ if (section.level === 0) return content;
1016
+ return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
1017
+ }).join("");
1018
+ };
1019
+ const keepAsHtml = (_state, node) => ({
1020
+ type: "html",
1021
+ value: toHtml(node)
1022
+ });
1023
+ const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
1024
+ table: keepAsHtml,
1025
+ pre: keepAsHtml
1026
+ } }).use(remarkGfm).use(remarkStringify, {
1027
+ bullet: "-",
1028
+ emphasis: "_",
1029
+ fences: true
1030
+ });
1031
+ const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
1032
+ const htmlToMarkdown = (html) => {
1033
+ if (!html) return "";
1034
+ return String(htmlToMdProcessor.processSync(html));
1035
+ };
1036
+ const markdownToHtml = (markdown) => {
1037
+ if (!markdown) return "";
1038
+ return String(mdToHtmlProcessor.processSync(markdown));
913
1039
  };
914
1040
  //#endregion
915
1041
  //#region src/utils/formatting.ts
@@ -930,7 +1056,11 @@ const formatTicket = (ticket) => [
930
1056
  `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
931
1057
  ticket.description ? `\n${ticket.description}` : ""
932
1058
  ].filter(Boolean).join("\n");
933
- 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
+ };
934
1064
  const formatSlaPolicy = (policy) => {
935
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())];
936
1066
  const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
@@ -1107,6 +1237,7 @@ const formatArticleSummary = (article) => [
1107
1237
  `## ${article.title} (${article.id})`,
1108
1238
  `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
1109
1239
  `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
1240
+ article.promoted ? "- **Promoted**: featured in its section — changing this requires Help Center admin (Guide admin) rights; set via update_article `promoted`." : "",
1110
1241
  `- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
1111
1242
  typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
1112
1243
  article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
@@ -1178,6 +1309,195 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
1178
1309
  };
1179
1310
  };
1180
1311
  //#endregion
1312
+ //#region src/guidance/article-resources.ts
1313
+ /**
1314
+ * Name of the companion tool that lists promoted articles. Shared between the
1315
+ * tool definition (`help-center.ts`) and the `--no-promoted-articles` opt-out
1316
+ * filter (`server.ts`) so the two can never drift: renaming the tool here keeps
1317
+ * the filter dropping it, preserving the "zero preloading requests when off"
1318
+ * invariant.
1319
+ */
1320
+ const LIST_PROMOTED_ARTICLES_TOOL = "list_promoted_articles";
1321
+ /**
1322
+ * Scan the Help Center for promoted ("featured") articles with the CALLER'S
1323
+ * token, so the result respects that user's read permissions. The API exposes no
1324
+ * server-side `promoted` filter (only label_names / sort), so we page through
1325
+ * `/articles` and filter `promoted` client-side, bounded by `maxPages` to keep
1326
+ * the scan tractable on a large Help Center. `truncated` signals the cap was hit.
1327
+ *
1328
+ * Returns the FULL promoted articles so callers that need rich metadata (the
1329
+ * `list_promoted_articles` tool) get everything; the resource provider maps these
1330
+ * down to lean refs before caching so the per-session cache doesn't retain bodies.
1331
+ */
1332
+ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOURCES_SCAN_MAX_PAGES) => {
1333
+ const promoted = [];
1334
+ let cursor;
1335
+ let pages = 0;
1336
+ let truncated = false;
1337
+ do {
1338
+ const response = await helpCenterGet(subdomain, token, "/articles", buildCursorParams(100, cursor));
1339
+ const articles = response.articles ?? [];
1340
+ for (const article of articles) if (article.promoted) promoted.push(article);
1341
+ pages += 1;
1342
+ const meta = extractPaginationMeta(response, articles.length);
1343
+ cursor = meta.has_more ? meta.after_cursor ?? void 0 : void 0;
1344
+ if (cursor && pages >= maxPages) {
1345
+ truncated = true;
1346
+ break;
1347
+ }
1348
+ } while (cursor);
1349
+ return {
1350
+ articles: promoted,
1351
+ truncated,
1352
+ pagesScanned: pages
1353
+ };
1354
+ };
1355
+ /**
1356
+ * Fetch a single article by id (optionally a translated locale) with the
1357
+ * caller's token and render it as Markdown: the shared metadata summary plus the
1358
+ * body converted from HTML (rather than a raw HTML dump), capped by the response
1359
+ * character limit. Reuses the same formatting as the `get_article` tool.
1360
+ */
1361
+ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1362
+ const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${id}` : `/articles/${id}`);
1363
+ return truncateIfNeeded([
1364
+ formatArticleSummary(article),
1365
+ "",
1366
+ htmlToMarkdown(article.body)
1367
+ ].join("\n"));
1368
+ };
1369
+ /**
1370
+ * Build an article-resources provider. `listPromoted` holds a memoized-promise
1371
+ * cache (TTL `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list`
1372
+ * calls a client makes; `readArticle` is a one-shot fetch (not cached). As with
1373
+ * `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
1374
+ * module scope — in HTTP mode this provider is instantiated per session, so a
1375
+ * shared cache would leak one caller's data to another. `getToken` is resolved
1376
+ * lazily at call time (never at construction) so connecting never triggers the
1377
+ * OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
1378
+ * stale token, mirroring the topology provider and the tool dispatch path.
1379
+ */
1380
+ const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
1381
+ let cached;
1382
+ const notifyIfUnauthorized = (err) => {
1383
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
1384
+ };
1385
+ return {
1386
+ listPromoted() {
1387
+ const now = Date.now();
1388
+ if (cached && now - cached.at < 3e5) return cached.promise;
1389
+ const promise = (async () => {
1390
+ const token = await getToken();
1391
+ const { articles, truncated } = await fetchPromotedArticles(subdomain, token);
1392
+ return {
1393
+ refs: articles.map((a) => ({
1394
+ id: a.id,
1395
+ title: a.title
1396
+ })),
1397
+ truncated
1398
+ };
1399
+ })().catch((err) => {
1400
+ cached = void 0;
1401
+ notifyIfUnauthorized(err);
1402
+ throw err;
1403
+ });
1404
+ cached = {
1405
+ at: now,
1406
+ promise
1407
+ };
1408
+ return promise;
1409
+ },
1410
+ async readArticle(id) {
1411
+ try {
1412
+ const token = await getToken();
1413
+ return await fetchArticleMarkdown(subdomain, token, id);
1414
+ } catch (err) {
1415
+ notifyIfUnauthorized(err);
1416
+ throw err;
1417
+ }
1418
+ }
1419
+ };
1420
+ };
1421
+ //#endregion
1422
+ //#region src/guidance/instructions.ts
1423
+ /**
1424
+ * URI of the dynamic Help Center topology resource. Single source of truth for
1425
+ * every place that cites it (resource registration, `instructions` blob): the
1426
+ * scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
1427
+ * the path is fixed. Any future Help Center resource should build its URI the
1428
+ * same way so the whole surface follows the configured scheme.
1429
+ */
1430
+ const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
1431
+ /**
1432
+ * URI template of the pull-only Help Center article resources, built from the
1433
+ * configured scheme exactly like `topologyResourceUri` (`--hc-resource-scheme`,
1434
+ * default `zendesk-hc` → `zendesk-hc://article/{id}`). The template's `list`
1435
+ * callback enumerates the promoted ("featured") articles (so clients can surface
1436
+ * them for pinning), while any article id can be read on demand.
1437
+ */
1438
+ const articleResourceUriTemplate = (config) => `${config.hcResourceScheme}://article/{id}`;
1439
+ /**
1440
+ * Build the concrete resource URI for a single article id, under the configured
1441
+ * scheme. Shares the scheme with the template above so the listed URIs always
1442
+ * match the template the read callback is registered under.
1443
+ */
1444
+ const articleResourceUri = (config, id) => `${config.hcResourceScheme}://article/${id}`;
1445
+ /**
1446
+ * Whether the `help_center` namespace is active: no `--namespace` filter, or one
1447
+ * that includes it. The shared second half of the two Help Center feature gates
1448
+ * below, so the namespace semantics live in one place.
1449
+ */
1450
+ const helpCenterNamespaceActive = (config) => !config.namespaces?.length || config.namespaces.includes("help_center");
1451
+ /**
1452
+ * Whether the Help Center structural context (init instructions + the
1453
+ * topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
1454
+ * feature is enabled (`--no-topology` not set) AND the `help_center` namespace
1455
+ * is active. Shared by the instructions builder and the resource registration in
1456
+ * `server.ts` so both gates stay in sync.
1457
+ */
1458
+ const helpCenterContextEnabled = (config) => config.topology && helpCenterNamespaceActive(config);
1459
+ /**
1460
+ * Whether the read-by-id article resource (`<scheme>://article/{id}`) should be
1461
+ * registered. Available whenever the `help_center` namespace is active — reading
1462
+ * one article is a cheap, on-demand single fetch with NO preloading, so it is
1463
+ * deliberately NOT gated by the promoted-listing flag: `--no-promoted-articles`
1464
+ * turns off the costly pre-listing (below), never the ability to address a known
1465
+ * article id. Not tied to `--no-topology` either (topology is a separate feature).
1466
+ */
1467
+ const articleResourceEnabled = (config) => helpCenterNamespaceActive(config);
1468
+ /**
1469
+ * Whether the promoted-article PRE-LISTING is exposed: the resource `list`
1470
+ * callback's scan (which enumerates the promoted articles for `resources/list`)
1471
+ * AND the `list_promoted_articles` tool. This is the costly, fan-out part (a capped
1472
+ * scan of `/articles`, no server-side promoted filter), so it gets its own flag —
1473
+ * `--no-promoted-articles` turns it off so the server issues zero preloading
1474
+ * requests, while read-by-id (above) stays available. `!== false` (not truthiness)
1475
+ * so an omitted flag on a hand-built Config stays default-on, matching the tool
1476
+ * filter in `server.ts`.
1477
+ */
1478
+ const promotedArticlesEnabled = (config) => config.promotedArticles !== false && helpCenterNamespaceActive(config);
1479
+ /**
1480
+ * The static `instructions` blob sent on `initialize`. Deliberately short and
1481
+ * I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
1482
+ * it stays within a tight token budget. The rich, dynamic topology lives in the
1483
+ * pull-only topology resource (default `zendesk-hc://topology`) referenced here.
1484
+ */
1485
+ const buildInstructions = (config) => {
1486
+ if (!helpCenterContextEnabled(config)) return void 0;
1487
+ return [
1488
+ `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
1489
+ "",
1490
+ `When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
1491
+ "it lists the active locales (and the default one), the category → section tree with IDs,",
1492
+ "the visibility user segments, the permission groups, and your current role.",
1493
+ "Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
1494
+ "",
1495
+ "It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
1496
+ "user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
1497
+ "or user_segment_id from an existing article (get_article) instead."
1498
+ ].join("\n");
1499
+ };
1500
+ //#endregion
1181
1501
  //#region src/guidance/topology.ts
1182
1502
  /**
1183
1503
  * Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
@@ -1233,20 +1553,20 @@ const fetchTopology = async (subdomain, token) => {
1233
1553
  currentUser: meRes.user
1234
1554
  };
1235
1555
  };
1236
- const renderTree = (data) => {
1237
- if (data.categoriesHasMore || data.sectionsHasMore) {
1238
- const reasons = [];
1239
- if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
1240
- if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
1241
- return [
1242
- `Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
1243
- data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
1244
- ...data.categories.map(formatCategory),
1245
- "",
1246
- ...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
1247
- "Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
1248
- ];
1249
- }
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) => {
1250
1570
  const byCategory = /* @__PURE__ */ new Map();
1251
1571
  for (const section of data.sections) {
1252
1572
  const list = byCategory.get(section.category_id) ?? [];
@@ -1260,6 +1580,7 @@ const renderTree = (data) => {
1260
1580
  }
1261
1581
  return lines.length ? lines : ["_(no categories)_"];
1262
1582
  };
1583
+ const renderTree = (data) => data.categoriesHasMore || data.sectionsHasMore ? renderOversizedTreeNotice(data) : renderCategoryTree(data);
1263
1584
  /**
1264
1585
  * Render an admin-gated section as one of three states so the LLM never mistakes
1265
1586
  * "you can't see this" for "there are none": the formatted list, `_(none)_` when
@@ -1413,101 +1734,6 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
1413
1734
  return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
1414
1735
  };
1415
1736
  //#endregion
1416
- //#region src/utils/article-sections.ts
1417
- const HEADING_LEVELS = /* @__PURE__ */ new Set([
1418
- "h1",
1419
- "h2",
1420
- "h3"
1421
- ]);
1422
- const countWords = (text) => {
1423
- const trimmed = text.trim();
1424
- if (!trimmed) return 0;
1425
- return trimmed.split(/\s+/).length;
1426
- };
1427
- const textOf = (html) => {
1428
- if (!html) return "";
1429
- return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
1430
- };
1431
- const parseSections = (html) => {
1432
- if (!html?.trim()) return [];
1433
- const $ = cheerio.load(html, null, false);
1434
- const children = $.root().contents().toArray();
1435
- const introParts = [];
1436
- const sections = [];
1437
- let current = null;
1438
- for (const node of children) {
1439
- const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
1440
- if (HEADING_LEVELS.has(tagName)) {
1441
- const level = Number.parseInt(tagName.slice(1), 10);
1442
- current = {
1443
- heading: $(node).text().trim(),
1444
- headingTag: tagName,
1445
- level,
1446
- contentParts: []
1447
- };
1448
- sections.push(current);
1449
- continue;
1450
- }
1451
- const outer = $.html(node);
1452
- if (current) current.contentParts.push(outer);
1453
- else introParts.push(outer);
1454
- }
1455
- const result = [];
1456
- if (introParts.length > 0) {
1457
- const introHtml = introParts.join("");
1458
- result.push({
1459
- index: 0,
1460
- heading: "intro",
1461
- headingTag: "",
1462
- level: 0,
1463
- html: introHtml,
1464
- wordCount: countWords(textOf(introHtml))
1465
- });
1466
- }
1467
- for (const s of sections) {
1468
- const sectionHtml = s.contentParts.join("");
1469
- result.push({
1470
- index: result.length,
1471
- heading: s.heading,
1472
- headingTag: s.headingTag,
1473
- level: s.level,
1474
- html: sectionHtml,
1475
- wordCount: countWords(textOf(sectionHtml))
1476
- });
1477
- }
1478
- return result;
1479
- };
1480
- const replaceSectionContent = (html, sectionIndex, newHtml) => {
1481
- const sections = parseSections(html);
1482
- if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
1483
- return sections.map((section, idx) => {
1484
- const content = idx === sectionIndex ? newHtml : section.html;
1485
- if (section.level === 0) return content;
1486
- return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
1487
- }).join("");
1488
- };
1489
- const keepAsHtml = (_state, node) => ({
1490
- type: "html",
1491
- value: toHtml(node)
1492
- });
1493
- const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
1494
- table: keepAsHtml,
1495
- pre: keepAsHtml
1496
- } }).use(remarkGfm).use(remarkStringify, {
1497
- bullet: "-",
1498
- emphasis: "_",
1499
- fences: true
1500
- });
1501
- const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
1502
- const htmlToMarkdown = (html) => {
1503
- if (!html) return "";
1504
- return String(htmlToMdProcessor.processSync(html));
1505
- };
1506
- const markdownToHtml = (markdown) => {
1507
- if (!markdown) return "";
1508
- return String(mdToHtmlProcessor.processSync(markdown));
1509
- };
1510
- //#endregion
1511
1737
  //#region src/tools/help-center.ts
1512
1738
  const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
1513
1739
  const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
@@ -1521,6 +1747,60 @@ const largeArticleHint = (body, sectionCount) => {
1521
1747
  ].join("\n");
1522
1748
  };
1523
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
+ };
1524
1804
  const createHelpCenterTools = (ctx) => {
1525
1805
  const { subdomain, getToken } = ctx;
1526
1806
  const fetchSectionOrder = async (sectionId, locale, token) => {
@@ -1538,6 +1818,26 @@ const createHelpCenterTools = (ctx) => {
1538
1818
  } while (cursor);
1539
1819
  return order;
1540
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
+ };
1541
1841
  return [
1542
1842
  {
1543
1843
  name: "search_articles",
@@ -1620,7 +1920,7 @@ const createHelpCenterTools = (ctx) => {
1620
1920
  handler: async (params) => {
1621
1921
  const { locale, page_size, cursor } = params;
1622
1922
  const token = await getToken();
1623
- const path = locale ? `/${locale}/categories` : "/categories";
1923
+ const path = `${localePrefix(locale)}/categories`;
1624
1924
  const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
1625
1925
  const categories = response.categories ?? [];
1626
1926
  return { content: [{
@@ -1650,8 +1950,7 @@ const createHelpCenterTools = (ctx) => {
1650
1950
  handler: async (params) => {
1651
1951
  const { category_id, locale, page_size, cursor } = params;
1652
1952
  const token = await getToken();
1653
- const path = category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections";
1654
- 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));
1655
1954
  const sections = response.sections ?? [];
1656
1955
  return { content: [{
1657
1956
  type: "text",
@@ -1688,8 +1987,7 @@ const createHelpCenterTools = (ctx) => {
1688
1987
  handler: async (params) => {
1689
1988
  const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
1690
1989
  const token = await getToken();
1691
- const path = section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles";
1692
- const response = await helpCenterGet(subdomain, token, path, {
1990
+ const response = await helpCenterGet(subdomain, token, articleListPath(section_id, locale), {
1693
1991
  ...buildCursorParams(page_size, cursor),
1694
1992
  sort_by,
1695
1993
  sort_order
@@ -1710,6 +2008,31 @@ const createHelpCenterTools = (ctx) => {
1710
2008
  }] };
1711
2009
  }
1712
2010
  },
2011
+ {
2012
+ name: LIST_PROMOTED_ARTICLES_TOOL,
2013
+ namespace: "help_center",
2014
+ readOnly: true,
2015
+ title: "List Promoted Help Center Articles",
2016
+ description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
2017
+ inputSchema: z.object({}),
2018
+ annotations: {
2019
+ readOnlyHint: true,
2020
+ destructiveHint: false,
2021
+ idempotentHint: true,
2022
+ openWorldHint: true
2023
+ },
2024
+ handler: async () => {
2025
+ const token = await getToken();
2026
+ const { articles, truncated, pagesScanned } = await fetchPromotedArticles(subdomain, token);
2027
+ const header = `Promoted (featured) articles: ${articles.length}`;
2028
+ const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
2029
+ const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
2030
+ return { content: [{
2031
+ type: "text",
2032
+ text: truncateIfNeeded(`${header}\n\n${body}${scanCostNote(truncated, pagesScanned, cost)}`)
2033
+ }] };
2034
+ }
2035
+ },
1713
2036
  {
1714
2037
  name: "list_article_translations",
1715
2038
  namespace: "help_center",
@@ -1918,23 +2241,14 @@ const createHelpCenterTools = (ctx) => {
1918
2241
  },
1919
2242
  handler: async (params) => {
1920
2243
  const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
1921
- const needsReference = target === "before" || target === "after";
1922
- if (needsReference && reference_article_id === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
1923
- 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").`);
1924
- 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);
1925
2246
  const token = await getToken();
1926
2247
  const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
1927
2248
  const sectionId = article.section_id;
1928
2249
  const locale = article.source_locale;
1929
2250
  const effective = await fetchSectionOrder(sectionId, locale, token);
1930
- if (needsReference && !effective.some((a) => a.id === reference_article_id)) {
1931
- let detail = "was not found";
1932
- try {
1933
- const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
1934
- detail = `is in section #${ref.section_id}, not section #${sectionId}`;
1935
- } catch {}
1936
- throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
1937
- }
2251
+ if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
1938
2252
  const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
1939
2253
  const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
1940
2254
  if (writes.length === 0) return { content: [{
@@ -1949,14 +2263,7 @@ const createHelpCenterTools = (ctx) => {
1949
2263
  type: "text",
1950
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.`
1951
2265
  }] };
1952
- let applied = 0;
1953
- for (const write of writes) try {
1954
- await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
1955
- applied += 1;
1956
- } catch (error) {
1957
- const reason = error instanceof Error ? error.message : String(error);
1958
- 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 });
1959
- }
2266
+ const applied = await applyPositionWrites(writes, article_id, token);
1960
2267
  if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
1961
2268
  type: "text",
1962
2269
  text: autoSortNotice(sectionId, applied)
@@ -2268,40 +2575,10 @@ const createHelpCenterTools = (ctx) => {
2268
2575
  ]);
2269
2576
  const sourceSections = parseSections(sourceRes.translation.body);
2270
2577
  const targetSections = parseSections(targetRes.translation.body);
2271
- const maxLen = Math.max(sourceSections.length, targetSections.length);
2272
- const sourceUpdated = sourceRes.translation.updated_at;
2273
- const targetUpdated = targetRes.translation.updated_at;
2274
- const srcMs = Date.parse(sourceUpdated);
2275
- const tgtMs = Date.parse(targetUpdated);
2276
- const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
2277
- let freshnessLine;
2278
- if (comparable && srcMs > tgtMs) {
2279
- const days = Math.floor((srcMs - tgtMs) / 864e5);
2280
- freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
2281
- } else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
2282
- else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
2283
- const targetLocaleKey = target_locale.toLowerCase();
2284
- const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
2285
- const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
2286
- 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.`;
2287
- const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
2288
- const targetTags = targetSections.map((s) => s.headingTag).join(",");
2289
- 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.`;
2290
- const rows = [];
2291
- rows.push(`| Idx | Heading | Status | Source words | Target words |`);
2292
- rows.push(`| --- | --- | --- | --- | --- |`);
2293
- for (let i = 0; i < maxLen; i += 1) {
2294
- const src = sourceSections[i];
2295
- const tgt = targetSections[i];
2296
- const heading = src?.heading ?? tgt?.heading ?? "";
2297
- const sourceWords = src?.wordCount ?? 0;
2298
- const targetWords = tgt?.wordCount ?? 0;
2299
- let status;
2300
- if (!tgt) status = "missing";
2301
- else if (!src) status = "extra";
2302
- else status = "ok";
2303
- rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
2304
- }
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);
2305
2582
  return { content: [{
2306
2583
  type: "text",
2307
2584
  text: [
@@ -2310,7 +2587,7 @@ const createHelpCenterTools = (ctx) => {
2310
2587
  freshnessLine,
2311
2588
  outdatedLine,
2312
2589
  structureLine,
2313
- `- **Updated**: source ${sourceUpdated} | target ${targetUpdated}`,
2590
+ `- **Updated**: source ${sourceRes.translation.updated_at} | target ${targetRes.translation.updated_at}`,
2314
2591
  `- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
2315
2592
  "",
2316
2593
  "_Word counts are informational: a length difference between languages is normal, not a divergence._",
@@ -2406,6 +2683,7 @@ const createSearchTools = (ctx) => {
2406
2683
  };
2407
2684
  //#endregion
2408
2685
  //#region src/tools/tickets.ts
2686
+ const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2));
2409
2687
  const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
2410
2688
  const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
2411
2689
  const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
@@ -2434,6 +2712,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
2434
2712
  }
2435
2713
  return all;
2436
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
+ };
2437
2725
  const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2438
2726
  const blocks = [];
2439
2727
  let embeddedCount = 0;
@@ -2447,7 +2735,7 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2447
2735
  continue;
2448
2736
  }
2449
2737
  let skipReason = null;
2450
- 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`;
2451
2739
  else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
2452
2740
  if (skipReason) {
2453
2741
  blocks.push({
@@ -2469,9 +2757,10 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
2469
2757
  }
2470
2758
  return blocks;
2471
2759
  };
2760
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
2472
2761
  const fetchTicketSla = async (subdomain, token, ticket) => {
2473
2762
  const day = ticket.created_at.slice(0, 10);
2474
- if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
2763
+ if (!ISO_DAY.test(day)) return void 0;
2475
2764
  const shiftDay = (offset) => {
2476
2765
  const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
2477
2766
  d.setUTCDate(d.getUTCDate() + offset);
@@ -2541,23 +2830,24 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
2541
2830
  const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
2542
2831
  return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
2543
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
+ };
2544
2845
  const collectAuditIds = (audits) => {
2545
2846
  const userIds = /* @__PURE__ */ new Set();
2546
2847
  const groupIds = /* @__PURE__ */ new Set();
2547
- const addId = (set, raw) => {
2548
- const n = Number(raw);
2549
- if (Number.isInteger(n) && n > 0) set.add(n);
2550
- };
2551
2848
  for (const audit of audits) {
2552
- addId(userIds, audit.author_id);
2553
- for (const event of audit.events) {
2554
- if (event.type !== "Change" && event.type !== "Create") continue;
2555
- const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
2556
- if (!entity) continue;
2557
- const set = entity === "user" ? userIds : groupIds;
2558
- addId(set, event.value);
2559
- addId(set, event.previous_value);
2560
- }
2849
+ addPositiveId(userIds, audit.author_id);
2850
+ for (const event of audit.events) collectEventIds(event, userIds, groupIds);
2561
2851
  }
2562
2852
  return {
2563
2853
  userIds: [...userIds],
@@ -2600,12 +2890,11 @@ const diffLine = (label, before, after) => {
2600
2890
  const a = shownValue(after);
2601
2891
  return b === a ? null : `- **${label}**: ${b} → ${a}`;
2602
2892
  };
2603
- const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2604
- const after = result?.ticket ?? {};
2605
- const beforeObj = before ?? {};
2606
- const comment = after.comment ?? result?.comment;
2893
+ const asRecord = (value) => value ?? {};
2894
+ const diffStandardFields = (before, after) => {
2895
+ const beforeObj = asRecord(before);
2607
2896
  const changes = [];
2608
- for (const [key, afterVal] of Object.entries(after)) {
2897
+ for (const [key, afterVal] of Object.entries(asRecord(after))) {
2609
2898
  if (DIFF_SKIP_KEYS.has(key)) continue;
2610
2899
  const beforeVal = beforeObj[key];
2611
2900
  if (valuesEqual(beforeVal, afterVal)) continue;
@@ -2618,12 +2907,22 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2618
2907
  const line = diffLine(key, beforeVal, afterVal);
2619
2908
  if (line) changes.push(line);
2620
2909
  }
2621
- 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();
2622
2914
  const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
2915
+ const changes = [];
2623
2916
  for (const f of afterFields) {
2624
2917
  const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
2625
2918
  if (line) changes.push(line);
2626
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)];
2627
2926
  const lines = [
2628
2927
  `# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
2629
2928
  "",
@@ -2749,16 +3048,7 @@ const createTicketTools = (ctx) => {
2749
3048
  handler: async (params) => {
2750
3049
  const { ticket_id, attachment_ids } = params;
2751
3050
  const token = await getToken();
2752
- let attachments;
2753
- if (attachment_ids && attachment_ids.length > 0) {
2754
- attachments = [];
2755
- for (const id of attachment_ids) try {
2756
- const { attachment } = await zendeskGet(subdomain, token, `/attachments/${id}`);
2757
- attachments.push(attachment);
2758
- } catch (error) {
2759
- if (!(error instanceof ZendeskApiError) || error.status !== 404) throw error;
2760
- }
2761
- } 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 ?? []);
2762
3052
  if (attachments.length === 0) return { content: [{
2763
3053
  type: "text",
2764
3054
  text: `No attachments found on ticket #${ticket_id}.`
@@ -3541,7 +3831,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3541
3831
  readOnly: config.readOnly,
3542
3832
  namespaces: config.namespaces,
3543
3833
  tools: config.tools
3544
- });
3834
+ }).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
3545
3835
  try {
3546
3836
  switch (config.mode) {
3547
3837
  case "all":
@@ -3563,6 +3853,10 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3563
3853
  case "single":
3564
3854
  registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
3565
3855
  break;
3856
+ default: {
3857
+ const unhandled = config.mode;
3858
+ throw new Error(`Unsupported tool mode: ${String(unhandled)}`);
3859
+ }
3566
3860
  }
3567
3861
  if (helpCenterContextEnabled(config)) {
3568
3862
  const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
@@ -3576,6 +3870,44 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3576
3870
  text: await topology.read()
3577
3871
  }] })));
3578
3872
  }
3873
+ if (articleResourceEnabled(config)) {
3874
+ const articles = createArticleResourcesProvider(getToken, config.subdomain, onUnauthorized);
3875
+ const listPromotedEnabled = promotedArticlesEnabled(config);
3876
+ const template = new ResourceTemplate(articleResourceUriTemplate(config), { list: async () => {
3877
+ if (!listPromotedEnabled) return { resources: [] };
3878
+ try {
3879
+ const { refs, truncated } = await articles.listPromoted();
3880
+ if (truncated) logger.warn("article_resources_list_truncated", {
3881
+ max_pages: ARTICLE_RESOURCES_SCAN_MAX_PAGES,
3882
+ listed: refs.length
3883
+ });
3884
+ return { resources: refs.map((ref) => ({
3885
+ uri: articleResourceUri(config, ref.id),
3886
+ name: ref.title,
3887
+ title: ref.title,
3888
+ description: `"${ref.title}" (article ${ref.id}) — promoted Help Center article, as Markdown.`,
3889
+ mimeType: "text/markdown"
3890
+ })) };
3891
+ } catch (err) {
3892
+ logger.warn("article_resources_list_failed", { error: err instanceof Error ? err.message : String(err) });
3893
+ return { resources: [] };
3894
+ }
3895
+ } });
3896
+ registered.push(server.registerResource("help-center-article", template, {
3897
+ title: "Zendesk Help Center article",
3898
+ description: "A Help Center article rendered as Markdown, addressed by id. The list surfaces the promoted (featured) articles so one can be pinned as context; any article id can be read, subject to your Zendesk read permissions.",
3899
+ mimeType: "text/markdown"
3900
+ }, async (uri, variables) => {
3901
+ const raw = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
3902
+ const id = Number(raw);
3903
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid article id in resource URI: ${uri.toString()}`);
3904
+ return { contents: [{
3905
+ uri: uri.toString(),
3906
+ mimeType: "text/markdown",
3907
+ text: await articles.readArticle(id)
3908
+ }] };
3909
+ }));
3910
+ }
3579
3911
  } catch (err) {
3580
3912
  dispose();
3581
3913
  throw err;
@@ -3751,6 +4083,7 @@ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
3751
4083
  "::",
3752
4084
  "*"
3753
4085
  ]);
4086
+ const TRAILING_SLASHES = /\/+$/;
3754
4087
  const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
3755
4088
  "https://chatgpt.com",
3756
4089
  "https://chat.openai.com",
@@ -3826,7 +4159,7 @@ const handleCorsPreflight = (req, res, extraOrigins) => {
3826
4159
  return true;
3827
4160
  };
3828
4161
  const resolveResourceUrl = (config, logger = silentLogger) => {
3829
- if (config.publicUrl) return config.publicUrl.replace(/\/+$/, "");
4162
+ if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
3830
4163
  if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
3831
4164
  logger.warn("public_url_unset", {
3832
4165
  host: config.host,
@@ -3883,6 +4216,14 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
3883
4216
  jsonrpc: "2.0"
3884
4217
  }));
3885
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
+ };
3886
4227
  const sendUnauthorized = (res, resource) => {
3887
4228
  const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
3888
4229
  sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
@@ -3953,6 +4294,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
3953
4294
  const sessions = /* @__PURE__ */ new Map();
3954
4295
  const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
3955
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
+ };
3956
4313
  const handleMcpRequest = async (req, res) => {
3957
4314
  const bearer = extractBearer(req);
3958
4315
  if (!bearer) {
@@ -3960,23 +4317,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
3960
4317
  return;
3961
4318
  }
3962
4319
  const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
3963
- if (sessionId) {
3964
- const session = sessions.get(sessionId);
3965
- if (session) {
3966
- session.auth.bearer = bearer;
3967
- session.lastActivityAt = Date.now();
3968
- const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
3969
- ok: true,
3970
- value: void 0
3971
- };
3972
- if (!body.ok) {
3973
- respondBodyError(req, res, body);
3974
- return;
3975
- }
3976
- await session.transport.handleRequest(req, res, body.value);
3977
- return;
3978
- }
3979
- }
4320
+ if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
3980
4321
  if (req.method !== "POST") {
3981
4322
  sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
3982
4323
  return;
@@ -4008,24 +4349,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
4008
4349
  await server.connect(transport);
4009
4350
  await transport.handleRequest(req, res, body.value);
4010
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
+ };
4011
4360
  const requestListener = async (req, res) => {
4012
4361
  try {
4013
4362
  if (handleCorsPreflight(req, res, config.corsOrigins)) return;
4014
4363
  applyCorsHeaders(req, res, config.corsOrigins);
4015
4364
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
4016
- if (url.pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
4017
- sendJson(res, 200, metadata.protectedResource);
4018
- return;
4019
- }
4020
- if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
4021
- sendJson(res, 200, metadata.authorizationServer);
4022
- return;
4023
- }
4024
- if (url.pathname === "/healthz" && req.method === "GET") {
4025
- sendJson(res, 200, {
4026
- status: "ok",
4027
- subdomain: config.subdomain
4028
- });
4365
+ const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
4366
+ if (staticRoute) {
4367
+ sendJson(res, 200, staticRoute);
4029
4368
  return;
4030
4369
  }
4031
4370
  if (url.pathname === "/mcp") {
@@ -4038,9 +4377,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
4038
4377
  path: url.pathname
4039
4378
  }));
4040
4379
  } catch (err) {
4041
- const message = err instanceof Error ? err.message : "Internal Server Error";
4042
- if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
4043
- else if (!res.writableEnded) res.end();
4380
+ failRequest(res, err);
4044
4381
  }
4045
4382
  };
4046
4383
  const httpServer = createServer((req, res) => {
@@ -4117,5 +4454,3 @@ main().catch((error) => {
4117
4454
  });
4118
4455
  //#endregion
4119
4456
  export {};
4120
-
4121
- //# sourceMappingURL=index.js.map