@fruggr/zendesk-mcp-server 2.0.1 → 2.2.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 +28 -2
- package/dist/index.js +297 -110
- 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`)
|
|
@@ -203,8 +224,11 @@ file per subdomain in your OS config dir —
|
|
|
203
224
|
elsewhere; override the path with `ZENDESK_TOKEN_FILE`). It is reused across restarts, so you don't
|
|
204
225
|
re-authenticate every time the MCP client respawns the server. If the Zendesk
|
|
205
226
|
OAuth client has token expiration enabled, the stored refresh token is used to
|
|
206
|
-
renew access silently
|
|
207
|
-
|
|
227
|
+
renew access silently — **proactively** (the token is refreshed before use when
|
|
228
|
+
it's expired, near expiry, or of unknown age, so the first request after an
|
|
229
|
+
overnight gap never hits a visible auth error) and **periodically** in the
|
|
230
|
+
background so a long-lived, idle session never serves a stale token. Only an
|
|
231
|
+
expired/invalid refresh token triggers a new browser sign-in.
|
|
208
232
|
|
|
209
233
|
> **Port conflict?** If port `27439` is already in use the first tool call returns
|
|
210
234
|
> a clear error telling you to set `ZENDESK_OAUTH_CALLBACK_PORT` (or
|
|
@@ -418,6 +442,8 @@ Options:
|
|
|
418
442
|
--namespace <ns> Filter by namespace (repeatable): tickets, help_center, users
|
|
419
443
|
--tool <name> Filter by tool name (repeatable, forces --mode all)
|
|
420
444
|
--read-only Only expose read operations
|
|
445
|
+
--no-topology Disable the Help Center structural context
|
|
446
|
+
(instructions + zendesk-hc://topology resource)
|
|
421
447
|
--log-level <level> debug | info (default) | warn | error
|
|
422
448
|
--transport <t> stdio (default) | http
|
|
423
449
|
--host <host> HTTP bind host (default: 0.0.0.0)
|
package/dist/index.js
CHANGED
|
@@ -221,7 +221,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
221
221
|
const tokenData = await tokenResponse.json();
|
|
222
222
|
logger.info("oauth_authenticated");
|
|
223
223
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
224
|
-
res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to
|
|
224
|
+
res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
|
|
225
225
|
clearTimeout(authTimeout);
|
|
226
226
|
callbackServer.close();
|
|
227
227
|
resolveToken(tokenData);
|
|
@@ -432,6 +432,7 @@ const clearToken = (path, logger = silentLogger) => {
|
|
|
432
432
|
//#endregion
|
|
433
433
|
//#region src/auth/token-store.ts
|
|
434
434
|
const EXPIRY_SKEW_MS = 6e4;
|
|
435
|
+
const SCHEDULED_REFRESH_MS = 14400 * 1e3;
|
|
435
436
|
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), {
|
|
436
437
|
name: "AuthRequiredError",
|
|
437
438
|
authorizeUrl
|
|
@@ -444,16 +445,18 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
444
445
|
let authorizeUrl;
|
|
445
446
|
let starting;
|
|
446
447
|
let refreshing;
|
|
448
|
+
let probedUnknownExpiry = false;
|
|
447
449
|
const persist = (t) => saveToken(tokenPath, t, logger);
|
|
448
450
|
const setToken = (accessToken, refreshToken) => {
|
|
449
451
|
token = {
|
|
450
452
|
accessToken,
|
|
451
453
|
refreshToken
|
|
452
454
|
};
|
|
455
|
+
probedUnknownExpiry = false;
|
|
453
456
|
persist(token);
|
|
454
457
|
};
|
|
455
|
-
const
|
|
456
|
-
const tryRefresh = async (current) => {
|
|
458
|
+
const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
|
|
459
|
+
const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
|
|
457
460
|
if (!current.refreshToken) return void 0;
|
|
458
461
|
try {
|
|
459
462
|
const result = await refreshAccessToken({
|
|
@@ -466,13 +469,16 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
466
469
|
refreshToken: result.refresh_token ?? current.refreshToken,
|
|
467
470
|
expiresAt: expiryFrom(result.expires_in)
|
|
468
471
|
};
|
|
472
|
+
probedUnknownExpiry = true;
|
|
469
473
|
persist(token);
|
|
470
474
|
logger.info("oauth_token_refreshed_cached");
|
|
471
475
|
return token.accessToken;
|
|
472
476
|
} catch (err) {
|
|
473
477
|
logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
474
|
-
|
|
475
|
-
|
|
478
|
+
if (dropOnFailure) {
|
|
479
|
+
token = void 0;
|
|
480
|
+
clearToken(tokenPath, logger);
|
|
481
|
+
}
|
|
476
482
|
return;
|
|
477
483
|
}
|
|
478
484
|
};
|
|
@@ -490,6 +496,7 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
490
496
|
refreshToken: result.refresh_token,
|
|
491
497
|
expiresAt: expiryFrom(result.expires_in)
|
|
492
498
|
};
|
|
499
|
+
probedUnknownExpiry = true;
|
|
493
500
|
persist(token);
|
|
494
501
|
logger.info("oauth_token_cached");
|
|
495
502
|
}).catch((err) => {
|
|
@@ -505,7 +512,8 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
505
512
|
});
|
|
506
513
|
};
|
|
507
514
|
const getToken = async () => {
|
|
508
|
-
if (
|
|
515
|
+
if (refreshing) await refreshing;
|
|
516
|
+
if (token && !needsRefresh(token)) {
|
|
509
517
|
logger.debug("oauth_token_cache_hit");
|
|
510
518
|
return token.accessToken;
|
|
511
519
|
}
|
|
@@ -533,10 +541,18 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
533
541
|
}
|
|
534
542
|
logger.info("oauth_token_invalidated");
|
|
535
543
|
};
|
|
544
|
+
const scheduledRefresh = setInterval(() => {
|
|
545
|
+
if (token?.refreshToken && !refreshing) refreshing = tryRefresh(token, { dropOnFailure: false }).finally(() => {
|
|
546
|
+
refreshing = void 0;
|
|
547
|
+
});
|
|
548
|
+
}, SCHEDULED_REFRESH_MS);
|
|
549
|
+
scheduledRefresh.unref?.();
|
|
550
|
+
const dispose = () => clearInterval(scheduledRefresh);
|
|
536
551
|
return {
|
|
537
552
|
getToken,
|
|
538
553
|
setToken,
|
|
539
|
-
invalidate
|
|
554
|
+
invalidate,
|
|
555
|
+
dispose
|
|
540
556
|
};
|
|
541
557
|
};
|
|
542
558
|
//#endregion
|
|
@@ -566,6 +582,14 @@ const ConfigSchema = z.object({
|
|
|
566
582
|
readOnly: z.boolean(),
|
|
567
583
|
namespaces: z.array(Namespace).optional(),
|
|
568
584
|
tools: z.array(z.string()).optional(),
|
|
585
|
+
/**
|
|
586
|
+
* Whether to expose the Help Center structural context (the `instructions`
|
|
587
|
+
* blob + the `zendesk-hc://topology` resource). On by default; an operator
|
|
588
|
+
* disables it server-wide with `--no-topology` (e.g. on a very large Help
|
|
589
|
+
* Center, or when the context is unwanted). Only ever active when the
|
|
590
|
+
* `help_center` namespace itself is active.
|
|
591
|
+
*/
|
|
592
|
+
topology: z.boolean().default(true),
|
|
569
593
|
transport: Transport,
|
|
570
594
|
host: z.string().min(1),
|
|
571
595
|
port: z.number().int().min(0).max(65535),
|
|
@@ -596,6 +620,7 @@ const parseCliArgs = (args) => {
|
|
|
596
620
|
result.mode = next;
|
|
597
621
|
i++;
|
|
598
622
|
} else if (arg === "--read-only") result.readOnly = true;
|
|
623
|
+
else if (arg === "--no-topology") result.topology = false;
|
|
599
624
|
else if (arg === "--namespace" && next) {
|
|
600
625
|
result.namespaces = result.namespaces ?? [];
|
|
601
626
|
result.namespaces.push(next);
|
|
@@ -653,6 +678,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
653
678
|
readOnly: cli.readOnly ?? false,
|
|
654
679
|
namespaces: cli.namespaces,
|
|
655
680
|
tools: cli.tools,
|
|
681
|
+
topology: cli.topology ?? true,
|
|
656
682
|
transport,
|
|
657
683
|
host,
|
|
658
684
|
port,
|
|
@@ -771,6 +797,246 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
771
797
|
return response.json();
|
|
772
798
|
};
|
|
773
799
|
//#endregion
|
|
800
|
+
//#region src/guidance/instructions.ts
|
|
801
|
+
/** Stable URI of the dynamic Help Center topology resource. */
|
|
802
|
+
const TOPOLOGY_RESOURCE_URI = "zendesk-hc://topology";
|
|
803
|
+
/**
|
|
804
|
+
* Whether the Help Center structural context (init instructions + the
|
|
805
|
+
* `zendesk-hc://topology` resource) should be exposed. True only when the
|
|
806
|
+
* feature is enabled (`--no-topology` not set) AND the `help_center` namespace
|
|
807
|
+
* is active (no `--namespace` filter, or one that includes it). Shared by the
|
|
808
|
+
* instructions builder and the resource registration in `server.ts` so both
|
|
809
|
+
* gates stay in sync.
|
|
810
|
+
*/
|
|
811
|
+
const helpCenterContextEnabled = (config) => config.topology && (!config.namespaces?.length || config.namespaces.includes("help_center"));
|
|
812
|
+
/**
|
|
813
|
+
* The static `instructions` blob sent on `initialize`. Deliberately short and
|
|
814
|
+
* I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
|
|
815
|
+
* it stays within a tight token budget. The rich, dynamic topology lives in the
|
|
816
|
+
* pull-only `zendesk-hc://topology` resource referenced here.
|
|
817
|
+
*/
|
|
818
|
+
const buildInstructions = (config) => {
|
|
819
|
+
if (!helpCenterContextEnabled(config)) return void 0;
|
|
820
|
+
return [
|
|
821
|
+
`This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
|
|
822
|
+
"",
|
|
823
|
+
`Before creating or editing Help Center content, read the resource ${TOPOLOGY_RESOURCE_URI}.`,
|
|
824
|
+
"It describes the active locales (and the default one), the category → section tree with IDs,",
|
|
825
|
+
"the visibility user segments, the permission groups, and your current role.",
|
|
826
|
+
"Prefer the IDs from that resource (section_id, permission_group_id, user_segment_id, locale)",
|
|
827
|
+
"over guessing from names."
|
|
828
|
+
].join("\n");
|
|
829
|
+
};
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/utils/formatting.ts
|
|
832
|
+
const truncateIfNeeded = (text) => {
|
|
833
|
+
if (text.length <= 25e3) return text;
|
|
834
|
+
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
|
|
835
|
+
};
|
|
836
|
+
const formatPagination = (meta) => {
|
|
837
|
+
const parts = [`Results: ${meta.count}`];
|
|
838
|
+
if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
|
|
839
|
+
return parts.join(" | ");
|
|
840
|
+
};
|
|
841
|
+
const formatTicket = (ticket) => [
|
|
842
|
+
`## Ticket #${ticket.id}: ${ticket.subject}`,
|
|
843
|
+
`- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
|
|
844
|
+
`- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
|
|
845
|
+
`- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
|
|
846
|
+
`- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
|
|
847
|
+
ticket.description ? `\n${ticket.description}` : ""
|
|
848
|
+
].filter(Boolean).join("\n");
|
|
849
|
+
const formatComment = (comment) => {
|
|
850
|
+
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
|
|
851
|
+
if (comment.attachments?.length) {
|
|
852
|
+
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
853
|
+
lines.push(`Attachments: ${summary}`);
|
|
854
|
+
}
|
|
855
|
+
lines.push("", comment.body);
|
|
856
|
+
return lines.join("\n");
|
|
857
|
+
};
|
|
858
|
+
const formatUser = (user) => [
|
|
859
|
+
`## ${user.name} (${user.id})`,
|
|
860
|
+
`- **Email**: ${user.email}`,
|
|
861
|
+
`- **Role**: ${user.role}`,
|
|
862
|
+
user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
|
|
863
|
+
`- **Active**: ${user.active}`,
|
|
864
|
+
user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
|
|
865
|
+
].filter(Boolean).join("\n");
|
|
866
|
+
const formatOrganization = (org) => [
|
|
867
|
+
`## ${org.name} (${org.id})`,
|
|
868
|
+
org.details ? `- **Details**: ${org.details}` : "",
|
|
869
|
+
org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
|
|
870
|
+
org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
|
|
871
|
+
].filter(Boolean).join("\n");
|
|
872
|
+
const formatArticleSummary = (article) => [
|
|
873
|
+
`## ${article.title} (${article.id})`,
|
|
874
|
+
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
875
|
+
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
876
|
+
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
877
|
+
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
878
|
+
`- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
|
|
879
|
+
].filter(Boolean).join("\n");
|
|
880
|
+
const formatArticle = (article) => [
|
|
881
|
+
formatArticleSummary(article),
|
|
882
|
+
"",
|
|
883
|
+
article.body
|
|
884
|
+
].join("\n");
|
|
885
|
+
const formatTranslationSummary = (translation) => [
|
|
886
|
+
`## Translation: ${translation.locale} (${translation.id})`,
|
|
887
|
+
`- **Title**: ${translation.title}`,
|
|
888
|
+
`- **Draft**: ${translation.draft}`,
|
|
889
|
+
`- **Updated**: ${translation.updated_at}`
|
|
890
|
+
].join("\n");
|
|
891
|
+
const formatTranslation = (translation) => [
|
|
892
|
+
formatTranslationSummary(translation),
|
|
893
|
+
"",
|
|
894
|
+
translation.body
|
|
895
|
+
].join("\n");
|
|
896
|
+
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
897
|
+
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
898
|
+
const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
|
|
899
|
+
const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
900
|
+
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
901
|
+
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
902
|
+
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
903
|
+
const formatList = (items, formatter, meta) => {
|
|
904
|
+
return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
|
|
905
|
+
};
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/utils/pagination.ts
|
|
908
|
+
const buildCursorParams = (pageSize, cursor) => {
|
|
909
|
+
const params = { "page[size]": String(pageSize) };
|
|
910
|
+
if (cursor) params["page[after]"] = cursor;
|
|
911
|
+
return params;
|
|
912
|
+
};
|
|
913
|
+
const buildOffsetParams = (perPage, page) => {
|
|
914
|
+
const params = { per_page: String(perPage) };
|
|
915
|
+
if (page && page > 1) params["page"] = String(page);
|
|
916
|
+
return params;
|
|
917
|
+
};
|
|
918
|
+
const extractPaginationMeta = (response) => ({
|
|
919
|
+
has_more: response.meta?.has_more ?? response.next_page != null,
|
|
920
|
+
after_cursor: response.meta?.after_cursor ?? null,
|
|
921
|
+
count: response.count ?? 0
|
|
922
|
+
});
|
|
923
|
+
const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
924
|
+
const count = response.count ?? 0;
|
|
925
|
+
const has_more = count > page * perPage;
|
|
926
|
+
return {
|
|
927
|
+
has_more,
|
|
928
|
+
after_cursor: has_more ? String(page + 1) : null,
|
|
929
|
+
count
|
|
930
|
+
};
|
|
931
|
+
};
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/guidance/topology.ts
|
|
934
|
+
/**
|
|
935
|
+
* Fetch the structural topology with the CALLER'S token, so the result respects
|
|
936
|
+
* that user's read permissions (no privileged shared credential). Categories
|
|
937
|
+
* and sections are each capped at one max-size page; `sectionsHasMore` /
|
|
938
|
+
* `categoriesHasMore` signal a Help Center too large to enumerate inline.
|
|
939
|
+
*/
|
|
940
|
+
const fetchTopology = async (subdomain, token) => {
|
|
941
|
+
const pageParams = { "page[size]": String(100) };
|
|
942
|
+
const [locales, categoriesRes, sectionsRes, segmentsRes, permsRes, meRes] = await Promise.all([
|
|
943
|
+
helpCenterGet(subdomain, token, "/locales"),
|
|
944
|
+
helpCenterGet(subdomain, token, "/categories", pageParams),
|
|
945
|
+
helpCenterGet(subdomain, token, "/sections", pageParams),
|
|
946
|
+
helpCenterGet(subdomain, token, "/user_segments"),
|
|
947
|
+
zendeskGet(subdomain, token, "/guide/permission_groups"),
|
|
948
|
+
zendeskGet(subdomain, token, "/users/me")
|
|
949
|
+
]);
|
|
950
|
+
return {
|
|
951
|
+
subdomain,
|
|
952
|
+
locales,
|
|
953
|
+
categories: categoriesRes.categories ?? [],
|
|
954
|
+
sections: sectionsRes.sections ?? [],
|
|
955
|
+
sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
|
|
956
|
+
categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
|
|
957
|
+
userSegments: segmentsRes.user_segments ?? [],
|
|
958
|
+
permissionGroups: permsRes.permission_groups ?? [],
|
|
959
|
+
currentUser: meRes.user
|
|
960
|
+
};
|
|
961
|
+
};
|
|
962
|
+
const renderTree = (data) => {
|
|
963
|
+
if (data.categoriesHasMore || data.sectionsHasMore) {
|
|
964
|
+
const reasons = [];
|
|
965
|
+
if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
|
|
966
|
+
if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
|
|
967
|
+
return [
|
|
968
|
+
`Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
|
|
969
|
+
data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
|
|
970
|
+
...data.categories.map(formatCategory),
|
|
971
|
+
"",
|
|
972
|
+
...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
|
|
973
|
+
"Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
|
|
974
|
+
];
|
|
975
|
+
}
|
|
976
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
977
|
+
for (const section of data.sections) {
|
|
978
|
+
const list = byCategory.get(section.category_id) ?? [];
|
|
979
|
+
list.push(section);
|
|
980
|
+
byCategory.set(section.category_id, list);
|
|
981
|
+
}
|
|
982
|
+
const lines = [];
|
|
983
|
+
for (const category of data.categories) {
|
|
984
|
+
lines.push(formatCategory(category));
|
|
985
|
+
for (const section of byCategory.get(category.id) ?? []) lines.push(` ${formatSection(section)}`);
|
|
986
|
+
}
|
|
987
|
+
return lines.length ? lines : ["_(no categories)_"];
|
|
988
|
+
};
|
|
989
|
+
/** Render the topology as a compact Markdown document for the LLM context. */
|
|
990
|
+
const formatTopology = (data) => {
|
|
991
|
+
return truncateIfNeeded([
|
|
992
|
+
`# Zendesk Help Center topology — ${data.subdomain}`,
|
|
993
|
+
"",
|
|
994
|
+
`**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
|
|
995
|
+
"",
|
|
996
|
+
"## Locales",
|
|
997
|
+
`- Default: ${data.locales.default_locale}`,
|
|
998
|
+
`- Active: ${data.locales.locales.join(", ")}`,
|
|
999
|
+
"",
|
|
1000
|
+
"## Categories → sections",
|
|
1001
|
+
...renderTree(data),
|
|
1002
|
+
"",
|
|
1003
|
+
"## Visibility (user segments)",
|
|
1004
|
+
...data.userSegments.length ? data.userSegments.map(formatUserSegment) : ["_(none)_"],
|
|
1005
|
+
"",
|
|
1006
|
+
"## Permission groups",
|
|
1007
|
+
...data.permissionGroups.length ? data.permissionGroups.map(formatPermissionGroup) : ["_(none)_"]
|
|
1008
|
+
].join("\n"));
|
|
1009
|
+
};
|
|
1010
|
+
/**
|
|
1011
|
+
* Build a topology provider holding a memoized-promise cache (TTL
|
|
1012
|
+
* `TOPOLOGY_TTL_MS`). In HTTP mode `createMcpServer` — and therefore this
|
|
1013
|
+
* provider — is instantiated PER SESSION, so this cache is per-session /
|
|
1014
|
+
* per-caller: it must NOT be hoisted to module scope, or one tenant's structure
|
|
1015
|
+
* would leak to another. The promise (not the value) is cached to coalesce
|
|
1016
|
+
* concurrent reads; failures are evicted so the next read retries with a fresh
|
|
1017
|
+
* token. A 401 notifies `onUnauthorized` (stdio OAuth only) to invalidate the
|
|
1018
|
+
* stale token, mirroring the tool dispatch path in `server.ts`.
|
|
1019
|
+
*/
|
|
1020
|
+
const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
|
|
1021
|
+
let cached;
|
|
1022
|
+
return { read() {
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
if (cached && now - cached.at < 3e5) return cached.promise;
|
|
1025
|
+
const promise = (async () => {
|
|
1026
|
+
return formatTopology(await fetchTopology(subdomain, await getToken()));
|
|
1027
|
+
})().catch((err) => {
|
|
1028
|
+
cached = void 0;
|
|
1029
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
1030
|
+
throw err;
|
|
1031
|
+
});
|
|
1032
|
+
cached = {
|
|
1033
|
+
at: now,
|
|
1034
|
+
promise
|
|
1035
|
+
};
|
|
1036
|
+
return promise;
|
|
1037
|
+
} };
|
|
1038
|
+
};
|
|
1039
|
+
//#endregion
|
|
774
1040
|
//#region src/routing/registry.ts
|
|
775
1041
|
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
776
1042
|
if (options.readOnly && !tool.readOnly) return false;
|
|
@@ -883,108 +1149,6 @@ const markdownToHtml = (markdown) => {
|
|
|
883
1149
|
return String(mdToHtmlProcessor.processSync(markdown));
|
|
884
1150
|
};
|
|
885
1151
|
//#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
1152
|
//#region src/tools/help-center.ts
|
|
989
1153
|
const largeArticleHint = (body, sectionCount) => {
|
|
990
1154
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
@@ -2228,6 +2392,13 @@ const createAllTools = (ctx) => [
|
|
|
2228
2392
|
* refreshes/re-authenticates instead of replaying a revoked token. The callback
|
|
2229
2393
|
* is omitted only where there is nothing to invalidate (e.g. HTTP per-session
|
|
2230
2394
|
* bearer, owned by the client).
|
|
2395
|
+
*
|
|
2396
|
+
* Client-visible behaviour on an in-flight revocation: the 401 is a *backstop*,
|
|
2397
|
+
* not a transparent retry. The current call still surfaces the error; recovery
|
|
2398
|
+
* happens on the *next* call, whose `getToken` sees the invalidated token and
|
|
2399
|
+
* silently refreshes (or falls back to browser re-auth if the refresh token is
|
|
2400
|
+
* also dead). Proactive refresh keeps this path rare — it only fires when a
|
|
2401
|
+
* token is revoked between the pre-call refresh check and the request.
|
|
2231
2402
|
*/
|
|
2232
2403
|
const runHandler = async (def, params, onUnauthorized) => {
|
|
2233
2404
|
try {
|
|
@@ -2294,10 +2465,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
2294
2465
|
};
|
|
2295
2466
|
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
2296
2467
|
const pkg = readPackageInfo();
|
|
2468
|
+
const instructions = buildInstructions(config);
|
|
2297
2469
|
const server = new McpServer({
|
|
2298
2470
|
name: pkg.name,
|
|
2299
2471
|
version: pkg.version
|
|
2300
|
-
}, {
|
|
2472
|
+
}, {
|
|
2473
|
+
capabilities: { logging: {} },
|
|
2474
|
+
...instructions ? { instructions } : {}
|
|
2475
|
+
});
|
|
2301
2476
|
logger.attachServer(server);
|
|
2302
2477
|
const filteredTools = filterTools(createAllTools({
|
|
2303
2478
|
subdomain: config.subdomain,
|
|
@@ -2328,6 +2503,18 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
2328
2503
|
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
|
|
2329
2504
|
break;
|
|
2330
2505
|
}
|
|
2506
|
+
if (helpCenterContextEnabled(config)) {
|
|
2507
|
+
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
2508
|
+
server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
|
|
2509
|
+
title: "Zendesk Help Center topology",
|
|
2510
|
+
description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
|
|
2511
|
+
mimeType: "text/markdown"
|
|
2512
|
+
}, async (uri) => ({ contents: [{
|
|
2513
|
+
uri: uri.toString(),
|
|
2514
|
+
mimeType: "text/markdown",
|
|
2515
|
+
text: await topology.read()
|
|
2516
|
+
}] }));
|
|
2517
|
+
}
|
|
2331
2518
|
logger.info("tools_registered", {
|
|
2332
2519
|
count: filteredTools.length,
|
|
2333
2520
|
mode: config.mode
|