@fruggr/zendesk-mcp-server 2.14.2 → 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 +35 -13
- package/dist/index.js +392 -132
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
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);
|
|
@@ -595,13 +596,46 @@ const ConfigSchema = z.object({
|
|
|
595
596
|
tools: z.array(z.string()).optional(),
|
|
596
597
|
/**
|
|
597
598
|
* Whether to expose the Help Center structural context (the `instructions`
|
|
598
|
-
* blob + the `zendesk-hc://topology`
|
|
599
|
+
* blob + the topology resource, default `zendesk-hc://topology`). On by
|
|
600
|
+
* default; an operator
|
|
599
601
|
* disables it server-wide with `--no-topology` (e.g. on a very large Help
|
|
600
602
|
* Center, or when the context is unwanted). Only ever active when the
|
|
601
603
|
* `help_center` namespace itself is active.
|
|
602
604
|
*/
|
|
603
605
|
topology: z.boolean().default(true),
|
|
604
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
|
+
/**
|
|
619
|
+
* URI scheme of the Help Center MCP resources (today the topology resource,
|
|
620
|
+
* `<scheme>://topology`). Defaults to `zendesk-hc`; a deployer can brand it
|
|
621
|
+
* (`--hc-resource-scheme wiki` / `HC_RESOURCE_SCHEME=wiki`). Strictly a bare
|
|
622
|
+
* RFC 3986 scheme — clients parse resource URIs with WHATWG `URL`, so a
|
|
623
|
+
* non-conformant scheme would surface as a broken resource at runtime;
|
|
624
|
+
* reject it at config parse time instead. ASCII-only message, value not
|
|
625
|
+
* echoed (same policy as parsePort below).
|
|
626
|
+
*/
|
|
627
|
+
hcResourceScheme: z.string().regex(/^[a-z][a-z0-9+.-]*$/, {
|
|
628
|
+
message: "Invalid HC_RESOURCE_SCHEME / --hc-resource-scheme value. Expected a bare RFC 3986 scheme: a lowercase letter followed by lowercase letters, digits, \"+\", \"-\" or \".\" (no \"://\").",
|
|
629
|
+
abort: true
|
|
630
|
+
}).refine((scheme) => {
|
|
631
|
+
const uri = `${scheme}://topology`;
|
|
632
|
+
try {
|
|
633
|
+
return new URL(uri).toString() === uri;
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}, { message: "Invalid HC_RESOURCE_SCHEME / --hc-resource-scheme value. WHATWG-special schemes (http, https, ws, wss, ftp, file) do not survive URL normalization and would make the resource unreadable; pick a custom scheme such as \"wiki\"." }).default("zendesk-hc"),
|
|
638
|
+
/**
|
|
605
639
|
* Dev-only (stdio): expose the `reload_tools` tool, which re-imports the tool
|
|
606
640
|
* modules from source and re-registers them on the live session on demand, so
|
|
607
641
|
* tool code edited during a dev cycle takes effect without a restart. CLI-only
|
|
@@ -640,7 +674,11 @@ const parseCliArgs = (args) => {
|
|
|
640
674
|
i++;
|
|
641
675
|
} else if (arg === "--read-only") result.readOnly = true;
|
|
642
676
|
else if (arg === "--no-topology") result.topology = false;
|
|
643
|
-
else if (arg === "--
|
|
677
|
+
else if (arg === "--no-promoted-articles") result.promotedArticles = false;
|
|
678
|
+
else if (arg === "--hc-resource-scheme" && next) {
|
|
679
|
+
result.hcResourceScheme = next;
|
|
680
|
+
i++;
|
|
681
|
+
} else if (arg === "--dev") result.dev = true;
|
|
644
682
|
else if (arg === "--namespace" && next) {
|
|
645
683
|
result.namespaces = result.namespaces ?? [];
|
|
646
684
|
result.namespaces.push(next);
|
|
@@ -690,6 +728,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
690
728
|
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
691
729
|
const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
|
|
692
730
|
const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
731
|
+
const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
|
|
693
732
|
return ConfigSchema.parse({
|
|
694
733
|
subdomain,
|
|
695
734
|
oauthClientId,
|
|
@@ -699,6 +738,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
699
738
|
namespaces: cli.namespaces,
|
|
700
739
|
tools: cli.tools,
|
|
701
740
|
topology: cli.topology ?? true,
|
|
741
|
+
promotedArticles: cli.promotedArticles ?? true,
|
|
742
|
+
hcResourceScheme,
|
|
702
743
|
dev: cli.dev ?? false,
|
|
703
744
|
transport,
|
|
704
745
|
host,
|
|
@@ -846,38 +887,99 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
846
887
|
return response.json();
|
|
847
888
|
};
|
|
848
889
|
//#endregion
|
|
849
|
-
//#region src/
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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));
|
|
881
983
|
};
|
|
882
984
|
//#endregion
|
|
883
985
|
//#region src/utils/formatting.ts
|
|
@@ -1075,6 +1177,7 @@ const formatArticleSummary = (article) => [
|
|
|
1075
1177
|
`## ${article.title} (${article.id})`,
|
|
1076
1178
|
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
1077
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`." : "",
|
|
1078
1181
|
`- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
|
|
1079
1182
|
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
1080
1183
|
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
@@ -1146,6 +1249,195 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
|
1146
1249
|
};
|
|
1147
1250
|
};
|
|
1148
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
|
|
1149
1441
|
//#region src/guidance/topology.ts
|
|
1150
1442
|
/**
|
|
1151
1443
|
* Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
|
|
@@ -1381,101 +1673,6 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
|
|
|
1381
1673
|
return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
|
|
1382
1674
|
};
|
|
1383
1675
|
//#endregion
|
|
1384
|
-
//#region src/utils/article-sections.ts
|
|
1385
|
-
const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
1386
|
-
"h1",
|
|
1387
|
-
"h2",
|
|
1388
|
-
"h3"
|
|
1389
|
-
]);
|
|
1390
|
-
const countWords = (text) => {
|
|
1391
|
-
const trimmed = text.trim();
|
|
1392
|
-
if (!trimmed) return 0;
|
|
1393
|
-
return trimmed.split(/\s+/).length;
|
|
1394
|
-
};
|
|
1395
|
-
const textOf = (html) => {
|
|
1396
|
-
if (!html) return "";
|
|
1397
|
-
return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
|
|
1398
|
-
};
|
|
1399
|
-
const parseSections = (html) => {
|
|
1400
|
-
if (!html?.trim()) return [];
|
|
1401
|
-
const $ = cheerio.load(html, null, false);
|
|
1402
|
-
const children = $.root().contents().toArray();
|
|
1403
|
-
const introParts = [];
|
|
1404
|
-
const sections = [];
|
|
1405
|
-
let current = null;
|
|
1406
|
-
for (const node of children) {
|
|
1407
|
-
const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
|
|
1408
|
-
if (HEADING_LEVELS.has(tagName)) {
|
|
1409
|
-
const level = Number.parseInt(tagName.slice(1), 10);
|
|
1410
|
-
current = {
|
|
1411
|
-
heading: $(node).text().trim(),
|
|
1412
|
-
headingTag: tagName,
|
|
1413
|
-
level,
|
|
1414
|
-
contentParts: []
|
|
1415
|
-
};
|
|
1416
|
-
sections.push(current);
|
|
1417
|
-
continue;
|
|
1418
|
-
}
|
|
1419
|
-
const outer = $.html(node);
|
|
1420
|
-
if (current) current.contentParts.push(outer);
|
|
1421
|
-
else introParts.push(outer);
|
|
1422
|
-
}
|
|
1423
|
-
const result = [];
|
|
1424
|
-
if (introParts.length > 0) {
|
|
1425
|
-
const introHtml = introParts.join("");
|
|
1426
|
-
result.push({
|
|
1427
|
-
index: 0,
|
|
1428
|
-
heading: "intro",
|
|
1429
|
-
headingTag: "",
|
|
1430
|
-
level: 0,
|
|
1431
|
-
html: introHtml,
|
|
1432
|
-
wordCount: countWords(textOf(introHtml))
|
|
1433
|
-
});
|
|
1434
|
-
}
|
|
1435
|
-
for (const s of sections) {
|
|
1436
|
-
const sectionHtml = s.contentParts.join("");
|
|
1437
|
-
result.push({
|
|
1438
|
-
index: result.length,
|
|
1439
|
-
heading: s.heading,
|
|
1440
|
-
headingTag: s.headingTag,
|
|
1441
|
-
level: s.level,
|
|
1442
|
-
html: sectionHtml,
|
|
1443
|
-
wordCount: countWords(textOf(sectionHtml))
|
|
1444
|
-
});
|
|
1445
|
-
}
|
|
1446
|
-
return result;
|
|
1447
|
-
};
|
|
1448
|
-
const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
1449
|
-
const sections = parseSections(html);
|
|
1450
|
-
if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
|
|
1451
|
-
return sections.map((section, idx) => {
|
|
1452
|
-
const content = idx === sectionIndex ? newHtml : section.html;
|
|
1453
|
-
if (section.level === 0) return content;
|
|
1454
|
-
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
1455
|
-
}).join("");
|
|
1456
|
-
};
|
|
1457
|
-
const keepAsHtml = (_state, node) => ({
|
|
1458
|
-
type: "html",
|
|
1459
|
-
value: toHtml(node)
|
|
1460
|
-
});
|
|
1461
|
-
const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: {
|
|
1462
|
-
table: keepAsHtml,
|
|
1463
|
-
pre: keepAsHtml
|
|
1464
|
-
} }).use(remarkGfm).use(remarkStringify, {
|
|
1465
|
-
bullet: "-",
|
|
1466
|
-
emphasis: "_",
|
|
1467
|
-
fences: true
|
|
1468
|
-
});
|
|
1469
|
-
const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
|
|
1470
|
-
const htmlToMarkdown = (html) => {
|
|
1471
|
-
if (!html) return "";
|
|
1472
|
-
return String(htmlToMdProcessor.processSync(html));
|
|
1473
|
-
};
|
|
1474
|
-
const markdownToHtml = (markdown) => {
|
|
1475
|
-
if (!markdown) return "";
|
|
1476
|
-
return String(mdToHtmlProcessor.processSync(markdown));
|
|
1477
|
-
};
|
|
1478
|
-
//#endregion
|
|
1479
1676
|
//#region src/tools/help-center.ts
|
|
1480
1677
|
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1481
1678
|
const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
|
|
@@ -1678,6 +1875,31 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1678
1875
|
}] };
|
|
1679
1876
|
}
|
|
1680
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
|
+
},
|
|
1681
1903
|
{
|
|
1682
1904
|
name: "list_article_translations",
|
|
1683
1905
|
namespace: "help_center",
|
|
@@ -3509,7 +3731,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3509
3731
|
readOnly: config.readOnly,
|
|
3510
3732
|
namespaces: config.namespaces,
|
|
3511
3733
|
tools: config.tools
|
|
3512
|
-
});
|
|
3734
|
+
}).filter((t) => config.promotedArticles !== false || t.name !== "list_promoted_articles");
|
|
3513
3735
|
try {
|
|
3514
3736
|
switch (config.mode) {
|
|
3515
3737
|
case "all":
|
|
@@ -3534,7 +3756,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3534
3756
|
}
|
|
3535
3757
|
if (helpCenterContextEnabled(config)) {
|
|
3536
3758
|
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
3537
|
-
registered.push(server.registerResource("help-center-topology",
|
|
3759
|
+
registered.push(server.registerResource("help-center-topology", topologyResourceUri(config), {
|
|
3538
3760
|
title: "Zendesk Help Center topology",
|
|
3539
3761
|
description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Useful context when creating or editing content; admin-only sections (permission groups, user segments) are marked unavailable rather than empty when your role lacks Guide-admin rights.",
|
|
3540
3762
|
mimeType: "text/markdown"
|
|
@@ -3544,6 +3766,44 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
|
|
|
3544
3766
|
text: await topology.read()
|
|
3545
3767
|
}] })));
|
|
3546
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
|
+
}
|
|
3547
3807
|
} catch (err) {
|
|
3548
3808
|
dispose();
|
|
3549
3809
|
throw err;
|