@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.
Files changed (2) hide show
  1. package/dist/index.js +79 -13
  2. 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
- const content = idx === sectionIndex ? newHtml : section.html;
1370
- if (section.level === 0) return content;
1371
- return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
1372
- }).join("");
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 content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. 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). The section heading is preserved and is NOT part of the replaced content.",
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 = replaceSectionContent(translation.body, section_index, newSectionHtml);
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 updatedSection = parseSections(updated.body)[section_index];
3270
- const newWordCount = updatedSection?.wordCount ?? 0;
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: [`Section [${section_index}] "${updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`, `New word count: ${newWordCount}.`].join("\n")
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.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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
74
+ "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f",
75
75
  "dependencies": {
76
- "@modelcontextprotocol/server": "2.0.0",
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.0.0",
111
- "@modelcontextprotocol/node": "2.0.0",
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",