@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.
Files changed (2) hide show
  1. package/dist/index.js +75 -8
  2. 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 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.",
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 = replaceSectionContent(translation.body, section_index, newSectionHtml);
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 updatedSection = parseSections(updated.body)[section_index];
3270
- const newWordCount = updatedSection?.wordCount ?? 0;
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: [`Section [${section_index}] "${updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`, `New word count: ${newWordCount}.`].join("\n")
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.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.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",