@fruggr/zendesk-mcp-server 2.16.1 → 2.17.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/README.md +159 -288
- package/dist/index.js +401 -103
- package/package.json +11 -6
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir, release } from "node:os";
|
|
|
6
6
|
import open from "open";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
9
10
|
import * as z from "zod/v4";
|
|
10
11
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
12
|
import * as cheerio from "cheerio";
|
|
@@ -125,7 +126,8 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
125
126
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
126
127
|
};
|
|
127
128
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
128
|
-
const
|
|
129
|
+
const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
|
|
130
|
+
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
129
131
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
130
132
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
131
133
|
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
@@ -137,7 +139,7 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
137
139
|
});
|
|
138
140
|
//#endregion
|
|
139
141
|
//#region src/auth/browser-oauth.ts
|
|
140
|
-
const AUTH_TIMEOUT_MS =
|
|
142
|
+
const AUTH_TIMEOUT_MS = 3e5;
|
|
141
143
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
142
144
|
const detectWsl = () => {
|
|
143
145
|
if (process.platform !== "linux") return false;
|
|
@@ -427,8 +429,12 @@ const appDirSegments = () => {
|
|
|
427
429
|
};
|
|
428
430
|
const configDir = () => {
|
|
429
431
|
const segments = appDirSegments();
|
|
430
|
-
if (isWindows)
|
|
431
|
-
|
|
432
|
+
if (isWindows) {
|
|
433
|
+
const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
|
|
434
|
+
return join(base, ...segments);
|
|
435
|
+
}
|
|
436
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
437
|
+
return join(base, ...segments);
|
|
432
438
|
};
|
|
433
439
|
const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
|
|
434
440
|
/**
|
|
@@ -479,7 +485,7 @@ const clearToken = (path, logger = silentLogger) => {
|
|
|
479
485
|
//#endregion
|
|
480
486
|
//#region src/auth/token-store.ts
|
|
481
487
|
const EXPIRY_SKEW_MS = 6e4;
|
|
482
|
-
const SCHEDULED_REFRESH_MS =
|
|
488
|
+
const SCHEDULED_REFRESH_MS = 144e5;
|
|
483
489
|
const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
|
|
484
490
|
name: "AuthRequiredError",
|
|
485
491
|
authorizeUrl
|
|
@@ -701,97 +707,99 @@ const parsePort = (raw, label) => {
|
|
|
701
707
|
if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
702
708
|
return Number(raw);
|
|
703
709
|
};
|
|
704
|
-
const parsePortEnv = (raw, label) => raw === void 0
|
|
705
|
-
const
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
};
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
710
|
+
const parsePortEnv = (raw, label) => raw === void 0 ? void 0 : parsePort(raw, label);
|
|
711
|
+
const requireNonEmptyEnv = (name) => {
|
|
712
|
+
const raw = process.env[name];
|
|
713
|
+
if (raw === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
|
|
714
|
+
return raw;
|
|
715
|
+
};
|
|
716
|
+
const CLI_OPTIONS = {
|
|
717
|
+
mode: { type: "string" },
|
|
718
|
+
namespace: {
|
|
719
|
+
type: "string",
|
|
720
|
+
multiple: true
|
|
721
|
+
},
|
|
722
|
+
tool: {
|
|
723
|
+
type: "string",
|
|
724
|
+
multiple: true
|
|
725
|
+
},
|
|
726
|
+
"log-level": { type: "string" },
|
|
727
|
+
"hc-resource-scheme": { type: "string" },
|
|
728
|
+
transport: { type: "string" },
|
|
729
|
+
host: { type: "string" },
|
|
730
|
+
port: { type: "string" },
|
|
731
|
+
"public-url": { type: "string" },
|
|
732
|
+
"cors-origin": {
|
|
733
|
+
type: "string",
|
|
734
|
+
multiple: true
|
|
735
|
+
},
|
|
736
|
+
"callback-port": { type: "string" },
|
|
737
|
+
"read-only": { type: "boolean" },
|
|
738
|
+
"no-topology": { type: "boolean" },
|
|
739
|
+
"no-promoted-articles": { type: "boolean" },
|
|
740
|
+
dev: { type: "boolean" }
|
|
741
|
+
};
|
|
742
|
+
new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
|
|
743
|
+
const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
744
|
+
["mode", "mode"],
|
|
745
|
+
["namespace", "namespaces"],
|
|
746
|
+
["tool", "tools"],
|
|
747
|
+
["log-level", "logLevel"],
|
|
748
|
+
["hc-resource-scheme", "hcResourceScheme"],
|
|
749
|
+
["transport", "transport"],
|
|
750
|
+
["host", "host"],
|
|
751
|
+
["public-url", "publicUrl"],
|
|
752
|
+
["cors-origin", "corsOrigins"]
|
|
723
753
|
]);
|
|
724
|
-
const
|
|
725
|
-
["
|
|
726
|
-
|
|
727
|
-
}],
|
|
728
|
-
["
|
|
729
|
-
result.hcResourceScheme = value;
|
|
730
|
-
}],
|
|
731
|
-
["--log-level", (result, value) => {
|
|
732
|
-
result.logLevel = value;
|
|
733
|
-
}],
|
|
734
|
-
["--transport", (result, value) => {
|
|
735
|
-
result.transport = value;
|
|
736
|
-
}],
|
|
737
|
-
["--host", (result, value) => {
|
|
738
|
-
result.host = value;
|
|
739
|
-
}],
|
|
740
|
-
["--public-url", (result, value) => {
|
|
741
|
-
result.publicUrl = value;
|
|
742
|
-
}],
|
|
743
|
-
["--port", (result, value) => {
|
|
744
|
-
result.port = parsePort(value, "--port");
|
|
745
|
-
}],
|
|
746
|
-
["--callback-port", (result, value) => {
|
|
747
|
-
result.callbackPort = parsePort(value, "--callback-port");
|
|
748
|
-
}],
|
|
749
|
-
["--namespace", appendTo("namespaces")],
|
|
750
|
-
["--tool", appendTo("tools")],
|
|
751
|
-
["--cors-origin", appendTo("corsOrigins")]
|
|
754
|
+
const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
755
|
+
["read-only", { readOnly: true }],
|
|
756
|
+
["no-topology", { topology: false }],
|
|
757
|
+
["no-promoted-articles", { promotedArticles: false }],
|
|
758
|
+
["dev", { dev: true }]
|
|
752
759
|
]);
|
|
753
760
|
const parseCliArgs = (args) => {
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
valued(result, next);
|
|
767
|
-
i++;
|
|
761
|
+
const { values, positionals } = parseArgs({
|
|
762
|
+
args,
|
|
763
|
+
options: CLI_OPTIONS,
|
|
764
|
+
allowPositionals: true
|
|
765
|
+
});
|
|
766
|
+
if (positionals.length > 1) throw new Error(`Expected one positional argument (the subdomain), got ${positionals.length}. A repeatable flag has to be repeated (--namespace tickets --namespace help_center); it does not take a space-separated list.`);
|
|
767
|
+
const result = { subdomain: positionals[0] };
|
|
768
|
+
for (const [flag, value] of Object.entries(values)) {
|
|
769
|
+
if (Array.isArray(value) ? value.includes("") : value === "") throw new Error(`Empty value for --${flag}. Provide a value, or omit the flag.`);
|
|
770
|
+
const effect = STANDALONE_EFFECTS.get(flag);
|
|
771
|
+
if (effect) {
|
|
772
|
+
Object.assign(result, effect);
|
|
768
773
|
continue;
|
|
769
774
|
}
|
|
770
|
-
|
|
775
|
+
const field = FIELD_BY_FLAG.get(flag);
|
|
776
|
+
if (field) Object.assign(result, { [field]: value });
|
|
771
777
|
}
|
|
778
|
+
if (values.port !== void 0) result.port = parsePort(values.port, "--port");
|
|
779
|
+
if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
|
|
772
780
|
return result;
|
|
773
781
|
};
|
|
774
782
|
const resolveTransportSettings = (cli) => {
|
|
775
783
|
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
776
784
|
return {
|
|
777
|
-
transport: cli.transport ??
|
|
778
|
-
host: cli.host ??
|
|
779
|
-
port: cli.port ?? parsePortEnv(
|
|
780
|
-
publicUrl: cli.publicUrl ??
|
|
785
|
+
transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
|
|
786
|
+
host: cli.host ?? requireNonEmptyEnv("HOST") ?? "0.0.0.0",
|
|
787
|
+
port: cli.port ?? parsePortEnv(requireNonEmptyEnv("PORT"), "PORT") ?? 3e3,
|
|
788
|
+
publicUrl: cli.publicUrl ?? requireNonEmptyEnv("PUBLIC_URL"),
|
|
781
789
|
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
782
790
|
};
|
|
783
791
|
};
|
|
784
792
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
785
793
|
const cli = parseCliArgs(argv);
|
|
786
|
-
const subdomain = cli.subdomain ??
|
|
787
|
-
const oauthClientId =
|
|
794
|
+
const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
|
|
795
|
+
const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
|
|
788
796
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
789
|
-
const callbackPort = cli.callbackPort ?? parsePortEnv(
|
|
790
|
-
const hcResourceScheme = cli.hcResourceScheme ?? (
|
|
797
|
+
const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
798
|
+
const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
|
|
791
799
|
return ConfigSchema.parse({
|
|
792
800
|
subdomain,
|
|
793
801
|
oauthClientId,
|
|
794
|
-
logLevel: cli.logLevel ??
|
|
802
|
+
logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
|
|
795
803
|
mode,
|
|
796
804
|
readOnly: cli.readOnly ?? false,
|
|
797
805
|
namespaces: cli.namespaces,
|
|
@@ -1259,6 +1267,13 @@ const formatTranslation = (translation) => [
|
|
|
1259
1267
|
"",
|
|
1260
1268
|
translation.body
|
|
1261
1269
|
].join("\n");
|
|
1270
|
+
const formatNodeTranslationSummary = (translation) => [
|
|
1271
|
+
`## Translation: ${translation.locale} (${translation.id})`,
|
|
1272
|
+
`- **Name**: ${translation.title}`,
|
|
1273
|
+
`- **Description**: ${translation.body ? "set" : "empty"}`,
|
|
1274
|
+
`- **Draft**: ${translation.draft}`,
|
|
1275
|
+
`- **Updated**: ${translation.updated_at}`
|
|
1276
|
+
].join("\n");
|
|
1262
1277
|
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
1263
1278
|
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
1264
1279
|
const formatView = (view, count) => {
|
|
@@ -1359,12 +1374,14 @@ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOUR
|
|
|
1359
1374
|
* character limit. Reuses the same formatting as the `get_article` tool.
|
|
1360
1375
|
*/
|
|
1361
1376
|
const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1377
|
+
const path = locale ? `/${locale}/articles/${id}` : `/articles/${id}`;
|
|
1378
|
+
const { article } = await helpCenterGet(subdomain, token, path);
|
|
1379
|
+
const text = [
|
|
1364
1380
|
formatArticleSummary(article),
|
|
1365
1381
|
"",
|
|
1366
1382
|
htmlToMarkdown(article.body)
|
|
1367
|
-
].join("\n")
|
|
1383
|
+
].join("\n");
|
|
1384
|
+
return truncateIfNeeded(text);
|
|
1368
1385
|
};
|
|
1369
1386
|
/**
|
|
1370
1387
|
* Build an article-resources provider. `listPromoted` holds a memoized-promise
|
|
@@ -1592,7 +1609,7 @@ const renderAdminSection = (items, denied, deniedNote) => {
|
|
|
1592
1609
|
};
|
|
1593
1610
|
/** Render the topology as a compact Markdown document for the LLM context. */
|
|
1594
1611
|
const formatTopology = (data) => {
|
|
1595
|
-
|
|
1612
|
+
const text = [
|
|
1596
1613
|
`# Zendesk Help Center topology — ${data.subdomain}`,
|
|
1597
1614
|
"",
|
|
1598
1615
|
`**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
|
|
@@ -1609,7 +1626,8 @@ const formatTopology = (data) => {
|
|
|
1609
1626
|
"",
|
|
1610
1627
|
"## Permission groups",
|
|
1611
1628
|
...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
|
|
1612
|
-
].join("\n")
|
|
1629
|
+
].join("\n");
|
|
1630
|
+
return truncateIfNeeded(text);
|
|
1613
1631
|
};
|
|
1614
1632
|
/**
|
|
1615
1633
|
* Build a topology provider holding a memoized-promise cache (TTL
|
|
@@ -1737,6 +1755,117 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
|
|
|
1737
1755
|
//#region src/tools/help-center.ts
|
|
1738
1756
|
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1739
1757
|
const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
|
|
1758
|
+
const NODE_LABEL = {
|
|
1759
|
+
sections: "section",
|
|
1760
|
+
categories: "category"
|
|
1761
|
+
};
|
|
1762
|
+
const listNodeTranslations = (subdomain, token, kind, nodeId, locale) => helpCenterGet(subdomain, token, `/${kind}/${nodeId}/translations`, locale ? { locales: locale.toLowerCase() } : void 0).then((res) => res.translations ?? []);
|
|
1763
|
+
const findTranslation = (translations, locale) => {
|
|
1764
|
+
const wanted = locale.toLowerCase();
|
|
1765
|
+
return translations.find((t) => t.locale.toLowerCase() === wanted);
|
|
1766
|
+
};
|
|
1767
|
+
/**
|
|
1768
|
+
* Create-or-update a section/category translation in one call. The POST-vs-PUT
|
|
1769
|
+
* probe spares the caller a listing round-trip, or the 400 a duplicate POST
|
|
1770
|
+
* returns. Only the fields passed are sent on update, so omitting `description`
|
|
1771
|
+
* never blanks it and omitting `draft` never (un)publishes by accident.
|
|
1772
|
+
*/
|
|
1773
|
+
const upsertNodeTranslation = async (subdomain, token, kind, nodeId, input) => {
|
|
1774
|
+
const { locale, name, description, draft } = input;
|
|
1775
|
+
const existing = findTranslation(await listNodeTranslations(subdomain, token, kind, nodeId, locale), locale);
|
|
1776
|
+
if (!existing) {
|
|
1777
|
+
if (name === void 0) throw new Error(`${NODE_LABEL[kind]} #${nodeId} has no "${locale}" translation yet, so one has to be created and "name" is required. Pass the localized name, or call list_${NODE_LABEL[kind]}_translations to see which locales already exist.`);
|
|
1778
|
+
const { translation } = await helpCenterPost(subdomain, token, `/${kind}/${nodeId}/translations`, { translation: {
|
|
1779
|
+
locale,
|
|
1780
|
+
title: name,
|
|
1781
|
+
body: description ?? "",
|
|
1782
|
+
draft: draft ?? false
|
|
1783
|
+
} });
|
|
1784
|
+
return {
|
|
1785
|
+
translation,
|
|
1786
|
+
created: true
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
const updates = {};
|
|
1790
|
+
if (name !== void 0) updates["title"] = name;
|
|
1791
|
+
if (description !== void 0) updates["body"] = description;
|
|
1792
|
+
if (draft !== void 0) updates["draft"] = draft;
|
|
1793
|
+
if (Object.keys(updates).length === 0) throw new Error(`Nothing to write: ${NODE_LABEL[kind]} #${nodeId} already has a "${existing.locale}" translation, so pass at least one of "name", "description" or "draft" to change it (draft: false publishes it).`);
|
|
1794
|
+
const { translation } = await helpCenterPut(subdomain, token, `/${kind}/${nodeId}/translations/${existing.locale}`, { translation: updates });
|
|
1795
|
+
return {
|
|
1796
|
+
translation,
|
|
1797
|
+
created: false
|
|
1798
|
+
};
|
|
1799
|
+
};
|
|
1800
|
+
const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
|
|
1801
|
+
`Translation ${created ? "created" : "updated"} for ${NODE_LABEL[kind]} #${nodeId} in "${translation.locale}" (${translation.draft ? "draft, not visible to end users" : "published"}).`,
|
|
1802
|
+
"",
|
|
1803
|
+
formatNodeTranslationSummary(translation)
|
|
1804
|
+
].join("\n");
|
|
1805
|
+
const GAP_REASON_TEXT = {
|
|
1806
|
+
missing: "no translation",
|
|
1807
|
+
draft: "draft translation (not published)"
|
|
1808
|
+
};
|
|
1809
|
+
const classifyGap = (node, translations, locale) => {
|
|
1810
|
+
const translation = findTranslation(translations, locale);
|
|
1811
|
+
if (!translation) return {
|
|
1812
|
+
id: node.id,
|
|
1813
|
+
name: node.name,
|
|
1814
|
+
reason: "missing"
|
|
1815
|
+
};
|
|
1816
|
+
return translation.draft ? {
|
|
1817
|
+
id: node.id,
|
|
1818
|
+
name: node.name,
|
|
1819
|
+
reason: "draft"
|
|
1820
|
+
} : null;
|
|
1821
|
+
};
|
|
1822
|
+
const renderGapLines = (heading, gaps, scanned, found) => {
|
|
1823
|
+
const header = `## ${heading} (${scanned} scanned)`;
|
|
1824
|
+
if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
|
|
1825
|
+
if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none scanned — the ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap was spent before this level; ${found} left unchecked, see the note below)_`];
|
|
1826
|
+
return [header, "_(none — every one scanned has a published translation)_"];
|
|
1827
|
+
};
|
|
1828
|
+
const GAP_SCAN_WAVE_SIZE = 5;
|
|
1829
|
+
const probeInWaves = async (nodes, probe) => {
|
|
1830
|
+
const gaps = [];
|
|
1831
|
+
for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
|
|
1832
|
+
const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
|
|
1833
|
+
for (const gap of wave) if (gap !== null) gaps.push(gap);
|
|
1834
|
+
}
|
|
1835
|
+
return gaps;
|
|
1836
|
+
};
|
|
1837
|
+
const fetchGapCategories = async (subdomain, token, categoryId) => {
|
|
1838
|
+
if (categoryId !== void 0) {
|
|
1839
|
+
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
|
|
1840
|
+
return {
|
|
1841
|
+
categories: [category],
|
|
1842
|
+
hasMore: false
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
|
|
1846
|
+
const categories = response.categories ?? [];
|
|
1847
|
+
return {
|
|
1848
|
+
categories,
|
|
1849
|
+
hasMore: extractPaginationMeta(response, categories.length).has_more
|
|
1850
|
+
};
|
|
1851
|
+
};
|
|
1852
|
+
const renderGapReport = (report) => {
|
|
1853
|
+
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
1854
|
+
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
1855
|
+
const capped = scanned.categories < found.categories || scanned.sections < found.sections;
|
|
1856
|
+
return truncateIfNeeded([
|
|
1857
|
+
`# Translation gaps — "${locale}"`,
|
|
1858
|
+
"",
|
|
1859
|
+
...report.activeLocales.some((l) => l.toLowerCase() === locale.toLowerCase()) ? [] : [`> ⚠ "${locale}" is not an active locale of this Help Center (active: ${report.activeLocales.join(", ")}), so everything below reads as untranslated. Check the spelling, or activate the language in Guide first.`, ""],
|
|
1860
|
+
...renderGapLines("Categories", categoryGaps, scanned.categories, found.categories),
|
|
1861
|
+
"",
|
|
1862
|
+
...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
|
|
1863
|
+
"",
|
|
1864
|
+
gapCount === 0 ? `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.` : `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`,
|
|
1865
|
+
...capped ? ["", `_Note: the scan stopped at its ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap, covering ${scanned.categories}/${found.categories} categories and ${scanned.sections}/${found.sections} sections. The rest were not checked — narrow the scan with category_id, or raise ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES._`] : [],
|
|
1866
|
+
...report.listingIncomplete ? ["", `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
|
|
1867
|
+
].join("\n"));
|
|
1868
|
+
};
|
|
1740
1869
|
const largeArticleHint = (body, sectionCount) => {
|
|
1741
1870
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
1742
1871
|
return [
|
|
@@ -1894,9 +2023,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1894
2023
|
const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
|
|
1895
2024
|
const { article } = await helpCenterGet(subdomain, token, path);
|
|
1896
2025
|
const translations = await listTranslations(subdomain, token, article_id);
|
|
2026
|
+
const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
|
|
1897
2027
|
return { content: [{
|
|
1898
2028
|
type: "text",
|
|
1899
|
-
text: truncateIfNeeded(
|
|
2029
|
+
text: truncateIfNeeded(text)
|
|
1900
2030
|
}] };
|
|
1901
2031
|
}
|
|
1902
2032
|
},
|
|
@@ -2002,9 +2132,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2002
2132
|
return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
|
|
2003
2133
|
}));
|
|
2004
2134
|
const meta = extractPaginationMeta(response, articles.length);
|
|
2135
|
+
const text = [meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n");
|
|
2005
2136
|
return { content: [{
|
|
2006
2137
|
type: "text",
|
|
2007
|
-
text: truncateIfNeeded(
|
|
2138
|
+
text: truncateIfNeeded(text)
|
|
2008
2139
|
}] };
|
|
2009
2140
|
}
|
|
2010
2141
|
},
|
|
@@ -2027,9 +2158,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2027
2158
|
const header = `Promoted (featured) articles: ${articles.length}`;
|
|
2028
2159
|
const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
|
|
2029
2160
|
const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
|
|
2161
|
+
const note = scanCostNote(truncated, pagesScanned, cost);
|
|
2030
2162
|
return { content: [{
|
|
2031
2163
|
type: "text",
|
|
2032
|
-
text: truncateIfNeeded(`${header}\n\n${body}${
|
|
2164
|
+
text: truncateIfNeeded(`${header}\n\n${body}${note}`)
|
|
2033
2165
|
}] };
|
|
2034
2166
|
}
|
|
2035
2167
|
},
|
|
@@ -2049,9 +2181,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2049
2181
|
handler: async (params) => {
|
|
2050
2182
|
const { article_id } = params;
|
|
2051
2183
|
const token = await getToken();
|
|
2184
|
+
const translations = await listTranslations(subdomain, token, article_id);
|
|
2052
2185
|
return { content: [{
|
|
2053
2186
|
type: "text",
|
|
2054
|
-
text: formatList(
|
|
2187
|
+
text: formatList(translations, formatTranslationSummary)
|
|
2055
2188
|
}] };
|
|
2056
2189
|
}
|
|
2057
2190
|
},
|
|
@@ -2118,6 +2251,160 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2118
2251
|
}] };
|
|
2119
2252
|
}
|
|
2120
2253
|
},
|
|
2254
|
+
{
|
|
2255
|
+
name: "list_section_translations",
|
|
2256
|
+
namespace: "help_center",
|
|
2257
|
+
readOnly: true,
|
|
2258
|
+
title: "List Section Translations",
|
|
2259
|
+
description: "List the translations of a Help Center section: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a section looks wrong in a locale, because list_sections with that locale cannot settle it: a section with no translation is omitted from it, while a section whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_section_translation; to sweep every category and section at once, use find_translation_gaps.",
|
|
2260
|
+
inputSchema: z.object({ section_id: z.number().int().describe("Section ID — the numeric id of the Help Center section. Obtain it from list_sections or the zendesk-hc://topology resource.") }),
|
|
2261
|
+
annotations: {
|
|
2262
|
+
readOnlyHint: true,
|
|
2263
|
+
destructiveHint: false,
|
|
2264
|
+
idempotentHint: true,
|
|
2265
|
+
openWorldHint: true
|
|
2266
|
+
},
|
|
2267
|
+
handler: async (params) => {
|
|
2268
|
+
const { section_id } = params;
|
|
2269
|
+
const token = await getToken();
|
|
2270
|
+
const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
|
|
2271
|
+
return { content: [{
|
|
2272
|
+
type: "text",
|
|
2273
|
+
text: formatList(translations, formatNodeTranslationSummary)
|
|
2274
|
+
}] };
|
|
2275
|
+
}
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
name: "list_category_translations",
|
|
2279
|
+
namespace: "help_center",
|
|
2280
|
+
readOnly: true,
|
|
2281
|
+
title: "List Category Translations",
|
|
2282
|
+
description: "List the translations of a Help Center category: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a category looks wrong in a locale, because list_categories with that locale cannot settle it: a category with no translation is omitted from it, while a category whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_category_translation; to sweep every category and section at once, use find_translation_gaps.",
|
|
2283
|
+
inputSchema: z.object({ category_id: z.number().int().describe("Category ID — the numeric id of the Help Center category. Obtain it from list_categories or the zendesk-hc://topology resource.") }),
|
|
2284
|
+
annotations: {
|
|
2285
|
+
readOnlyHint: true,
|
|
2286
|
+
destructiveHint: false,
|
|
2287
|
+
idempotentHint: true,
|
|
2288
|
+
openWorldHint: true
|
|
2289
|
+
},
|
|
2290
|
+
handler: async (params) => {
|
|
2291
|
+
const { category_id } = params;
|
|
2292
|
+
const token = await getToken();
|
|
2293
|
+
const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
|
|
2294
|
+
return { content: [{
|
|
2295
|
+
type: "text",
|
|
2296
|
+
text: formatList(translations, formatNodeTranslationSummary)
|
|
2297
|
+
}] };
|
|
2298
|
+
}
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "find_translation_gaps",
|
|
2302
|
+
namespace: "help_center",
|
|
2303
|
+
readOnly: true,
|
|
2304
|
+
title: "Find Help Center Translation Gaps",
|
|
2305
|
+
description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs one extra request per node scanned, capped (the report says so when the cap bites) — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
|
|
2306
|
+
inputSchema: z.object({
|
|
2307
|
+
locale: z.string().describe("Locale to audit, e.g. \"fr\" or \"de\" — usually a non-default active locale of the Help Center (zendesk-hc://topology lists them). A locale that is not active is reported as a warning, since every node would then look untranslated."),
|
|
2308
|
+
category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs one request per category and per section.")
|
|
2309
|
+
}),
|
|
2310
|
+
annotations: {
|
|
2311
|
+
readOnlyHint: true,
|
|
2312
|
+
destructiveHint: false,
|
|
2313
|
+
idempotentHint: true,
|
|
2314
|
+
openWorldHint: true
|
|
2315
|
+
},
|
|
2316
|
+
handler: async (params) => {
|
|
2317
|
+
const { locale, category_id } = params;
|
|
2318
|
+
const token = await getToken();
|
|
2319
|
+
const [locales, categoryScope, sectionsRes] = await Promise.all([
|
|
2320
|
+
helpCenterGet(subdomain, token, "/locales"),
|
|
2321
|
+
fetchGapCategories(subdomain, token, category_id),
|
|
2322
|
+
helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
|
|
2323
|
+
]);
|
|
2324
|
+
const allCategories = categoryScope.categories;
|
|
2325
|
+
const allSections = sectionsRes.sections ?? [];
|
|
2326
|
+
const categories = allCategories.slice(0, TRANSLATION_GAP_SCAN_MAX_NODES);
|
|
2327
|
+
const sections = allSections.slice(0, Math.max(0, TRANSLATION_GAP_SCAN_MAX_NODES - categories.length));
|
|
2328
|
+
const categoryGaps = await probeInWaves(categories, async (category) => classifyGap(category, await listNodeTranslations(subdomain, token, "categories", category.id, locale), locale));
|
|
2329
|
+
const sectionGaps = await probeInWaves(sections, async (section) => classifyGap(section, await listNodeTranslations(subdomain, token, "sections", section.id, locale), locale));
|
|
2330
|
+
return { content: [{
|
|
2331
|
+
type: "text",
|
|
2332
|
+
text: renderGapReport({
|
|
2333
|
+
locale,
|
|
2334
|
+
activeLocales: locales.locales ?? [],
|
|
2335
|
+
categoryGaps,
|
|
2336
|
+
sectionGaps,
|
|
2337
|
+
scanned: {
|
|
2338
|
+
categories: categories.length,
|
|
2339
|
+
sections: sections.length
|
|
2340
|
+
},
|
|
2341
|
+
found: {
|
|
2342
|
+
categories: allCategories.length,
|
|
2343
|
+
sections: allSections.length
|
|
2344
|
+
},
|
|
2345
|
+
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
|
|
2346
|
+
})
|
|
2347
|
+
}] };
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
name: "set_section_translation",
|
|
2352
|
+
namespace: "help_center",
|
|
2353
|
+
readOnly: false,
|
|
2354
|
+
title: "Create or Update a Section Translation",
|
|
2355
|
+
description: "Create or update the translation of a Help Center section in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a section reachable in a locale where its articles are already translated — a gap find_translation_gaps reports and list_sections cannot explain.",
|
|
2356
|
+
inputSchema: z.object({
|
|
2357
|
+
section_id: z.number().int().describe("Section ID — the numeric id of the section whose translation to write. Obtain it from list_sections, find_translation_gaps or the zendesk-hc://topology resource."),
|
|
2358
|
+
locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_section_translations shows which ones the section already has."),
|
|
2359
|
+
name: z.string().min(1).optional().describe("Localized section name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
|
|
2360
|
+
description: z.string().optional().describe("Localized section description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
|
|
2361
|
+
draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the section visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
|
|
2362
|
+
}),
|
|
2363
|
+
annotations: {
|
|
2364
|
+
readOnlyHint: false,
|
|
2365
|
+
destructiveHint: true,
|
|
2366
|
+
idempotentHint: true,
|
|
2367
|
+
openWorldHint: true
|
|
2368
|
+
},
|
|
2369
|
+
handler: async (params) => {
|
|
2370
|
+
const { section_id, ...input } = params;
|
|
2371
|
+
const token = await getToken();
|
|
2372
|
+
const { translation, created } = await upsertNodeTranslation(subdomain, token, "sections", section_id, input);
|
|
2373
|
+
return { content: [{
|
|
2374
|
+
type: "text",
|
|
2375
|
+
text: nodeTranslationWriteText("sections", section_id, translation, created)
|
|
2376
|
+
}] };
|
|
2377
|
+
}
|
|
2378
|
+
},
|
|
2379
|
+
{
|
|
2380
|
+
name: "set_category_translation",
|
|
2381
|
+
namespace: "help_center",
|
|
2382
|
+
readOnly: false,
|
|
2383
|
+
title: "Create or Update a Category Translation",
|
|
2384
|
+
description: "Create or update the translation of a Help Center category in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a category reachable in a locale where its sections or articles are already translated — a gap find_translation_gaps reports and list_categories cannot explain.",
|
|
2385
|
+
inputSchema: z.object({
|
|
2386
|
+
category_id: z.number().int().describe("Category ID — the numeric id of the category whose translation to write. Obtain it from list_categories, find_translation_gaps or the zendesk-hc://topology resource."),
|
|
2387
|
+
locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_category_translations shows which ones the category already has."),
|
|
2388
|
+
name: z.string().min(1).optional().describe("Localized category name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
|
|
2389
|
+
description: z.string().optional().describe("Localized category description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
|
|
2390
|
+
draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the category visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
|
|
2391
|
+
}),
|
|
2392
|
+
annotations: {
|
|
2393
|
+
readOnlyHint: false,
|
|
2394
|
+
destructiveHint: true,
|
|
2395
|
+
idempotentHint: true,
|
|
2396
|
+
openWorldHint: true
|
|
2397
|
+
},
|
|
2398
|
+
handler: async (params) => {
|
|
2399
|
+
const { category_id, ...input } = params;
|
|
2400
|
+
const token = await getToken();
|
|
2401
|
+
const { translation, created } = await upsertNodeTranslation(subdomain, token, "categories", category_id, input);
|
|
2402
|
+
return { content: [{
|
|
2403
|
+
type: "text",
|
|
2404
|
+
text: nodeTranslationWriteText("categories", category_id, translation, created)
|
|
2405
|
+
}] };
|
|
2406
|
+
}
|
|
2407
|
+
},
|
|
2121
2408
|
{
|
|
2122
2409
|
name: "list_permission_groups",
|
|
2123
2410
|
namespace: "help_center",
|
|
@@ -2250,7 +2537,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2250
2537
|
const effective = await fetchSectionOrder(sectionId, locale, token);
|
|
2251
2538
|
if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
|
|
2252
2539
|
const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
|
|
2253
|
-
const
|
|
2540
|
+
const desired = arrangeDesiredOrder(effective, article_id, target, reference_article_id);
|
|
2541
|
+
const writes = computePositionWrites(desired, article_id, normalize);
|
|
2254
2542
|
if (writes.length === 0) return { content: [{
|
|
2255
2543
|
type: "text",
|
|
2256
2544
|
text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
|
|
@@ -2264,7 +2552,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2264
2552
|
text: `Reordering article #${article_id} to ${targetLabel} would reposition ${writes.length} articles in section #${sectionId}, above the safety threshold of ${REORDER_CONFIRM_THRESHOLD}. Re-run with confirm: true to proceed.`
|
|
2265
2553
|
}] };
|
|
2266
2554
|
const applied = await applyPositionWrites(writes, article_id, token);
|
|
2267
|
-
|
|
2555
|
+
const after = await fetchSectionOrder(sectionId, locale, token);
|
|
2556
|
+
if (!isPlacedAsRequested(after, article_id, target, reference_article_id)) return { content: [{
|
|
2268
2557
|
type: "text",
|
|
2269
2558
|
text: autoSortNotice(sectionId, applied)
|
|
2270
2559
|
}] };
|
|
@@ -2375,9 +2664,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2375
2664
|
},
|
|
2376
2665
|
handler: async () => {
|
|
2377
2666
|
const token = await getToken();
|
|
2667
|
+
const response = await helpCenterGet(subdomain, token, "/articles/labels");
|
|
2378
2668
|
return { content: [{
|
|
2379
2669
|
type: "text",
|
|
2380
|
-
text: formatList(
|
|
2670
|
+
text: formatList(response.labels ?? [], formatLabel)
|
|
2381
2671
|
}] };
|
|
2382
2672
|
}
|
|
2383
2673
|
},
|
|
@@ -2503,14 +2793,15 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2503
2793
|
const section = sections[section_index];
|
|
2504
2794
|
if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
|
|
2505
2795
|
const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
|
|
2796
|
+
const text = [
|
|
2797
|
+
section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
|
|
2798
|
+
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
2799
|
+
"",
|
|
2800
|
+
content
|
|
2801
|
+
].join("\n");
|
|
2506
2802
|
return { content: [{
|
|
2507
2803
|
type: "text",
|
|
2508
|
-
text: truncateIfNeeded(
|
|
2509
|
-
section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
|
|
2510
|
-
`_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
|
|
2511
|
-
"",
|
|
2512
|
-
content
|
|
2513
|
-
].join("\n"))
|
|
2804
|
+
text: truncateIfNeeded(text)
|
|
2514
2805
|
}] };
|
|
2515
2806
|
}
|
|
2516
2807
|
},
|
|
@@ -2674,16 +2965,18 @@ const createSearchTools = (ctx) => {
|
|
|
2674
2965
|
});
|
|
2675
2966
|
const results = response.results ?? [];
|
|
2676
2967
|
const meta = extractSearchPaginationMeta(response, per_page, page);
|
|
2968
|
+
const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
|
|
2969
|
+
const body = results.map(formatSearchResult).join("\n\n");
|
|
2677
2970
|
return { content: [{
|
|
2678
2971
|
type: "text",
|
|
2679
|
-
text: truncateIfNeeded([
|
|
2972
|
+
text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n"))
|
|
2680
2973
|
}] };
|
|
2681
2974
|
}
|
|
2682
2975
|
}];
|
|
2683
2976
|
};
|
|
2684
2977
|
//#endregion
|
|
2685
2978
|
//#region src/tools/tickets.ts
|
|
2686
|
-
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES /
|
|
2979
|
+
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
|
|
2687
2980
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
2688
2981
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
2689
2982
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -3302,9 +3595,10 @@ const createTicketTools = (ctx) => {
|
|
|
3302
3595
|
const { problem_id } = params;
|
|
3303
3596
|
const token = await getToken();
|
|
3304
3597
|
const incidents = (await zendeskGet(subdomain, token, `/tickets/${problem_id}/incidents`)).tickets ?? [];
|
|
3598
|
+
const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
|
|
3305
3599
|
return { content: [{
|
|
3306
3600
|
type: "text",
|
|
3307
|
-
text: truncateIfNeeded(
|
|
3601
|
+
text: truncateIfNeeded(text)
|
|
3308
3602
|
}] };
|
|
3309
3603
|
}
|
|
3310
3604
|
},
|
|
@@ -3370,9 +3664,10 @@ const createTicketTools = (ctx) => {
|
|
|
3370
3664
|
throw error;
|
|
3371
3665
|
}
|
|
3372
3666
|
const policies = response.sla_policies ?? [];
|
|
3667
|
+
const meta = extractOffsetPaginationMeta(response, policies.length, per_page, page);
|
|
3373
3668
|
return { content: [{
|
|
3374
3669
|
type: "text",
|
|
3375
|
-
text: formatList(policies, formatSlaPolicy,
|
|
3670
|
+
text: formatList(policies, formatSlaPolicy, meta)
|
|
3376
3671
|
}] };
|
|
3377
3672
|
}
|
|
3378
3673
|
},
|
|
@@ -3506,9 +3801,10 @@ const createTicketTools = (ctx) => {
|
|
|
3506
3801
|
const token = await getToken();
|
|
3507
3802
|
const response = await zendeskGet(subdomain, token, "/macros/active", buildOffsetParams(per_page, page));
|
|
3508
3803
|
const macros = response.macros ?? [];
|
|
3804
|
+
const meta = extractOffsetPaginationMeta(response, macros.length, per_page, page);
|
|
3509
3805
|
return { content: [{
|
|
3510
3806
|
type: "text",
|
|
3511
|
-
text: formatList(macros, formatMacro,
|
|
3807
|
+
text: formatList(macros, formatMacro, meta)
|
|
3512
3808
|
}] };
|
|
3513
3809
|
}
|
|
3514
3810
|
},
|
|
@@ -3944,7 +4240,8 @@ const startStdioTransport = async (server, logger = silentLogger) => {
|
|
|
3944
4240
|
};
|
|
3945
4241
|
//#endregion
|
|
3946
4242
|
//#region src/dev/reload.ts
|
|
3947
|
-
const
|
|
4243
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
4244
|
+
const toolsDir = join(thisDir, "..", "tools");
|
|
3948
4245
|
const TOOL_MODULES = [
|
|
3949
4246
|
{
|
|
3950
4247
|
file: "tickets.ts",
|
|
@@ -4287,8 +4584,8 @@ const respondBodyError = (req, res, failure) => {
|
|
|
4287
4584
|
if (failure.status === 413) if (res.writableFinished) req.destroy();
|
|
4288
4585
|
else res.once("finish", () => req.destroy());
|
|
4289
4586
|
};
|
|
4290
|
-
const SESSION_IDLE_TIMEOUT_MS =
|
|
4291
|
-
const SESSION_SWEEP_INTERVAL_MS =
|
|
4587
|
+
const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
4588
|
+
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
4292
4589
|
const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
|
|
4293
4590
|
const metadata = buildOAuthMetadata(config, logger);
|
|
4294
4591
|
const sessions = /* @__PURE__ */ new Map();
|
|
@@ -4442,7 +4739,8 @@ const main = async () => {
|
|
|
4442
4739
|
await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
4443
4740
|
return;
|
|
4444
4741
|
}
|
|
4445
|
-
|
|
4742
|
+
const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
4743
|
+
await startStdioTransport(server, logger);
|
|
4446
4744
|
return;
|
|
4447
4745
|
}
|
|
4448
4746
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|