@fruggr/zendesk-mcp-server 2.0.1 → 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 +267 -103
- 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;
|
|
@@ -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
|