@fruggr/zendesk-mcp-server 3.0.0 → 3.0.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 +75 -8
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1371,6 +1371,50 @@ const replaceSectionContent = (html, sectionIndex, newHtml) => {
|
|
|
1371
1371
|
return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
|
|
1372
1372
|
}).join("");
|
|
1373
1373
|
};
|
|
1374
|
+
const LEADING_HEADING = /^\s*<(h[1-3])(?:\s[^>]*)?>([\s\S]*?)<\/\1\s*>/i;
|
|
1375
|
+
const normalizeText = (text) => text.trim().split(WHITESPACE_RUN).join(" ");
|
|
1376
|
+
const stripLeadingDuplicateHeading = (html, target) => {
|
|
1377
|
+
const match = LEADING_HEADING.exec(html);
|
|
1378
|
+
const tag = match?.[1];
|
|
1379
|
+
if (!match || !tag) return {
|
|
1380
|
+
html,
|
|
1381
|
+
stripped: null
|
|
1382
|
+
};
|
|
1383
|
+
const text = normalizeText(textOf(match[2] ?? ""));
|
|
1384
|
+
if (tag.toLowerCase() !== target.headingTag || text !== normalizeText(target.heading)) return {
|
|
1385
|
+
html,
|
|
1386
|
+
stripped: null
|
|
1387
|
+
};
|
|
1388
|
+
return {
|
|
1389
|
+
html: html.slice(match[0].length).trimStart(),
|
|
1390
|
+
stripped: {
|
|
1391
|
+
tag: target.headingTag,
|
|
1392
|
+
text
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
};
|
|
1396
|
+
const introLoss = (target, contentSections) => {
|
|
1397
|
+
if (target?.level !== 0) return null;
|
|
1398
|
+
const first = contentSections[0];
|
|
1399
|
+
if (!first) return "empty";
|
|
1400
|
+
return first.level > 0 ? "heading-led" : null;
|
|
1401
|
+
};
|
|
1402
|
+
const applySectionUpdate = (html, sectionIndex, newHtml) => {
|
|
1403
|
+
const sections = parseSections(html);
|
|
1404
|
+
const target = sections[sectionIndex];
|
|
1405
|
+
const { html: content, stripped } = target ? stripLeadingDuplicateHeading(newHtml, target) : {
|
|
1406
|
+
html: newHtml,
|
|
1407
|
+
stripped: null
|
|
1408
|
+
};
|
|
1409
|
+
const contentSections = parseSections(content);
|
|
1410
|
+
return {
|
|
1411
|
+
body: replaceSectionContent(html, sectionIndex, content),
|
|
1412
|
+
stripped,
|
|
1413
|
+
contentHeadings: contentSections.filter((s) => s.level > 0).length,
|
|
1414
|
+
introLost: introLoss(target, contentSections),
|
|
1415
|
+
sectionsBefore: sections.length
|
|
1416
|
+
};
|
|
1417
|
+
};
|
|
1374
1418
|
const keepAsHtml = (_state, node) => ({
|
|
1375
1419
|
type: "html",
|
|
1376
1420
|
value: toHtml(node)
|
|
@@ -2361,6 +2405,16 @@ const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
|
|
|
2361
2405
|
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
2406
|
if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
|
|
2363
2407
|
};
|
|
2408
|
+
const structureWarning = (r) => {
|
|
2409
|
+
if (r.introLost === "empty") {
|
|
2410
|
+
if (r.sectionsBefore === 1) return ["Warning: content is empty and the intro was the only section, so the article body is now empty."];
|
|
2411
|
+
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.`];
|
|
2412
|
+
}
|
|
2413
|
+
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.`];
|
|
2414
|
+
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.`];
|
|
2415
|
+
return [];
|
|
2416
|
+
};
|
|
2417
|
+
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
2418
|
const createHelpCenterTools = (ctx) => {
|
|
2365
2419
|
const { subdomain, getToken } = ctx;
|
|
2366
2420
|
const fetchSectionOrder = async (sectionId, locale, token) => {
|
|
@@ -3207,7 +3261,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3207
3261
|
namespace: "help_center",
|
|
3208
3262
|
readOnly: true,
|
|
3209
3263
|
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).",
|
|
3264
|
+
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
3265
|
inputSchema: z.object({
|
|
3212
3266
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3213
3267
|
locale: z.string().describe("Locale of the body (e.g., \"en-us\", \"fr\")"),
|
|
@@ -3245,12 +3299,12 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3245
3299
|
namespace: "help_center",
|
|
3246
3300
|
readOnly: false,
|
|
3247
3301
|
title: "Update Article Section",
|
|
3248
|
-
description: "Replace the
|
|
3302
|
+
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
3303
|
inputSchema: z.object({
|
|
3250
3304
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3251
3305
|
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\"."),
|
|
3306
|
+
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."),
|
|
3307
|
+
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
3308
|
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
3309
|
}),
|
|
3256
3310
|
annotations: {
|
|
@@ -3264,13 +3318,26 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3264
3318
|
const token = await getToken();
|
|
3265
3319
|
const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${locale}`);
|
|
3266
3320
|
const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
|
|
3267
|
-
const newBody =
|
|
3321
|
+
const { body: newBody, stripped, contentHeadings, introLost, sectionsBefore } = applySectionUpdate(translation.body, section_index, newSectionHtml);
|
|
3268
3322
|
const { translation: updated } = await helpCenterPut(subdomain, token, `/articles/${article_id}/translations/${locale}`, { translation: { body: newBody } });
|
|
3269
|
-
const
|
|
3270
|
-
const
|
|
3323
|
+
const updatedSections = parseSections(updated.body);
|
|
3324
|
+
const updatedSection = updatedSections[section_index];
|
|
3325
|
+
const emptied = introLost === "empty";
|
|
3326
|
+
const newWordCount = emptied ? 0 : updatedSection?.wordCount ?? 0;
|
|
3271
3327
|
return { content: [{
|
|
3272
3328
|
type: "text",
|
|
3273
|
-
text: [
|
|
3329
|
+
text: [
|
|
3330
|
+
`Section [${section_index}] "${emptied ? "(intro)" : updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`,
|
|
3331
|
+
`New word count: ${newWordCount}.`,
|
|
3332
|
+
...sectionUpdateNotes({
|
|
3333
|
+
stripped,
|
|
3334
|
+
contentHeadings,
|
|
3335
|
+
introLost,
|
|
3336
|
+
sectionIndex: section_index,
|
|
3337
|
+
sectionsBefore,
|
|
3338
|
+
sectionsAfter: updatedSections.length
|
|
3339
|
+
})
|
|
3340
|
+
].join("\n")
|
|
3274
3341
|
}] };
|
|
3275
3342
|
}
|
|
3276
3343
|
},
|
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.1",
|
|
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",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
|
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",
|