@fruggr/zendesk-mcp-server 3.0.0 → 3.0.2
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 +79 -13
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1327,6 +1327,7 @@ const parseSections = (html) => {
|
|
|
1327
1327
|
current = {
|
|
1328
1328
|
heading: $(node).text().trim(),
|
|
1329
1329
|
headingTag: tagName,
|
|
1330
|
+
headingHtml: $.html(node),
|
|
1330
1331
|
level,
|
|
1331
1332
|
contentParts: []
|
|
1332
1333
|
};
|
|
@@ -1344,6 +1345,7 @@ const parseSections = (html) => {
|
|
|
1344
1345
|
index: 0,
|
|
1345
1346
|
heading: "intro",
|
|
1346
1347
|
headingTag: "",
|
|
1348
|
+
headingHtml: "",
|
|
1347
1349
|
level: 0,
|
|
1348
1350
|
html: introHtml,
|
|
1349
1351
|
wordCount: countWords(textOf(introHtml))
|
|
@@ -1355,6 +1357,7 @@ const parseSections = (html) => {
|
|
|
1355
1357
|
index: result.length,
|
|
1356
1358
|
heading: s.heading,
|
|
1357
1359
|
headingTag: s.headingTag,
|
|
1360
|
+
headingHtml: s.headingHtml,
|
|
1358
1361
|
level: s.level,
|
|
1359
1362
|
html: sectionHtml,
|
|
1360
1363
|
wordCount: countWords(textOf(sectionHtml))
|
|
@@ -1365,11 +1368,51 @@ const parseSections = (html) => {
|
|
|
1365
1368
|
const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
1366
1369
|
const sections = parseSections(html);
|
|
1367
1370
|
if (sectionIndex < 0 || sectionIndex >= sections.length) throw new Error(`Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`);
|
|
1368
|
-
return sections.map((section, idx) => {
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1371
|
+
return sections.map((section, idx) => `${section.headingHtml}${idx === sectionIndex ? newHtml : section.html}`).join("");
|
|
1372
|
+
};
|
|
1373
|
+
const LEADING_HEADING = /^\s*<(h[1-3])(?:\s[^>]*)?>([\s\S]*?)<\/\1\s*>/i;
|
|
1374
|
+
const normalizeText = (text) => text.trim().split(WHITESPACE_RUN).join(" ");
|
|
1375
|
+
const stripLeadingDuplicateHeading = (html, target) => {
|
|
1376
|
+
const match = LEADING_HEADING.exec(html);
|
|
1377
|
+
const tag = match?.[1];
|
|
1378
|
+
if (!match || !tag) return {
|
|
1379
|
+
html,
|
|
1380
|
+
stripped: null
|
|
1381
|
+
};
|
|
1382
|
+
const text = normalizeText(textOf(match[2] ?? ""));
|
|
1383
|
+
if (tag.toLowerCase() !== target.headingTag || text !== normalizeText(target.heading)) return {
|
|
1384
|
+
html,
|
|
1385
|
+
stripped: null
|
|
1386
|
+
};
|
|
1387
|
+
return {
|
|
1388
|
+
html: html.slice(match[0].length).trimStart(),
|
|
1389
|
+
stripped: {
|
|
1390
|
+
tag: target.headingTag,
|
|
1391
|
+
text
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
};
|
|
1395
|
+
const introLoss = (target, contentSections) => {
|
|
1396
|
+
if (target?.level !== 0) return null;
|
|
1397
|
+
const first = contentSections[0];
|
|
1398
|
+
if (!first) return "empty";
|
|
1399
|
+
return first.level > 0 ? "heading-led" : null;
|
|
1400
|
+
};
|
|
1401
|
+
const applySectionUpdate = (html, sectionIndex, newHtml) => {
|
|
1402
|
+
const sections = parseSections(html);
|
|
1403
|
+
const target = sections[sectionIndex];
|
|
1404
|
+
const { html: content, stripped } = target ? stripLeadingDuplicateHeading(newHtml, target) : {
|
|
1405
|
+
html: newHtml,
|
|
1406
|
+
stripped: null
|
|
1407
|
+
};
|
|
1408
|
+
const contentSections = parseSections(content);
|
|
1409
|
+
return {
|
|
1410
|
+
body: replaceSectionContent(html, sectionIndex, content),
|
|
1411
|
+
stripped,
|
|
1412
|
+
contentHeadings: contentSections.filter((s) => s.level > 0).length,
|
|
1413
|
+
introLost: introLoss(target, contentSections),
|
|
1414
|
+
sectionsBefore: sections.length
|
|
1415
|
+
};
|
|
1373
1416
|
};
|
|
1374
1417
|
const keepAsHtml = (_state, node) => ({
|
|
1375
1418
|
type: "html",
|
|
@@ -2361,6 +2404,16 @@ const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
|
|
|
2361
2404
|
if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
|
|
2362
2405
|
if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
|
|
2363
2406
|
};
|
|
2407
|
+
const structureWarning = (r) => {
|
|
2408
|
+
if (r.introLost === "empty") {
|
|
2409
|
+
if (r.sectionsBefore === 1) return ["Warning: content is empty and the intro was the only section, so the article body is now empty."];
|
|
2410
|
+
return [`Warning: content is empty, so the intro no longer exists and every later section index moved down by one (article: ${r.sectionsAfter} sections, was ${r.sectionsBefore}). Call get_article_outline before the next edit.`];
|
|
2411
|
+
}
|
|
2412
|
+
if (r.introLost === "heading-led") return [`Warning: content starts with a heading, so the intro no longer exists and section [${r.sectionIndex}] is now a heading section (article: ${r.sectionsAfter} sections, was ${r.sectionsBefore}). Call get_article_outline before the next edit.`];
|
|
2413
|
+
if (r.contentHeadings > 0) return [`Warning: content contains ${r.contentHeadings} heading(s) (h1-h3), so the article now has ${r.sectionsAfter} sections (was ${r.sectionsBefore}); indexes after [${r.sectionIndex}] shifted. Call get_article_outline before the next edit.`];
|
|
2414
|
+
return [];
|
|
2415
|
+
};
|
|
2416
|
+
const sectionUpdateNotes = (r) => [...r.stripped ? [`Note: removed a leading <${r.stripped.tag}> "${r.stripped.text}" from content; the section heading is kept automatically and is not part of content.`] : [], ...structureWarning(r)];
|
|
2364
2417
|
const createHelpCenterTools = (ctx) => {
|
|
2365
2418
|
const { subdomain, getToken } = ctx;
|
|
2366
2419
|
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
@@ -3207,7 +3260,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3207
3260
|
namespace: "help_center",
|
|
3208
3261
|
readOnly: true,
|
|
3209
3262
|
title: "Get Article Section",
|
|
3210
|
-
description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section).",
|
|
3263
|
+
description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section). The \"## [n] hN: ...\" line at the top is a label, not part of the content.",
|
|
3211
3264
|
inputSchema: z.object({
|
|
3212
3265
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3213
3266
|
locale: z.string().describe("Locale of the body (e.g., \"en-us\", \"fr\")"),
|
|
@@ -3245,12 +3298,12 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3245
3298
|
namespace: "help_center",
|
|
3246
3299
|
readOnly: false,
|
|
3247
3300
|
title: "Update Article Section",
|
|
3248
|
-
description: "Replace the
|
|
3301
|
+
description: "Replace the body of one article section, heading excluded (the heading is kept as is), in a given locale, leaving the rest of the article intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. A leading heading in content that repeats the section heading is removed; any other h1-h3 in content creates new sections and shifts the later indexes (the response warns). section_index 0 is the heading-less intro, which exists only when the body has text before its first heading. Default format=\"html\" for fidelity. Use format=\"markdown\" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells).",
|
|
3249
3302
|
inputSchema: z.object({
|
|
3250
3303
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3251
3304
|
locale: z.string().describe("Locale of the translation to update"),
|
|
3252
|
-
section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
|
|
3253
|
-
content: z.string().describe("New content for the section (heading excluded). HTML by default, Markdown if format=\"markdown\"."),
|
|
3305
|
+
section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline). 0 is the heading-less intro when the body has text before its first heading, otherwise the first heading."),
|
|
3306
|
+
content: z.string().describe("New content for the section (heading excluded: send neither the heading nor the \"## [n] hN: ...\" label line that get_article_section prints). HTML by default, Markdown if format=\"markdown\"."),
|
|
3254
3307
|
format: z.enum(["html", "markdown"]).default("html").describe("Input format. \"html\" (default) is the safe path. \"markdown\" is converted to HTML server-side but may introduce artifacts on complex content.")
|
|
3255
3308
|
}),
|
|
3256
3309
|
annotations: {
|
|
@@ -3264,13 +3317,26 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3264
3317
|
const token = await getToken();
|
|
3265
3318
|
const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${locale}`);
|
|
3266
3319
|
const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
|
|
3267
|
-
const newBody =
|
|
3320
|
+
const { body: newBody, stripped, contentHeadings, introLost, sectionsBefore } = applySectionUpdate(translation.body, section_index, newSectionHtml);
|
|
3268
3321
|
const { translation: updated } = await helpCenterPut(subdomain, token, `/articles/${article_id}/translations/${locale}`, { translation: { body: newBody } });
|
|
3269
|
-
const
|
|
3270
|
-
const
|
|
3322
|
+
const updatedSections = parseSections(updated.body);
|
|
3323
|
+
const updatedSection = updatedSections[section_index];
|
|
3324
|
+
const emptied = introLost === "empty";
|
|
3325
|
+
const newWordCount = emptied ? 0 : updatedSection?.wordCount ?? 0;
|
|
3271
3326
|
return { content: [{
|
|
3272
3327
|
type: "text",
|
|
3273
|
-
text: [
|
|
3328
|
+
text: [
|
|
3329
|
+
`Section [${section_index}] "${emptied ? "(intro)" : updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`,
|
|
3330
|
+
`New word count: ${newWordCount}.`,
|
|
3331
|
+
...sectionUpdateNotes({
|
|
3332
|
+
stripped,
|
|
3333
|
+
contentHeadings,
|
|
3334
|
+
introLost,
|
|
3335
|
+
sectionIndex: section_index,
|
|
3336
|
+
sectionsBefore,
|
|
3337
|
+
sectionsAfter: updatedSections.length
|
|
3338
|
+
})
|
|
3339
|
+
].join("\n")
|
|
3274
3340
|
}] };
|
|
3275
3341
|
}
|
|
3276
3342
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"mcpName": "io.github.fruggr/zendesk-mcp-server",
|
|
5
5
|
"description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
|
|
6
6
|
"type": "module",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"engines": {
|
|
72
72
|
"node": ">=22"
|
|
73
73
|
},
|
|
74
|
-
"packageManager": "pnpm@12.
|
|
74
|
+
"packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f",
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@modelcontextprotocol/server": "2.
|
|
76
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
77
77
|
"cheerio": "1.2.0",
|
|
78
78
|
"hast-util-to-html": "9.0.5",
|
|
79
79
|
"hast-util-to-mdast": "10.1.2",
|
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@biomejs/biome": "2.5.14",
|
|
110
|
-
"@modelcontextprotocol/client": "2.
|
|
111
|
-
"@modelcontextprotocol/node": "2.
|
|
110
|
+
"@modelcontextprotocol/client": "2.1.0",
|
|
111
|
+
"@modelcontextprotocol/node": "2.1.0",
|
|
112
112
|
"@semantic-release/changelog": "^7.0.0",
|
|
113
113
|
"@semantic-release/exec": "^7.1.0",
|
|
114
114
|
"@semantic-release/git": "^11.0.0",
|