@fruggr/zendesk-mcp-server 2.0.0 → 2.1.0
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 +23 -0
- package/dist/index.js +303 -139
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,27 @@ zendesk-mcp-server acme --namespace tickets
|
|
|
151
151
|
|
|
152
152
|
</details>
|
|
153
153
|
|
|
154
|
+
## Help Center context (instructions + resources)
|
|
155
|
+
|
|
156
|
+
Beyond tools, the server hands an LLM the structural context it needs to work
|
|
157
|
+
against *your* Help Center — so it stops guessing locales or fuzzy-matching
|
|
158
|
+
section names and uses real IDs instead. This is delivered through two
|
|
159
|
+
MCP-native channels (both active only when the `help_center` namespace is, and
|
|
160
|
+
disabled together with `--no-topology`):
|
|
161
|
+
|
|
162
|
+
- **`instructions`** (sent on `initialize`): a short, static blob auto-loaded by
|
|
163
|
+
compliant clients. It names the subdomain and points at the topology resource.
|
|
164
|
+
- **`zendesk-hc://topology`** (a pull-only [MCP resource](https://modelcontextprotocol.io/docs/concepts/resources)):
|
|
165
|
+
read on demand, it returns Markdown describing the active locales (and the
|
|
166
|
+
default), the category → section tree with IDs, the visibility user segments,
|
|
167
|
+
the permission groups, and the calling user's role. It is fetched **with the
|
|
168
|
+
caller's own token**, so it respects that user's read permissions. On a very
|
|
169
|
+
large Help Center the section tree is summarized (per-category, with a pointer
|
|
170
|
+
to `list_sections`) to stay concise.
|
|
171
|
+
|
|
172
|
+
Clients that don't consume `instructions` or `resources` simply ignore them —
|
|
173
|
+
the feature degrades silently. Use `--no-topology` to turn both off server-wide.
|
|
174
|
+
|
|
154
175
|
## Prerequisites
|
|
155
176
|
|
|
156
177
|
- **Node.js** >= 20 (runtime — declared in `package.json#engines.node`)
|
|
@@ -418,6 +439,8 @@ Options:
|
|
|
418
439
|
--namespace <ns> Filter by namespace (repeatable): tickets, help_center, users
|
|
419
440
|
--tool <name> Filter by tool name (repeatable, forces --mode all)
|
|
420
441
|
--read-only Only expose read operations
|
|
442
|
+
--no-topology Disable the Help Center structural context
|
|
443
|
+
(instructions + zendesk-hc://topology resource)
|
|
421
444
|
--log-level <level> debug | info (default) | warn | error
|
|
422
445
|
--transport <t> stdio (default) | http
|
|
423
446
|
--host <host> HTTP bind host (default: 0.0.0.0)
|
package/dist/index.js
CHANGED
|
@@ -566,6 +566,14 @@ const ConfigSchema = z.object({
|
|
|
566
566
|
readOnly: z.boolean(),
|
|
567
567
|
namespaces: z.array(Namespace).optional(),
|
|
568
568
|
tools: z.array(z.string()).optional(),
|
|
569
|
+
/**
|
|
570
|
+
* Whether to expose the Help Center structural context (the `instructions`
|
|
571
|
+
* blob + the `zendesk-hc://topology` resource). On by default; an operator
|
|
572
|
+
* disables it server-wide with `--no-topology` (e.g. on a very large Help
|
|
573
|
+
* Center, or when the context is unwanted). Only ever active when the
|
|
574
|
+
* `help_center` namespace itself is active.
|
|
575
|
+
*/
|
|
576
|
+
topology: z.boolean().default(true),
|
|
569
577
|
transport: Transport,
|
|
570
578
|
host: z.string().min(1),
|
|
571
579
|
port: z.number().int().min(0).max(65535),
|
|
@@ -596,6 +604,7 @@ const parseCliArgs = (args) => {
|
|
|
596
604
|
result.mode = next;
|
|
597
605
|
i++;
|
|
598
606
|
} else if (arg === "--read-only") result.readOnly = true;
|
|
607
|
+
else if (arg === "--no-topology") result.topology = false;
|
|
599
608
|
else if (arg === "--namespace" && next) {
|
|
600
609
|
result.namespaces = result.namespaces ?? [];
|
|
601
610
|
result.namespaces.push(next);
|
|
@@ -653,6 +662,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
653
662
|
readOnly: cli.readOnly ?? false,
|
|
654
663
|
namespaces: cli.namespaces,
|
|
655
664
|
tools: cli.tools,
|
|
665
|
+
topology: cli.topology ?? true,
|
|
656
666
|
transport,
|
|
657
667
|
host,
|
|
658
668
|
port,
|
|
@@ -771,6 +781,246 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
771
781
|
return response.json();
|
|
772
782
|
};
|
|
773
783
|
//#endregion
|
|
784
|
+
//#region src/guidance/instructions.ts
|
|
785
|
+
/** Stable URI of the dynamic Help Center topology resource. */
|
|
786
|
+
const TOPOLOGY_RESOURCE_URI = "zendesk-hc://topology";
|
|
787
|
+
/**
|
|
788
|
+
* Whether the Help Center structural context (init instructions + the
|
|
789
|
+
* `zendesk-hc://topology` resource) should be exposed. True only when the
|
|
790
|
+
* feature is enabled (`--no-topology` not set) AND the `help_center` namespace
|
|
791
|
+
* is active (no `--namespace` filter, or one that includes it). Shared by the
|
|
792
|
+
* instructions builder and the resource registration in `server.ts` so both
|
|
793
|
+
* gates stay in sync.
|
|
794
|
+
*/
|
|
795
|
+
const helpCenterContextEnabled = (config) => config.topology && (!config.namespaces?.length || config.namespaces.includes("help_center"));
|
|
796
|
+
/**
|
|
797
|
+
* The static `instructions` blob sent on `initialize`. Deliberately short and
|
|
798
|
+
* I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
|
|
799
|
+
* it stays within a tight token budget. The rich, dynamic topology lives in the
|
|
800
|
+
* pull-only `zendesk-hc://topology` resource referenced here.
|
|
801
|
+
*/
|
|
802
|
+
const buildInstructions = (config) => {
|
|
803
|
+
if (!helpCenterContextEnabled(config)) return void 0;
|
|
804
|
+
return [
|
|
805
|
+
`This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
|
|
806
|
+
"",
|
|
807
|
+
`Before creating or editing Help Center content, read the resource ${TOPOLOGY_RESOURCE_URI}.`,
|
|
808
|
+
"It describes the active locales (and the default one), the category → section tree with IDs,",
|
|
809
|
+
"the visibility user segments, the permission groups, and your current role.",
|
|
810
|
+
"Prefer the IDs from that resource (section_id, permission_group_id, user_segment_id, locale)",
|
|
811
|
+
"over guessing from names."
|
|
812
|
+
].join("\n");
|
|
813
|
+
};
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/utils/formatting.ts
|
|
816
|
+
const truncateIfNeeded = (text) => {
|
|
817
|
+
if (text.length <= 25e3) return text;
|
|
818
|
+
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
|
|
819
|
+
};
|
|
820
|
+
const formatPagination = (meta) => {
|
|
821
|
+
const parts = [`Results: ${meta.count}`];
|
|
822
|
+
if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
|
|
823
|
+
return parts.join(" | ");
|
|
824
|
+
};
|
|
825
|
+
const formatTicket = (ticket) => [
|
|
826
|
+
`## Ticket #${ticket.id}: ${ticket.subject}`,
|
|
827
|
+
`- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
|
|
828
|
+
`- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
|
|
829
|
+
`- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
|
|
830
|
+
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
831
|
+
ticket.description ? `\n${ticket.description}` : ""
|
|
832
|
+
].filter(Boolean).join("\n");
|
|
833
|
+
const formatComment = (comment) => {
|
|
834
|
+
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
|
|
835
|
+
if (comment.attachments?.length) {
|
|
836
|
+
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
837
|
+
lines.push(`Attachments: ${summary}`);
|
|
838
|
+
}
|
|
839
|
+
lines.push("", comment.body);
|
|
840
|
+
return lines.join("\n");
|
|
841
|
+
};
|
|
842
|
+
const formatUser = (user) => [
|
|
843
|
+
`## ${user.name} (${user.id})`,
|
|
844
|
+
`- **Email**: ${user.email}`,
|
|
845
|
+
`- **Role**: ${user.role}`,
|
|
846
|
+
user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
|
|
847
|
+
`- **Active**: ${user.active}`,
|
|
848
|
+
user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
|
|
849
|
+
].filter(Boolean).join("\n");
|
|
850
|
+
const formatOrganization = (org) => [
|
|
851
|
+
`## ${org.name} (${org.id})`,
|
|
852
|
+
org.details ? `- **Details**: ${org.details}` : "",
|
|
853
|
+
org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
|
|
854
|
+
org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
|
|
855
|
+
].filter(Boolean).join("\n");
|
|
856
|
+
const formatArticleSummary = (article) => [
|
|
857
|
+
`## ${article.title} (${article.id})`,
|
|
858
|
+
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
859
|
+
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
860
|
+
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
861
|
+
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
862
|
+
`- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
|
|
863
|
+
].filter(Boolean).join("\n");
|
|
864
|
+
const formatArticle = (article) => [
|
|
865
|
+
formatArticleSummary(article),
|
|
866
|
+
"",
|
|
867
|
+
article.body
|
|
868
|
+
].join("\n");
|
|
869
|
+
const formatTranslationSummary = (translation) => [
|
|
870
|
+
`## Translation: ${translation.locale} (${translation.id})`,
|
|
871
|
+
`- **Title**: ${translation.title}`,
|
|
872
|
+
`- **Draft**: ${translation.draft}`,
|
|
873
|
+
`- **Updated**: ${translation.updated_at}`
|
|
874
|
+
].join("\n");
|
|
875
|
+
const formatTranslation = (translation) => [
|
|
876
|
+
formatTranslationSummary(translation),
|
|
877
|
+
"",
|
|
878
|
+
translation.body
|
|
879
|
+
].join("\n");
|
|
880
|
+
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
881
|
+
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
882
|
+
const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
|
|
883
|
+
const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
884
|
+
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
885
|
+
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
886
|
+
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
887
|
+
const formatList = (items, formatter, meta) => {
|
|
888
|
+
return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
|
|
889
|
+
};
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/utils/pagination.ts
|
|
892
|
+
const buildCursorParams = (pageSize, cursor) => {
|
|
893
|
+
const params = { "page[size]": String(pageSize) };
|
|
894
|
+
if (cursor) params["page[after]"] = cursor;
|
|
895
|
+
return params;
|
|
896
|
+
};
|
|
897
|
+
const buildOffsetParams = (perPage, page) => {
|
|
898
|
+
const params = { per_page: String(perPage) };
|
|
899
|
+
if (page && page > 1) params["page"] = String(page);
|
|
900
|
+
return params;
|
|
901
|
+
};
|
|
902
|
+
const extractPaginationMeta = (response) => ({
|
|
903
|
+
has_more: response.meta?.has_more ?? response.next_page != null,
|
|
904
|
+
after_cursor: response.meta?.after_cursor ?? null,
|
|
905
|
+
count: response.count ?? 0
|
|
906
|
+
});
|
|
907
|
+
const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
908
|
+
const count = response.count ?? 0;
|
|
909
|
+
const has_more = count > page * perPage;
|
|
910
|
+
return {
|
|
911
|
+
has_more,
|
|
912
|
+
after_cursor: has_more ? String(page + 1) : null,
|
|
913
|
+
count
|
|
914
|
+
};
|
|
915
|
+
};
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/guidance/topology.ts
|
|
918
|
+
/**
|
|
919
|
+
* Fetch the structural topology with the CALLER'S token, so the result respects
|
|
920
|
+
* that user's read permissions (no privileged shared credential). Categories
|
|
921
|
+
* and sections are each capped at one max-size page; `sectionsHasMore` /
|
|
922
|
+
* `categoriesHasMore` signal a Help Center too large to enumerate inline.
|
|
923
|
+
*/
|
|
924
|
+
const fetchTopology = async (subdomain, token) => {
|
|
925
|
+
const pageParams = { "page[size]": String(100) };
|
|
926
|
+
const [locales, categoriesRes, sectionsRes, segmentsRes, permsRes, meRes] = await Promise.all([
|
|
927
|
+
helpCenterGet(subdomain, token, "/locales"),
|
|
928
|
+
helpCenterGet(subdomain, token, "/categories", pageParams),
|
|
929
|
+
helpCenterGet(subdomain, token, "/sections", pageParams),
|
|
930
|
+
helpCenterGet(subdomain, token, "/user_segments"),
|
|
931
|
+
zendeskGet(subdomain, token, "/guide/permission_groups"),
|
|
932
|
+
zendeskGet(subdomain, token, "/users/me")
|
|
933
|
+
]);
|
|
934
|
+
return {
|
|
935
|
+
subdomain,
|
|
936
|
+
locales,
|
|
937
|
+
categories: categoriesRes.categories ?? [],
|
|
938
|
+
sections: sectionsRes.sections ?? [],
|
|
939
|
+
sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
|
|
940
|
+
categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
|
|
941
|
+
userSegments: segmentsRes.user_segments ?? [],
|
|
942
|
+
permissionGroups: permsRes.permission_groups ?? [],
|
|
943
|
+
currentUser: meRes.user
|
|
944
|
+
};
|
|
945
|
+
};
|
|
946
|
+
const renderTree = (data) => {
|
|
947
|
+
if (data.categoriesHasMore || data.sectionsHasMore) {
|
|
948
|
+
const reasons = [];
|
|
949
|
+
if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
|
|
950
|
+
if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
|
|
951
|
+
return [
|
|
952
|
+
`Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
|
|
953
|
+
data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
|
|
954
|
+
...data.categories.map(formatCategory),
|
|
955
|
+
"",
|
|
956
|
+
...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
|
|
957
|
+
"Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
|
|
958
|
+
];
|
|
959
|
+
}
|
|
960
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
961
|
+
for (const section of data.sections) {
|
|
962
|
+
const list = byCategory.get(section.category_id) ?? [];
|
|
963
|
+
list.push(section);
|
|
964
|
+
byCategory.set(section.category_id, list);
|
|
965
|
+
}
|
|
966
|
+
const lines = [];
|
|
967
|
+
for (const category of data.categories) {
|
|
968
|
+
lines.push(formatCategory(category));
|
|
969
|
+
for (const section of byCategory.get(category.id) ?? []) lines.push(` ${formatSection(section)}`);
|
|
970
|
+
}
|
|
971
|
+
return lines.length ? lines : ["_(no categories)_"];
|
|
972
|
+
};
|
|
973
|
+
/** Render the topology as a compact Markdown document for the LLM context. */
|
|
974
|
+
const formatTopology = (data) => {
|
|
975
|
+
return truncateIfNeeded([
|
|
976
|
+
`# Zendesk Help Center topology — ${data.subdomain}`,
|
|
977
|
+
"",
|
|
978
|
+
`**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
|
|
979
|
+
"",
|
|
980
|
+
"## Locales",
|
|
981
|
+
`- Default: ${data.locales.default_locale}`,
|
|
982
|
+
`- Active: ${data.locales.locales.join(", ")}`,
|
|
983
|
+
"",
|
|
984
|
+
"## Categories → sections",
|
|
985
|
+
...renderTree(data),
|
|
986
|
+
"",
|
|
987
|
+
"## Visibility (user segments)",
|
|
988
|
+
...data.userSegments.length ? data.userSegments.map(formatUserSegment) : ["_(none)_"],
|
|
989
|
+
"",
|
|
990
|
+
"## Permission groups",
|
|
991
|
+
...data.permissionGroups.length ? data.permissionGroups.map(formatPermissionGroup) : ["_(none)_"]
|
|
992
|
+
].join("\n"));
|
|
993
|
+
};
|
|
994
|
+
/**
|
|
995
|
+
* Build a topology provider holding a memoized-promise cache (TTL
|
|
996
|
+
* `TOPOLOGY_TTL_MS`). In HTTP mode `createMcpServer` — and therefore this
|
|
997
|
+
* provider — is instantiated PER SESSION, so this cache is per-session /
|
|
998
|
+
* per-caller: it must NOT be hoisted to module scope, or one tenant's structure
|
|
999
|
+
* would leak to another. The promise (not the value) is cached to coalesce
|
|
1000
|
+
* concurrent reads; failures are evicted so the next read retries with a fresh
|
|
1001
|
+
* token. A 401 notifies `onUnauthorized` (stdio OAuth only) to invalidate the
|
|
1002
|
+
* stale token, mirroring the tool dispatch path in `server.ts`.
|
|
1003
|
+
*/
|
|
1004
|
+
const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
|
|
1005
|
+
let cached;
|
|
1006
|
+
return { read() {
|
|
1007
|
+
const now = Date.now();
|
|
1008
|
+
if (cached && now - cached.at < 3e5) return cached.promise;
|
|
1009
|
+
const promise = (async () => {
|
|
1010
|
+
return formatTopology(await fetchTopology(subdomain, await getToken()));
|
|
1011
|
+
})().catch((err) => {
|
|
1012
|
+
cached = void 0;
|
|
1013
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
1014
|
+
throw err;
|
|
1015
|
+
});
|
|
1016
|
+
cached = {
|
|
1017
|
+
at: now,
|
|
1018
|
+
promise
|
|
1019
|
+
};
|
|
1020
|
+
return promise;
|
|
1021
|
+
} };
|
|
1022
|
+
};
|
|
1023
|
+
//#endregion
|
|
774
1024
|
//#region src/routing/registry.ts
|
|
775
1025
|
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
776
1026
|
if (options.readOnly && !tool.readOnly) return false;
|
|
@@ -883,108 +1133,6 @@ const markdownToHtml = (markdown) => {
|
|
|
883
1133
|
return String(mdToHtmlProcessor.processSync(markdown));
|
|
884
1134
|
};
|
|
885
1135
|
//#endregion
|
|
886
|
-
//#region src/utils/formatting.ts
|
|
887
|
-
const truncateIfNeeded = (text) => {
|
|
888
|
-
if (text.length <= 25e3) return text;
|
|
889
|
-
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
|
|
890
|
-
};
|
|
891
|
-
const formatPagination = (meta) => {
|
|
892
|
-
const parts = [`Results: ${meta.count}`];
|
|
893
|
-
if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
|
|
894
|
-
return parts.join(" | ");
|
|
895
|
-
};
|
|
896
|
-
const formatTicket = (ticket) => [
|
|
897
|
-
`## Ticket #${ticket.id}: ${ticket.subject}`,
|
|
898
|
-
`- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
|
|
899
|
-
`- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
|
|
900
|
-
`- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
|
|
901
|
-
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
902
|
-
ticket.description ? `\n${ticket.description}` : ""
|
|
903
|
-
].filter(Boolean).join("\n");
|
|
904
|
-
const formatComment = (comment) => {
|
|
905
|
-
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
|
|
906
|
-
if (comment.attachments?.length) {
|
|
907
|
-
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
908
|
-
lines.push(`Attachments: ${summary}`);
|
|
909
|
-
}
|
|
910
|
-
lines.push("", comment.body);
|
|
911
|
-
return lines.join("\n");
|
|
912
|
-
};
|
|
913
|
-
const formatUser = (user) => [
|
|
914
|
-
`## ${user.name} (${user.id})`,
|
|
915
|
-
`- **Email**: ${user.email}`,
|
|
916
|
-
`- **Role**: ${user.role}`,
|
|
917
|
-
user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
|
|
918
|
-
`- **Active**: ${user.active}`,
|
|
919
|
-
user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
|
|
920
|
-
].filter(Boolean).join("\n");
|
|
921
|
-
const formatOrganization = (org) => [
|
|
922
|
-
`## ${org.name} (${org.id})`,
|
|
923
|
-
org.details ? `- **Details**: ${org.details}` : "",
|
|
924
|
-
org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
|
|
925
|
-
org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
|
|
926
|
-
].filter(Boolean).join("\n");
|
|
927
|
-
const formatArticleSummary = (article) => [
|
|
928
|
-
`## ${article.title} (${article.id})`,
|
|
929
|
-
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
930
|
-
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
931
|
-
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
932
|
-
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
933
|
-
`- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
|
|
934
|
-
].filter(Boolean).join("\n");
|
|
935
|
-
const formatArticle = (article) => [
|
|
936
|
-
formatArticleSummary(article),
|
|
937
|
-
"",
|
|
938
|
-
article.body
|
|
939
|
-
].join("\n");
|
|
940
|
-
const formatTranslationSummary = (translation) => [
|
|
941
|
-
`## Translation: ${translation.locale} (${translation.id})`,
|
|
942
|
-
`- **Title**: ${translation.title}`,
|
|
943
|
-
`- **Draft**: ${translation.draft}`,
|
|
944
|
-
`- **Updated**: ${translation.updated_at}`
|
|
945
|
-
].join("\n");
|
|
946
|
-
const formatTranslation = (translation) => [
|
|
947
|
-
formatTranslationSummary(translation),
|
|
948
|
-
"",
|
|
949
|
-
translation.body
|
|
950
|
-
].join("\n");
|
|
951
|
-
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
952
|
-
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
953
|
-
const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
|
|
954
|
-
const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
955
|
-
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
956
|
-
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
957
|
-
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
958
|
-
const formatList = (items, formatter, meta) => {
|
|
959
|
-
return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
|
|
960
|
-
};
|
|
961
|
-
//#endregion
|
|
962
|
-
//#region src/utils/pagination.ts
|
|
963
|
-
const buildCursorParams = (pageSize, cursor) => {
|
|
964
|
-
const params = { "page[size]": String(pageSize) };
|
|
965
|
-
if (cursor) params["page[after]"] = cursor;
|
|
966
|
-
return params;
|
|
967
|
-
};
|
|
968
|
-
const buildOffsetParams = (perPage, page) => {
|
|
969
|
-
const params = { per_page: String(perPage) };
|
|
970
|
-
if (page && page > 1) params["page"] = String(page);
|
|
971
|
-
return params;
|
|
972
|
-
};
|
|
973
|
-
const extractPaginationMeta = (response) => ({
|
|
974
|
-
has_more: response.meta?.has_more ?? response.next_page != null,
|
|
975
|
-
after_cursor: response.meta?.after_cursor ?? null,
|
|
976
|
-
count: response.count ?? 0
|
|
977
|
-
});
|
|
978
|
-
const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
979
|
-
const count = response.count ?? 0;
|
|
980
|
-
const has_more = count > page * perPage;
|
|
981
|
-
return {
|
|
982
|
-
has_more,
|
|
983
|
-
after_cursor: has_more ? String(page + 1) : null,
|
|
984
|
-
count
|
|
985
|
-
};
|
|
986
|
-
};
|
|
987
|
-
//#endregion
|
|
988
1136
|
//#region src/tools/help-center.ts
|
|
989
1137
|
const largeArticleHint = (body, sectionCount) => {
|
|
990
1138
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
@@ -1063,11 +1211,11 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1063
1211
|
namespace: "help_center",
|
|
1064
1212
|
readOnly: true,
|
|
1065
1213
|
title: "List Help Center Categories",
|
|
1066
|
-
description: "List all Help Center categories.
|
|
1214
|
+
description: "List all Help Center categories. Categories are the top level of the Guide hierarchy (category → section → article); each entry includes its id, name and locale. Results are cursor-paginated. Pair a returned category id with list_sections to drill down, then list_articles to reach articles. Pass a locale to read category names in that translation.",
|
|
1067
1215
|
inputSchema: z.object({
|
|
1068
|
-
locale: z.string().optional(),
|
|
1069
|
-
page_size: z.number().int().min(1).max(100).default(100),
|
|
1070
|
-
cursor: z.string().optional()
|
|
1216
|
+
locale: z.string().optional().describe("Locale for category names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
|
|
1217
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Categories per page (1-100, default 100)."),
|
|
1218
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
1071
1219
|
}),
|
|
1072
1220
|
annotations: {
|
|
1073
1221
|
readOnlyHint: true,
|
|
@@ -1089,12 +1237,12 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1089
1237
|
namespace: "help_center",
|
|
1090
1238
|
readOnly: true,
|
|
1091
1239
|
title: "List Help Center Sections",
|
|
1092
|
-
description: "List sections
|
|
1240
|
+
description: "List Help Center sections. Sections are the middle level of the Guide hierarchy (category → section → article) and group related articles; each entry includes its id, name, category_id and locale. Results are cursor-paginated. Pass category_id to list only one category's sections (ids come from list_categories), then use a section id with list_articles. Pass a locale to read section names in that translation.",
|
|
1093
1241
|
inputSchema: z.object({
|
|
1094
|
-
category_id: z.number().int().optional(),
|
|
1095
|
-
locale: z.string().optional(),
|
|
1096
|
-
page_size: z.number().int().min(1).max(100).default(100),
|
|
1097
|
-
cursor: z.string().optional()
|
|
1242
|
+
category_id: z.number().int().optional().describe("Restrict to sections of this category (id from list_categories). Omit to list every section."),
|
|
1243
|
+
locale: z.string().optional().describe("Locale for section names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
|
|
1244
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Sections per page (1-100, default 100)."),
|
|
1245
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
1098
1246
|
}),
|
|
1099
1247
|
annotations: {
|
|
1100
1248
|
readOnlyHint: true,
|
|
@@ -1189,13 +1337,13 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1189
1337
|
namespace: "help_center",
|
|
1190
1338
|
readOnly: false,
|
|
1191
1339
|
title: "Create Article Translation",
|
|
1192
|
-
description: "Create a translation for an existing article in a specific locale.",
|
|
1340
|
+
description: "Create a translation for an existing article in a specific locale. The article must already exist (create it with create_article); this adds a new localized version and returns the created translation (locale, title, draft state). The target locale must not already have a translation — use update_article_translation to modify an existing one, and list_article_translations to see which locales exist. Provide the full HTML body.",
|
|
1193
1341
|
inputSchema: z.object({
|
|
1194
|
-
article_id: z.number().int(),
|
|
1342
|
+
article_id: z.number().int().describe("ID of the existing article to translate."),
|
|
1195
1343
|
locale: z.string().describe("Target locale (e.g., \"fr\", \"de\")"),
|
|
1196
|
-
title: z.string().min(1),
|
|
1344
|
+
title: z.string().min(1).describe("Translated article title."),
|
|
1197
1345
|
body: z.string().min(1).describe("Translated body (HTML)"),
|
|
1198
|
-
draft: z.boolean().default(false)
|
|
1346
|
+
draft: z.boolean().default(false).describe("Create the translation as a draft (not visible to end users). Defaults to false (published).")
|
|
1199
1347
|
}),
|
|
1200
1348
|
annotations: {
|
|
1201
1349
|
readOnlyHint: false,
|
|
@@ -1357,8 +1505,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1357
1505
|
namespace: "help_center",
|
|
1358
1506
|
readOnly: false,
|
|
1359
1507
|
title: "Create Content Tag",
|
|
1360
|
-
description: "Create a new content tag for Guide articles.",
|
|
1361
|
-
inputSchema: z.object({ name: z.string().min(1).describe("Content tag name") }),
|
|
1508
|
+
description: "Create a new content tag for Guide articles. Content tags are end-user visible labels that help readers discover related articles; this returns the created tag with its id. Check list_content_tags first to avoid duplicates, then attach the new id via the content_tag_ids parameter of create_article or update_article. For internal search-ranking labels that are not shown to end users, use article labels (list_labels) instead.",
|
|
1509
|
+
inputSchema: z.object({ name: z.string().min(1).describe("Content tag name as shown to end users (e.g., \"billing\", \"getting-started\").") }),
|
|
1362
1510
|
annotations: {
|
|
1363
1511
|
readOnlyHint: false,
|
|
1364
1512
|
destructiveHint: false,
|
|
@@ -1419,8 +1567,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1419
1567
|
namespace: "help_center",
|
|
1420
1568
|
readOnly: true,
|
|
1421
1569
|
title: "List Article Attachments",
|
|
1422
|
-
description: "List all attachments for an article.",
|
|
1423
|
-
inputSchema: z.object({ article_id: z.number().int().describe("
|
|
1570
|
+
description: "List all attachments for an article. Returns attachment metadata only (id, file name, content type, size, URL), not the file bytes; both inline and block attachments are included. This is for Help Center articles — for attachments on support tickets use get_ticket_attachments instead. Upload new files with create_article_attachment.",
|
|
1571
|
+
inputSchema: z.object({ article_id: z.number().int().describe("ID of the Help Center article whose attachments to list.") }),
|
|
1424
1572
|
annotations: {
|
|
1425
1573
|
readOnlyHint: true,
|
|
1426
1574
|
destructiveHint: false,
|
|
@@ -1851,7 +1999,7 @@ const createTicketTools = (ctx) => {
|
|
|
1851
1999
|
namespace: "tickets",
|
|
1852
2000
|
readOnly: false,
|
|
1853
2001
|
title: "Create Zendesk Ticket",
|
|
1854
|
-
description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags.",
|
|
2002
|
+
description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags. The description becomes the first public comment of the ticket, and the new ticket id is returned. After creation, use update_ticket to change status or assignee, add_public_comment or add_private_note to reply, and manage_tags to adjust tags. Look up valid assignee_id / group_id and custom field ids via search_users or your Zendesk admin settings.",
|
|
1855
2003
|
inputSchema: z.object({
|
|
1856
2004
|
subject: z.string().min(1).describe("Ticket subject"),
|
|
1857
2005
|
description: z.string().min(1).describe("Ticket description"),
|
|
@@ -1860,20 +2008,20 @@ const createTicketTools = (ctx) => {
|
|
|
1860
2008
|
"high",
|
|
1861
2009
|
"normal",
|
|
1862
2010
|
"low"
|
|
1863
|
-
]).optional(),
|
|
2011
|
+
]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
|
|
1864
2012
|
type: z.enum([
|
|
1865
2013
|
"problem",
|
|
1866
2014
|
"incident",
|
|
1867
2015
|
"question",
|
|
1868
2016
|
"task"
|
|
1869
|
-
]).optional(),
|
|
1870
|
-
assignee_id: z.number().int().optional(),
|
|
1871
|
-
group_id: z.number().int().optional(),
|
|
1872
|
-
tags: z.array(z.string()).optional(),
|
|
2017
|
+
]).optional().describe("Ticket type. One of problem, incident, question, task."),
|
|
2018
|
+
assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
|
|
2019
|
+
group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
|
|
2020
|
+
tags: z.array(z.string()).optional().describe("Tags to set on the ticket."),
|
|
1873
2021
|
custom_fields: z.array(z.object({
|
|
1874
2022
|
id: z.number().int(),
|
|
1875
2023
|
value: z.unknown()
|
|
1876
|
-
})).optional()
|
|
2024
|
+
})).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
|
|
1877
2025
|
}),
|
|
1878
2026
|
annotations: {
|
|
1879
2027
|
readOnlyHint: false,
|
|
@@ -1899,7 +2047,7 @@ const createTicketTools = (ctx) => {
|
|
|
1899
2047
|
namespace: "tickets",
|
|
1900
2048
|
readOnly: false,
|
|
1901
2049
|
title: "Update Zendesk Ticket",
|
|
1902
|
-
description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields).",
|
|
2050
|
+
description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields). Only the fields you pass are changed, and the updated ticket is returned. Setting tags here replaces the whole tag set — use manage_tags to add or remove individual tags without overwriting the rest. This tool does not post replies: use add_public_comment or add_private_note for that. Find the ticket id via search_tickets or list_tickets.",
|
|
1903
2051
|
inputSchema: z.object({
|
|
1904
2052
|
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1905
2053
|
status: z.enum([
|
|
@@ -1909,27 +2057,27 @@ const createTicketTools = (ctx) => {
|
|
|
1909
2057
|
"hold",
|
|
1910
2058
|
"solved",
|
|
1911
2059
|
"closed"
|
|
1912
|
-
]).optional(),
|
|
2060
|
+
]).optional().describe("New ticket status. One of new, open, pending, hold, solved, closed."),
|
|
1913
2061
|
priority: z.enum([
|
|
1914
2062
|
"urgent",
|
|
1915
2063
|
"high",
|
|
1916
2064
|
"normal",
|
|
1917
2065
|
"low"
|
|
1918
|
-
]).optional(),
|
|
2066
|
+
]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
|
|
1919
2067
|
type: z.enum([
|
|
1920
2068
|
"problem",
|
|
1921
2069
|
"incident",
|
|
1922
2070
|
"question",
|
|
1923
2071
|
"task"
|
|
1924
|
-
]).optional(),
|
|
1925
|
-
assignee_id: z.number().int().optional(),
|
|
1926
|
-
group_id: z.number().int().optional(),
|
|
1927
|
-
subject: z.string().optional(),
|
|
1928
|
-
tags: z.array(z.string()).optional(),
|
|
2072
|
+
]).optional().describe("Ticket type. One of problem, incident, question, task."),
|
|
2073
|
+
assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
|
|
2074
|
+
group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
|
|
2075
|
+
subject: z.string().optional().describe("New ticket subject line."),
|
|
2076
|
+
tags: z.array(z.string()).optional().describe("Replaces the full tag set on the ticket. Use manage_tags for incremental add/remove."),
|
|
1929
2077
|
custom_fields: z.array(z.object({
|
|
1930
2078
|
id: z.number().int(),
|
|
1931
2079
|
value: z.unknown()
|
|
1932
|
-
})).optional()
|
|
2080
|
+
})).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
|
|
1933
2081
|
}),
|
|
1934
2082
|
annotations: {
|
|
1935
2083
|
readOnlyHint: false,
|
|
@@ -2190,10 +2338,10 @@ const createUserTools = (ctx) => {
|
|
|
2190
2338
|
namespace: "users",
|
|
2191
2339
|
readOnly: true,
|
|
2192
2340
|
title: "List Zendesk Organizations",
|
|
2193
|
-
description: "List all organizations with pagination.",
|
|
2341
|
+
description: "List all organizations with pagination. Returns the name and id of each organization plus basic fields; results are cursor-paginated. Use get_organization with an id for full details (tags, domains, notes), or search for query-based lookups by name. Organizations group end users and can be referenced when creating or filtering tickets.",
|
|
2194
2342
|
inputSchema: z.object({
|
|
2195
|
-
page_size: z.number().int().min(1).max(100).default(100),
|
|
2196
|
-
cursor: z.string().optional()
|
|
2343
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Organizations per page (1-100, default 100)."),
|
|
2344
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
2197
2345
|
}),
|
|
2198
2346
|
annotations: {
|
|
2199
2347
|
readOnlyHint: true,
|
|
@@ -2294,10 +2442,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
2294
2442
|
};
|
|
2295
2443
|
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
2296
2444
|
const pkg = readPackageInfo();
|
|
2445
|
+
const instructions = buildInstructions(config);
|
|
2297
2446
|
const server = new McpServer({
|
|
2298
2447
|
name: pkg.name,
|
|
2299
2448
|
version: pkg.version
|
|
2300
|
-
}, {
|
|
2449
|
+
}, {
|
|
2450
|
+
capabilities: { logging: {} },
|
|
2451
|
+
...instructions ? { instructions } : {}
|
|
2452
|
+
});
|
|
2301
2453
|
logger.attachServer(server);
|
|
2302
2454
|
const filteredTools = filterTools(createAllTools({
|
|
2303
2455
|
subdomain: config.subdomain,
|
|
@@ -2328,6 +2480,18 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
2328
2480
|
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
|
|
2329
2481
|
break;
|
|
2330
2482
|
}
|
|
2483
|
+
if (helpCenterContextEnabled(config)) {
|
|
2484
|
+
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
2485
|
+
server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
|
|
2486
|
+
title: "Zendesk Help Center topology",
|
|
2487
|
+
description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
|
|
2488
|
+
mimeType: "text/markdown"
|
|
2489
|
+
}, async (uri) => ({ contents: [{
|
|
2490
|
+
uri: uri.toString(),
|
|
2491
|
+
mimeType: "text/markdown",
|
|
2492
|
+
text: await topology.read()
|
|
2493
|
+
}] }));
|
|
2494
|
+
}
|
|
2331
2495
|
logger.info("tools_registered", {
|
|
2332
2496
|
count: filteredTools.length,
|
|
2333
2497
|
mode: config.mode
|