@fruggr/zendesk-mcp-server 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,28 +155,47 @@ The full tool-by-tool reference — every tool with its description and its
155
155
 
156
156
  Beyond tools, the server hands an LLM the structural context it needs to work
157
157
  against *your* Help Center — so it stops guessing locales or fuzzy-matching
158
- section names and uses real IDs instead. This is delivered through two
159
- MCP-native channels (both active only when the `help_center` namespace is, and
160
- disabled together with `--no-topology`):
158
+ section names and uses real IDs instead. This is delivered through MCP-native
159
+ channels (all active only when the `help_center` namespace is), each fetched
160
+ **with the caller's own token** so it respects that user's read permissions:
161
161
 
162
162
  - **`instructions`** (sent on `initialize`): a short, static blob auto-loaded by
163
163
  compliant clients. It names the subdomain and points at the topology resource.
164
164
  - **`zendesk-hc://topology`** (a pull-only [MCP resource](https://modelcontextprotocol.io/docs/concepts/resources)):
165
165
  read on demand, it returns Markdown describing the active locales (and the
166
166
  default), the category → section tree with IDs, the visibility user segments,
167
- the permission groups, and the calling user's role. It is fetched **with the
168
- caller's own token**, so it respects that user's read permissions. Listing the
169
- permission groups and user segments needs Guide-admin / Help Center manager
170
- rights; with a content-editor token those two sections are marked *unavailable*
171
- (not empty) and the rest still renders reuse those IDs from an existing
172
- article (`get_article`) instead. On a very large Help Center the section tree is
173
- summarized (per-category, with a pointer to `list_sections`) to stay concise.
174
-
175
- Clients that don't consume `instructions` or `resources` simply ignore them
176
- the feature degrades silently. Use `--no-topology` to turn both off server-wide.
177
- The `zendesk-hc://` URI scheme is the default; a deployer can brand it with
167
+ the permission groups, and the calling user's role. Listing the permission
168
+ groups and user segments needs Guide-admin / Help Center manager rights; with a
169
+ content-editor token those two sections are marked *unavailable* (not empty) and
170
+ the rest still renders reuse those IDs from an existing article (`get_article`)
171
+ instead. On a very large Help Center the section tree is summarized (per-category,
172
+ with a pointer to `list_sections`) to stay concise.
173
+ - **`zendesk-hc://article/{id}`** (pull-only [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources)):
174
+ two distinct capabilities. **Read-by-id** — any article id can be read on demand,
175
+ returned as Markdown (a cheap single fetch, no preloading). **Promoted pre-listing**
176
+ the resource's listing surfaces the promoted (*featured*) articles so a user can
177
+ pin one in clients that support resource pinning / @-mention, and the companion
178
+ `list_promoted_articles` tool returns the same set. Clients that don't support
179
+ resources ignore these silently.
180
+ <br>**Cost:** only the *pre-listing* costs requests — finding promoted articles has
181
+ no server-side filter, so it scans article pages (one Zendesk API request per page,
182
+ capped). The resource listing is cached briefly per session (repeated `resources/list`
183
+ calls coalesce); the `list_promoted_articles` tool performs a fresh scan on every
184
+ call. It runs only on a client's `resources/list` or a tool call, never at connect,
185
+ and consumes no LLM context until an article is pinned/read. Read-by-id costs one
186
+ fetch, only when a specific article is opened. See
187
+ [`ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES`](docs/configuration.md#zendesk_article_resources_scan_max_pages).
188
+
189
+ The `instructions` blob and the topology resource are toggled together with
190
+ `--no-topology`. The **promoted pre-listing** is toggled independently with
191
+ `--no-promoted-articles` — which turns off the resource `list` scan **and** the
192
+ `list_promoted_articles` tool, so the server makes zero preloading requests;
193
+ **reading a known article by id stays available** (it never preloads). Clients that
194
+ don't consume `instructions` or `resources` simply ignore them — the feature
195
+ degrades silently. The `zendesk-hc://` URI scheme is the default; a deployer can
196
+ brand it with
178
197
  [`--hc-resource-scheme` / `HC_RESOURCE_SCHEME`](docs/configuration.md#hc_resource_scheme)
179
- (e.g. `wiki` → `wiki://topology`).
198
+ (e.g. `wiki` → `wiki://topology`, `wiki://article/{id}`).
180
199
 
181
200
  ## Prerequisites
182
201
 
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";
@@ -114,6 +114,7 @@ const positiveIntEnv = (name, fallback) => {
114
114
  const parsed = Number(raw);
115
115
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
116
116
  };
117
+ const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
117
118
  const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
118
119
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
119
120
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
@@ -603,6 +604,18 @@ const ConfigSchema = z.object({
603
604
  */
604
605
  topology: z.boolean().default(true),
605
606
  /**
607
+ * Whether to PRE-LIST the promoted ("featured") Help Center articles: the
608
+ * `<scheme>://article/{id}` resource's `list` callback (which scans `/articles`
609
+ * to enumerate the promoted set for `resources/list`) AND the
610
+ * `list_promoted_articles` tool. On by default; an operator disables the
611
+ * pre-listing with `--no-promoted-articles` (e.g. on a very large Help Center
612
+ * where scanning is costly) so the server issues zero preloading requests. This
613
+ * does NOT disable reading a known article by id (`<scheme>://article/{id}` stays
614
+ * registered) — that is cheap and on-demand. Only ever active when the
615
+ * `help_center` namespace itself is active.
616
+ */
617
+ promotedArticles: z.boolean().default(true),
618
+ /**
606
619
  * URI scheme of the Help Center MCP resources (today the topology resource,
607
620
  * `<scheme>://topology`). Defaults to `zendesk-hc`; a deployer can brand it
608
621
  * (`--hc-resource-scheme wiki` / `HC_RESOURCE_SCHEME=wiki`). Strictly a bare
@@ -661,6 +674,7 @@ const parseCliArgs = (args) => {
661
674
  i++;
662
675
  } else if (arg === "--read-only") result.readOnly = true;
663
676
  else if (arg === "--no-topology") result.topology = false;
677
+ else if (arg === "--no-promoted-articles") result.promotedArticles = false;
664
678
  else if (arg === "--hc-resource-scheme" && next) {
665
679
  result.hcResourceScheme = next;
666
680
  i++;
@@ -724,6 +738,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
724
738
  namespaces: cli.namespaces,
725
739
  tools: cli.tools,
726
740
  topology: cli.topology ?? true,
741
+ promotedArticles: cli.promotedArticles ?? true,
727
742
  hcResourceScheme,
728
743
  dev: cli.dev ?? false,
729
744
  transport,
@@ -872,44 +887,99 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
872
887
  return response.json();
873
888
  };
874
889
  //#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");
890
+ //#region src/utils/article-sections.ts
891
+ const HEADING_LEVELS = /* @__PURE__ */ new Set([
892
+ "h1",
893
+ "h2",
894
+ "h3"
895
+ ]);
896
+ const countWords = (text) => {
897
+ const trimmed = text.trim();
898
+ if (!trimmed) return 0;
899
+ return trimmed.split(/\s+/).length;
900
+ };
901
+ const textOf = (html) => {
902
+ if (!html) return "";
903
+ return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
904
+ };
905
+ const parseSections = (html) => {
906
+ if (!html?.trim()) return [];
907
+ const $ = cheerio.load(html, null, false);
908
+ const children = $.root().contents().toArray();
909
+ const introParts = [];
910
+ const sections = [];
911
+ let current = null;
912
+ for (const node of children) {
913
+ const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
914
+ if (HEADING_LEVELS.has(tagName)) {
915
+ const level = Number.parseInt(tagName.slice(1), 10);
916
+ current = {
917
+ heading: $(node).text().trim(),
918
+ headingTag: tagName,
919
+ level,
920
+ contentParts: []
921
+ };
922
+ sections.push(current);
923
+ continue;
924
+ }
925
+ const outer = $.html(node);
926
+ if (current) current.contentParts.push(outer);
927
+ else introParts.push(outer);
928
+ }
929
+ const result = [];
930
+ if (introParts.length > 0) {
931
+ const introHtml = introParts.join("");
932
+ result.push({
933
+ index: 0,
934
+ heading: "intro",
935
+ headingTag: "",
936
+ level: 0,
937
+ html: introHtml,
938
+ wordCount: countWords(textOf(introHtml))
939
+ });
940
+ }
941
+ for (const s of sections) {
942
+ const sectionHtml = s.contentParts.join("");
943
+ result.push({
944
+ index: result.length,
945
+ heading: s.heading,
946
+ headingTag: s.headingTag,
947
+ level: s.level,
948
+ html: sectionHtml,
949
+ wordCount: countWords(textOf(sectionHtml))
950
+ });
951
+ }
952
+ return result;
953
+ };
954
+ const replaceSectionContent = (html, sectionIndex, newHtml) => {
955
+ const sections = parseSections(html);
956
+ if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
957
+ return sections.map((section, idx) => {
958
+ const content = idx === sectionIndex ? newHtml : section.html;
959
+ if (section.level === 0) return content;
960
+ return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
961
+ }).join("");
962
+ };
963
+ const keepAsHtml = (_state, node) => ({
964
+ type: "html",
965
+ value: toHtml(node)
966
+ });
967
+ const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
968
+ table: keepAsHtml,
969
+ pre: keepAsHtml
970
+ } }).use(remarkGfm).use(remarkStringify, {
971
+ bullet: "-",
972
+ emphasis: "_",
973
+ fences: true
974
+ });
975
+ const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
976
+ const htmlToMarkdown = (html) => {
977
+ if (!html) return "";
978
+ return String(htmlToMdProcessor.processSync(html));
979
+ };
980
+ const markdownToHtml = (markdown) => {
981
+ if (!markdown) return "";
982
+ return String(mdToHtmlProcessor.processSync(markdown));
913
983
  };
914
984
  //#endregion
915
985
  //#region src/utils/formatting.ts
@@ -1107,6 +1177,7 @@ const formatArticleSummary = (article) => [
1107
1177
  `## ${article.title} (${article.id})`,
1108
1178
  `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
1109
1179
  `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
1180
+ article.promoted ? "- **Promoted**: featured in its section — changing this requires Help Center admin (Guide admin) rights; set via update_article `promoted`." : "",
1110
1181
  `- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
1111
1182
  typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
1112
1183
  article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
@@ -1178,6 +1249,195 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
1178
1249
  };
1179
1250
  };
1180
1251
  //#endregion
1252
+ //#region src/guidance/article-resources.ts
1253
+ /**
1254
+ * Name of the companion tool that lists promoted articles. Shared between the
1255
+ * tool definition (`help-center.ts`) and the `--no-promoted-articles` opt-out
1256
+ * filter (`server.ts`) so the two can never drift: renaming the tool here keeps
1257
+ * the filter dropping it, preserving the "zero preloading requests when off"
1258
+ * invariant.
1259
+ */
1260
+ const LIST_PROMOTED_ARTICLES_TOOL = "list_promoted_articles";
1261
+ /**
1262
+ * Scan the Help Center for promoted ("featured") articles with the CALLER'S
1263
+ * token, so the result respects that user's read permissions. The API exposes no
1264
+ * server-side `promoted` filter (only label_names / sort), so we page through
1265
+ * `/articles` and filter `promoted` client-side, bounded by `maxPages` to keep
1266
+ * the scan tractable on a large Help Center. `truncated` signals the cap was hit.
1267
+ *
1268
+ * Returns the FULL promoted articles so callers that need rich metadata (the
1269
+ * `list_promoted_articles` tool) get everything; the resource provider maps these
1270
+ * down to lean refs before caching so the per-session cache doesn't retain bodies.
1271
+ */
1272
+ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOURCES_SCAN_MAX_PAGES) => {
1273
+ const promoted = [];
1274
+ let cursor;
1275
+ let pages = 0;
1276
+ let truncated = false;
1277
+ do {
1278
+ const response = await helpCenterGet(subdomain, token, "/articles", buildCursorParams(100, cursor));
1279
+ const articles = response.articles ?? [];
1280
+ for (const article of articles) if (article.promoted) promoted.push(article);
1281
+ pages += 1;
1282
+ const meta = extractPaginationMeta(response, articles.length);
1283
+ cursor = meta.has_more ? meta.after_cursor ?? void 0 : void 0;
1284
+ if (cursor && pages >= maxPages) {
1285
+ truncated = true;
1286
+ break;
1287
+ }
1288
+ } while (cursor);
1289
+ return {
1290
+ articles: promoted,
1291
+ truncated,
1292
+ pagesScanned: pages
1293
+ };
1294
+ };
1295
+ /**
1296
+ * Fetch a single article by id (optionally a translated locale) with the
1297
+ * caller's token and render it as Markdown: the shared metadata summary plus the
1298
+ * body converted from HTML (rather than a raw HTML dump), capped by the response
1299
+ * character limit. Reuses the same formatting as the `get_article` tool.
1300
+ */
1301
+ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1302
+ const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${id}` : `/articles/${id}`);
1303
+ return truncateIfNeeded([
1304
+ formatArticleSummary(article),
1305
+ "",
1306
+ htmlToMarkdown(article.body)
1307
+ ].join("\n"));
1308
+ };
1309
+ /**
1310
+ * Build an article-resources provider. `listPromoted` holds a memoized-promise
1311
+ * cache (TTL `ARTICLE_RESOURCES_TTL_MS`) to coalesce the repeated `resources/list`
1312
+ * calls a client makes; `readArticle` is a one-shot fetch (not cached). As with
1313
+ * `createTopologyProvider`, the cache is PER SESSION and must NOT be hoisted to
1314
+ * module scope — in HTTP mode this provider is instantiated per session, so a
1315
+ * shared cache would leak one caller's data to another. `getToken` is resolved
1316
+ * lazily at call time (never at construction) so connecting never triggers the
1317
+ * OAuth/PKCE flow. A 401 notifies `onUnauthorized` (stdio OAuth) to drop the
1318
+ * stale token, mirroring the topology provider and the tool dispatch path.
1319
+ */
1320
+ const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized) => {
1321
+ let cached;
1322
+ const notifyIfUnauthorized = (err) => {
1323
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
1324
+ };
1325
+ return {
1326
+ listPromoted() {
1327
+ const now = Date.now();
1328
+ if (cached && now - cached.at < 3e5) return cached.promise;
1329
+ const promise = (async () => {
1330
+ const token = await getToken();
1331
+ const { articles, truncated } = await fetchPromotedArticles(subdomain, token);
1332
+ return {
1333
+ refs: articles.map((a) => ({
1334
+ id: a.id,
1335
+ title: a.title
1336
+ })),
1337
+ truncated
1338
+ };
1339
+ })().catch((err) => {
1340
+ cached = void 0;
1341
+ notifyIfUnauthorized(err);
1342
+ throw err;
1343
+ });
1344
+ cached = {
1345
+ at: now,
1346
+ promise
1347
+ };
1348
+ return promise;
1349
+ },
1350
+ async readArticle(id) {
1351
+ try {
1352
+ const token = await getToken();
1353
+ return await fetchArticleMarkdown(subdomain, token, id);
1354
+ } catch (err) {
1355
+ notifyIfUnauthorized(err);
1356
+ throw err;
1357
+ }
1358
+ }
1359
+ };
1360
+ };
1361
+ //#endregion
1362
+ //#region src/guidance/instructions.ts
1363
+ /**
1364
+ * URI of the dynamic Help Center topology resource. Single source of truth for
1365
+ * every place that cites it (resource registration, `instructions` blob): the
1366
+ * scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
1367
+ * the path is fixed. Any future Help Center resource should build its URI the
1368
+ * same way so the whole surface follows the configured scheme.
1369
+ */
1370
+ const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
1371
+ /**
1372
+ * URI template of the pull-only Help Center article resources, built from the
1373
+ * configured scheme exactly like `topologyResourceUri` (`--hc-resource-scheme`,
1374
+ * default `zendesk-hc` → `zendesk-hc://article/{id}`). The template's `list`
1375
+ * callback enumerates the promoted ("featured") articles (so clients can surface
1376
+ * them for pinning), while any article id can be read on demand.
1377
+ */
1378
+ const articleResourceUriTemplate = (config) => `${config.hcResourceScheme}://article/{id}`;
1379
+ /**
1380
+ * Build the concrete resource URI for a single article id, under the configured
1381
+ * scheme. Shares the scheme with the template above so the listed URIs always
1382
+ * match the template the read callback is registered under.
1383
+ */
1384
+ const articleResourceUri = (config, id) => `${config.hcResourceScheme}://article/${id}`;
1385
+ /**
1386
+ * Whether the `help_center` namespace is active: no `--namespace` filter, or one
1387
+ * that includes it. The shared second half of the two Help Center feature gates
1388
+ * below, so the namespace semantics live in one place.
1389
+ */
1390
+ const helpCenterNamespaceActive = (config) => !config.namespaces?.length || config.namespaces.includes("help_center");
1391
+ /**
1392
+ * Whether the Help Center structural context (init instructions + the
1393
+ * topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
1394
+ * feature is enabled (`--no-topology` not set) AND the `help_center` namespace
1395
+ * is active. Shared by the instructions builder and the resource registration in
1396
+ * `server.ts` so both gates stay in sync.
1397
+ */
1398
+ const helpCenterContextEnabled = (config) => config.topology && helpCenterNamespaceActive(config);
1399
+ /**
1400
+ * Whether the read-by-id article resource (`<scheme>://article/{id}`) should be
1401
+ * registered. Available whenever the `help_center` namespace is active — reading
1402
+ * one article is a cheap, on-demand single fetch with NO preloading, so it is
1403
+ * deliberately NOT gated by the promoted-listing flag: `--no-promoted-articles`
1404
+ * turns off the costly pre-listing (below), never the ability to address a known
1405
+ * article id. Not tied to `--no-topology` either (topology is a separate feature).
1406
+ */
1407
+ const articleResourceEnabled = (config) => helpCenterNamespaceActive(config);
1408
+ /**
1409
+ * Whether the promoted-article PRE-LISTING is exposed: the resource `list`
1410
+ * callback's scan (which enumerates the promoted articles for `resources/list`)
1411
+ * AND the `list_promoted_articles` tool. This is the costly, fan-out part (a capped
1412
+ * scan of `/articles`, no server-side promoted filter), so it gets its own flag —
1413
+ * `--no-promoted-articles` turns it off so the server issues zero preloading
1414
+ * requests, while read-by-id (above) stays available. `!== false` (not truthiness)
1415
+ * so an omitted flag on a hand-built Config stays default-on, matching the tool
1416
+ * filter in `server.ts`.
1417
+ */
1418
+ const promotedArticlesEnabled = (config) => config.promotedArticles !== false && helpCenterNamespaceActive(config);
1419
+ /**
1420
+ * The static `instructions` blob sent on `initialize`. Deliberately short and
1421
+ * I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
1422
+ * it stays within a tight token budget. The rich, dynamic topology lives in the
1423
+ * pull-only topology resource (default `zendesk-hc://topology`) referenced here.
1424
+ */
1425
+ const buildInstructions = (config) => {
1426
+ if (!helpCenterContextEnabled(config)) return void 0;
1427
+ return [
1428
+ `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
1429
+ "",
1430
+ `When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
1431
+ "it lists the active locales (and the default one), the category → section tree with IDs,",
1432
+ "the visibility user segments, the permission groups, and your current role.",
1433
+ "Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
1434
+ "",
1435
+ "It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
1436
+ "user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
1437
+ "or user_segment_id from an existing article (get_article) instead."
1438
+ ].join("\n");
1439
+ };
1440
+ //#endregion
1181
1441
  //#region src/guidance/topology.ts
1182
1442
  /**
1183
1443
  * Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
@@ -1413,101 +1673,6 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
1413
1673
  return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
1414
1674
  };
1415
1675
  //#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
1676
  //#region src/tools/help-center.ts
1512
1677
  const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
1513
1678
  const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
@@ -1710,6 +1875,31 @@ const createHelpCenterTools = (ctx) => {
1710
1875
  }] };
1711
1876
  }
1712
1877
  },
1878
+ {
1879
+ name: LIST_PROMOTED_ARTICLES_TOOL,
1880
+ namespace: "help_center",
1881
+ readOnly: true,
1882
+ title: "List Promoted Help Center Articles",
1883
+ 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).",
1884
+ inputSchema: z.object({}),
1885
+ annotations: {
1886
+ readOnlyHint: true,
1887
+ destructiveHint: false,
1888
+ idempotentHint: true,
1889
+ openWorldHint: true
1890
+ },
1891
+ handler: async () => {
1892
+ const token = await getToken();
1893
+ const { articles, truncated, pagesScanned } = await fetchPromotedArticles(subdomain, token);
1894
+ const header = `Promoted (featured) articles: ${articles.length}`;
1895
+ const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
1896
+ const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
1897
+ return { content: [{
1898
+ 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._` : ""}`)
1900
+ }] };
1901
+ }
1902
+ },
1713
1903
  {
1714
1904
  name: "list_article_translations",
1715
1905
  namespace: "help_center",
@@ -3541,7 +3731,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3541
3731
  readOnly: config.readOnly,
3542
3732
  namespaces: config.namespaces,
3543
3733
  tools: config.tools
3544
- });
3734
+ }).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
3545
3735
  try {
3546
3736
  switch (config.mode) {
3547
3737
  case "all":
@@ -3576,6 +3766,44 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3576
3766
  text: await topology.read()
3577
3767
  }] })));
3578
3768
  }
3769
+ if (articleResourceEnabled(config)) {
3770
+ const articles = createArticleResourcesProvider(getToken, config.subdomain, onUnauthorized);
3771
+ const listPromotedEnabled = promotedArticlesEnabled(config);
3772
+ const template = new ResourceTemplate(articleResourceUriTemplate(config), { list: async () => {
3773
+ if (!listPromotedEnabled) return { resources: [] };
3774
+ try {
3775
+ const { refs, truncated } = await articles.listPromoted();
3776
+ if (truncated) logger.warn("article_resources_list_truncated", {
3777
+ max_pages: ARTICLE_RESOURCES_SCAN_MAX_PAGES,
3778
+ listed: refs.length
3779
+ });
3780
+ return { resources: refs.map((ref) => ({
3781
+ uri: articleResourceUri(config, ref.id),
3782
+ name: ref.title,
3783
+ title: ref.title,
3784
+ description: `"${ref.title}" (article ${ref.id}) — promoted Help Center article, as Markdown.`,
3785
+ mimeType: "text/markdown"
3786
+ })) };
3787
+ } catch (err) {
3788
+ logger.warn("article_resources_list_failed", { error: err instanceof Error ? err.message : String(err) });
3789
+ return { resources: [] };
3790
+ }
3791
+ } });
3792
+ registered.push(server.registerResource("help-center-article", template, {
3793
+ title: "Zendesk Help Center article",
3794
+ 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.",
3795
+ mimeType: "text/markdown"
3796
+ }, async (uri, variables) => {
3797
+ const raw = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
3798
+ const id = Number(raw);
3799
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid article id in resource URI: ${uri.toString()}`);
3800
+ return { contents: [{
3801
+ uri: uri.toString(),
3802
+ mimeType: "text/markdown",
3803
+ text: await articles.readArticle(id)
3804
+ }] };
3805
+ }));
3806
+ }
3579
3807
  } catch (err) {
3580
3808
  dispose();
3581
3809
  throw err;