@fruggr/zendesk-mcp-server 1.0.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/dist/index.js ADDED
@@ -0,0 +1,2092 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/auth/api-token.ts
4
+ var buildBasicAuthHeader = (email, apiToken) => {
5
+ const credentials = `${email}/token:${apiToken}`;
6
+ return `Basic ${Buffer.from(credentials).toString("base64")}`;
7
+ };
8
+
9
+ // src/auth/browser-oauth.ts
10
+ import { createHash, randomBytes } from "crypto";
11
+ import { createServer } from "http";
12
+ import open from "open";
13
+
14
+ // src/constants.ts
15
+ var CHARACTER_LIMIT = 25e3;
16
+ var DEFAULT_PAGE_SIZE = 100;
17
+ var MAX_PAGE_SIZE = 100;
18
+ var TOKEN_CACHE_TTL_MS = 5 * 60 * 1e3;
19
+ var LARGE_ARTICLE_BODY_CHARS = 3e3;
20
+ var LARGE_ARTICLE_SECTION_COUNT = 4;
21
+ var getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
22
+ var getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
23
+ var getOAuthUrls = (subdomain) => ({
24
+ authorizeUrl: `https://${subdomain}.zendesk.com/oauth/authorizations/new`,
25
+ tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
26
+ });
27
+
28
+ // src/auth/browser-oauth.ts
29
+ var DEFAULT_CALLBACK_PORT = 3e3;
30
+ var generateCodeVerifier = () => randomBytes(32).toString("base64url");
31
+ var generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
32
+ var authenticateViaBrowser = (config) => {
33
+ const { subdomain, oauthClientId } = config;
34
+ const { authorizeUrl, tokenUrl } = getOAuthUrls(subdomain);
35
+ const codeVerifier = generateCodeVerifier();
36
+ const codeChallenge = generateCodeChallenge(codeVerifier);
37
+ return new Promise((resolve, reject) => {
38
+ let callbackServer;
39
+ callbackServer = createServer(async (req, res) => {
40
+ const url = new URL(req.url ?? "/", `http://localhost`);
41
+ if (url.pathname !== "/callback") {
42
+ res.writeHead(404);
43
+ res.end("Not found");
44
+ return;
45
+ }
46
+ const code = url.searchParams.get("code");
47
+ const error = url.searchParams.get("error");
48
+ if (error) {
49
+ const desc = url.searchParams.get("error_description") ?? error;
50
+ res.writeHead(400, { "Content-Type": "text/html" });
51
+ res.end(`<html><body><h1>Authentication failed</h1><p>${desc}</p></body></html>`);
52
+ callbackServer.close();
53
+ reject(new Error(`OAuth error: ${desc}`));
54
+ return;
55
+ }
56
+ if (!code) {
57
+ res.writeHead(400, { "Content-Type": "text/html" });
58
+ res.end("<html><body><h1>Missing authorization code</h1></body></html>");
59
+ callbackServer.close();
60
+ reject(new Error("Missing authorization code in callback"));
61
+ return;
62
+ }
63
+ try {
64
+ const callbackPort = callbackServer.address().port;
65
+ const tokenBody = new URLSearchParams({
66
+ grant_type: "authorization_code",
67
+ code,
68
+ client_id: oauthClientId,
69
+ redirect_uri: `http://localhost:${callbackPort}/callback`,
70
+ code_verifier: codeVerifier
71
+ });
72
+ const tokenResponse = await fetch(tokenUrl, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
75
+ body: tokenBody.toString()
76
+ });
77
+ if (!tokenResponse.ok) {
78
+ const errorBody = await tokenResponse.text();
79
+ throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
80
+ }
81
+ const tokenData = await tokenResponse.json();
82
+ res.writeHead(200, { "Content-Type": "text/html" });
83
+ res.end(
84
+ "<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><script>window.close()</script></body></html>"
85
+ );
86
+ callbackServer.close();
87
+ resolve(tokenData);
88
+ } catch (err) {
89
+ res.writeHead(500, { "Content-Type": "text/html" });
90
+ res.end(
91
+ `<html><body><h1>Token exchange failed</h1><p>${err instanceof Error ? err.message : String(err)}</p></body></html>`
92
+ );
93
+ callbackServer.close();
94
+ reject(err);
95
+ }
96
+ });
97
+ callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
98
+ const port = callbackServer.address().port;
99
+ const redirectUri = `http://localhost:${port}/callback`;
100
+ const params = new URLSearchParams({
101
+ response_type: "code",
102
+ client_id: oauthClientId,
103
+ redirect_uri: redirectUri,
104
+ scope: "read write",
105
+ code_challenge: codeChallenge,
106
+ code_challenge_method: "S256"
107
+ });
108
+ const authUrl = `${authorizeUrl}?${params.toString()}`;
109
+ console.error(`Opening browser for Zendesk authentication...`);
110
+ console.error(`If the browser doesn't open, visit: ${authUrl}`);
111
+ open(authUrl).catch(() => {
112
+ });
113
+ });
114
+ setTimeout(
115
+ () => {
116
+ callbackServer.close();
117
+ reject(new Error("OAuth authentication timed out (5 min). Please try again."));
118
+ },
119
+ 5 * 60 * 1e3
120
+ ).unref();
121
+ });
122
+ };
123
+
124
+ // src/auth/token-store.ts
125
+ var createTokenStore = (config) => {
126
+ let token;
127
+ let authPromise;
128
+ const setToken = (accessToken, refreshToken) => {
129
+ token = { accessToken, refreshToken };
130
+ };
131
+ const ensureToken = async () => {
132
+ if (token) return token;
133
+ if (!authPromise) {
134
+ authPromise = authenticateViaBrowser({
135
+ subdomain: config.subdomain,
136
+ oauthClientId: config.oauthClientId
137
+ }).then((result) => {
138
+ const stored = {
139
+ accessToken: result.access_token,
140
+ refreshToken: result.refresh_token
141
+ };
142
+ token = stored;
143
+ authPromise = void 0;
144
+ return stored;
145
+ }).catch((err) => {
146
+ authPromise = void 0;
147
+ throw err;
148
+ });
149
+ }
150
+ return authPromise;
151
+ };
152
+ const getToken = async () => {
153
+ const stored = await ensureToken();
154
+ return stored.accessToken;
155
+ };
156
+ return { getToken, setToken };
157
+ };
158
+
159
+ // src/config.ts
160
+ import * as z from "zod/v4";
161
+ var ToolMode = z.enum(["single", "namespace", "all"]);
162
+ var LogLevel = z.enum(["debug", "info", "warn", "error"]);
163
+ var Namespace = z.enum(["tickets", "help_center", "users"]);
164
+ var ConfigSchema = z.object({
165
+ subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
166
+ oauthClientId: z.string().min(1),
167
+ zendeskEmail: z.string().optional(),
168
+ zendeskApiToken: z.string().optional(),
169
+ logLevel: LogLevel,
170
+ mode: ToolMode,
171
+ readOnly: z.boolean(),
172
+ namespaces: z.array(Namespace).optional(),
173
+ tools: z.array(z.string()).optional()
174
+ });
175
+ var parseCliArgs = (args) => {
176
+ const result = {};
177
+ let positionalIndex = 0;
178
+ for (let i = 0; i < args.length; i++) {
179
+ const arg = args[i];
180
+ if (arg === void 0) continue;
181
+ const next = args[i + 1];
182
+ if (arg === "--mode" && next) {
183
+ result.mode = next;
184
+ i++;
185
+ } else if (arg === "--read-only") {
186
+ result.readOnly = true;
187
+ } else if (arg === "--namespace" && next) {
188
+ result.namespaces = result.namespaces ?? [];
189
+ result.namespaces.push(next);
190
+ i++;
191
+ } else if (arg === "--tool" && next) {
192
+ result.tools = result.tools ?? [];
193
+ result.tools.push(next);
194
+ i++;
195
+ } else if (arg === "--log-level" && next) {
196
+ result.logLevel = next;
197
+ i++;
198
+ } else if (!arg.startsWith("-") && positionalIndex === 0) {
199
+ result.subdomain = arg;
200
+ positionalIndex++;
201
+ }
202
+ }
203
+ return result;
204
+ };
205
+ var loadConfig = (argv = process.argv.slice(2)) => {
206
+ const cli = parseCliArgs(argv);
207
+ const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
208
+ const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
209
+ const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
210
+ return ConfigSchema.parse({
211
+ subdomain,
212
+ oauthClientId,
213
+ zendeskEmail: process.env["ZENDESK_EMAIL"],
214
+ zendeskApiToken: process.env["ZENDESK_API_TOKEN"],
215
+ logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
216
+ mode,
217
+ readOnly: cli.readOnly ?? false,
218
+ namespaces: cli.namespaces,
219
+ tools: cli.tools
220
+ });
221
+ };
222
+
223
+ // src/server.ts
224
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
225
+ import * as z6 from "zod/v4";
226
+
227
+ // src/routing/registry.ts
228
+ var filterTools = (allTools, options) => allTools.filter((tool) => {
229
+ if (options.readOnly && !tool.readOnly) return false;
230
+ if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
231
+ if (options.tools?.length && !options.tools.includes(tool.name)) return false;
232
+ return true;
233
+ });
234
+ var groupByNamespace = (tools) => {
235
+ const grouped = /* @__PURE__ */ new Map();
236
+ for (const tool of tools) {
237
+ const existing = grouped.get(tool.namespace) ?? [];
238
+ existing.push(tool);
239
+ grouped.set(tool.namespace, existing);
240
+ }
241
+ return grouped;
242
+ };
243
+
244
+ // src/tools/help-center.ts
245
+ import * as z2 from "zod/v4";
246
+
247
+ // src/client/zendesk-api.ts
248
+ var ZendeskApiError = class _ZendeskApiError extends Error {
249
+ constructor(status, statusText, body) {
250
+ super(_ZendeskApiError.buildMessage(status, statusText, body));
251
+ this.status = status;
252
+ this.statusText = statusText;
253
+ this.body = body;
254
+ this.name = "ZendeskApiError";
255
+ }
256
+ status;
257
+ statusText;
258
+ body;
259
+ static buildMessage(status, statusText, body) {
260
+ switch (status) {
261
+ case 401:
262
+ return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
263
+ case 403:
264
+ return "Permission denied. Your Zendesk account does not have access to this resource.";
265
+ case 404:
266
+ return `Resource not found. Please verify the ID is correct. (${statusText})`;
267
+ case 422:
268
+ return `Validation error: ${body}`;
269
+ case 429:
270
+ return "Rate limit exceeded. Please wait before making more requests.";
271
+ default:
272
+ return `Zendesk API error ${status}: ${statusText}. ${body}`;
273
+ }
274
+ }
275
+ };
276
+ var buildUrl = (base, path, params) => {
277
+ const url = new URL(`${base}${path}`);
278
+ if (params) {
279
+ for (const [key, value] of Object.entries(params)) {
280
+ url.searchParams.set(key, value);
281
+ }
282
+ }
283
+ return url.toString();
284
+ };
285
+ var executeRequest = async (url, token, options = {}) => {
286
+ const { method = "GET", body } = options;
287
+ const authorization = token.startsWith("Basic ") ? token : `Bearer ${token}`;
288
+ const headers = {
289
+ Authorization: authorization,
290
+ Accept: "application/json"
291
+ };
292
+ if (body) {
293
+ headers["Content-Type"] = "application/json";
294
+ }
295
+ const init = { method, headers };
296
+ if (body) {
297
+ init.body = JSON.stringify(body);
298
+ }
299
+ const response = await fetch(url, init);
300
+ if (!response.ok) {
301
+ const responseBody = await response.text();
302
+ throw new ZendeskApiError(response.status, response.statusText, responseBody);
303
+ }
304
+ if (response.status === 204) {
305
+ return {};
306
+ }
307
+ return response.json();
308
+ };
309
+ var zendeskGet = (subdomain, token, path, params) => {
310
+ const url = buildUrl(getBaseUrl(subdomain), path, params);
311
+ return executeRequest(url, token);
312
+ };
313
+ var zendeskPost = (subdomain, token, path, body) => {
314
+ const url = buildUrl(getBaseUrl(subdomain), path);
315
+ return executeRequest(url, token, { method: "POST", body });
316
+ };
317
+ var zendeskPut = (subdomain, token, path, body) => {
318
+ const url = buildUrl(getBaseUrl(subdomain), path);
319
+ return executeRequest(url, token, { method: "PUT", body });
320
+ };
321
+ var helpCenterGet = (subdomain, token, path, params) => {
322
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path, params);
323
+ return executeRequest(url, token);
324
+ };
325
+ var helpCenterPost = (subdomain, token, path, body) => {
326
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
327
+ return executeRequest(url, token, { method: "POST", body });
328
+ };
329
+ var helpCenterPut = (subdomain, token, path, body) => {
330
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
331
+ return executeRequest(url, token, { method: "PUT", body });
332
+ };
333
+ var helpCenterUpload = async (subdomain, token, path, formData) => {
334
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
335
+ const authorization = token.startsWith("Basic ") ? token : `Bearer ${token}`;
336
+ const response = await fetch(url, {
337
+ method: "POST",
338
+ headers: { Authorization: authorization },
339
+ body: formData
340
+ });
341
+ if (!response.ok) {
342
+ const responseBody = await response.text();
343
+ throw new ZendeskApiError(response.status, response.statusText, responseBody);
344
+ }
345
+ return response.json();
346
+ };
347
+
348
+ // src/utils/article-sections.ts
349
+ import * as cheerio from "cheerio";
350
+ import { toHtml } from "hast-util-to-html";
351
+ import rehypeParse from "rehype-parse";
352
+ import rehypeRaw from "rehype-raw";
353
+ import rehypeRemark from "rehype-remark";
354
+ import rehypeStringify from "rehype-stringify";
355
+ import remarkGfm from "remark-gfm";
356
+ import remarkParse from "remark-parse";
357
+ import remarkRehype from "remark-rehype";
358
+ import remarkStringify from "remark-stringify";
359
+ import { unified } from "unified";
360
+ var HEADING_LEVELS = /* @__PURE__ */ new Set(["h1", "h2", "h3"]);
361
+ var countWords = (text) => {
362
+ const trimmed = text.trim();
363
+ if (!trimmed) return 0;
364
+ return trimmed.split(/\s+/).length;
365
+ };
366
+ var textOf = (html) => {
367
+ if (!html) return "";
368
+ const $ = cheerio.load(`<div>${html}</div>`, null, false);
369
+ return $("div").first().text();
370
+ };
371
+ var parseSections = (html) => {
372
+ if (!html || !html.trim()) return [];
373
+ const $ = cheerio.load(html, null, false);
374
+ const children = $.root().contents().toArray();
375
+ const introParts = [];
376
+ const sections = [];
377
+ let current = null;
378
+ for (const node of children) {
379
+ const tagName = node.type === "tag" ? node.name.toLowerCase() : "";
380
+ if (HEADING_LEVELS.has(tagName)) {
381
+ const level = Number.parseInt(tagName.slice(1), 10);
382
+ current = {
383
+ heading: $(node).text().trim(),
384
+ headingTag: tagName,
385
+ level,
386
+ contentParts: []
387
+ };
388
+ sections.push(current);
389
+ continue;
390
+ }
391
+ const outer = $.html(node);
392
+ if (current) {
393
+ current.contentParts.push(outer);
394
+ } else {
395
+ introParts.push(outer);
396
+ }
397
+ }
398
+ const result = [];
399
+ if (introParts.length > 0) {
400
+ const introHtml = introParts.join("");
401
+ result.push({
402
+ index: 0,
403
+ heading: "intro",
404
+ headingTag: "",
405
+ level: 0,
406
+ html: introHtml,
407
+ wordCount: countWords(textOf(introHtml))
408
+ });
409
+ }
410
+ for (const s of sections) {
411
+ const sectionHtml = s.contentParts.join("");
412
+ result.push({
413
+ index: result.length,
414
+ heading: s.heading,
415
+ headingTag: s.headingTag,
416
+ level: s.level,
417
+ html: sectionHtml,
418
+ wordCount: countWords(textOf(sectionHtml))
419
+ });
420
+ }
421
+ return result;
422
+ };
423
+ var replaceSectionContent = (html, sectionIndex, newHtml) => {
424
+ const sections = parseSections(html);
425
+ if (sectionIndex < 0 || sectionIndex >= sections.length) {
426
+ throw new Error(
427
+ `Section index ${sectionIndex} out of range (valid: 0-${Math.max(0, sections.length - 1)})`
428
+ );
429
+ }
430
+ return sections.map((section, idx) => {
431
+ const content = idx === sectionIndex ? newHtml : section.html;
432
+ if (section.level === 0) return content;
433
+ return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
434
+ }).join("");
435
+ };
436
+ var keepAsHtml = (_state, node) => ({
437
+ type: "html",
438
+ value: toHtml(node)
439
+ });
440
+ var htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(rehypeRemark, { handlers: { table: keepAsHtml, pre: keepAsHtml } }).use(remarkGfm).use(remarkStringify, { bullet: "-", emphasis: "_", fences: true });
441
+ var mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
442
+ var htmlToMarkdown = (html) => {
443
+ if (!html) return "";
444
+ return String(htmlToMdProcessor.processSync(html));
445
+ };
446
+ var markdownToHtml = (markdown) => {
447
+ if (!markdown) return "";
448
+ return String(mdToHtmlProcessor.processSync(markdown));
449
+ };
450
+
451
+ // src/utils/formatting.ts
452
+ var truncateIfNeeded = (text) => {
453
+ if (text.length <= CHARACTER_LIMIT) return text;
454
+ return `${text.slice(0, CHARACTER_LIMIT)}
455
+
456
+ --- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
457
+ };
458
+ var formatPagination = (meta) => {
459
+ const parts = [`Results: ${meta.count}`];
460
+ if (meta.has_more) {
461
+ parts.push(`More available (cursor: ${meta.after_cursor})`);
462
+ }
463
+ return parts.join(" | ");
464
+ };
465
+ var formatTicket = (ticket) => [
466
+ `## Ticket #${ticket.id}: ${ticket.subject}`,
467
+ `- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
468
+ `- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
469
+ `- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
470
+ `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
471
+ ticket.description ? `
472
+ ${ticket.description}` : ""
473
+ ].filter(Boolean).join("\n");
474
+ var formatComment = (comment) => [
475
+ `### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`,
476
+ `*${comment.created_at}*`,
477
+ "",
478
+ comment.body
479
+ ].join("\n");
480
+ var formatUser = (user) => [
481
+ `## ${user.name} (${user.id})`,
482
+ `- **Email**: ${user.email}`,
483
+ `- **Role**: ${user.role}`,
484
+ user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
485
+ `- **Active**: ${user.active}`,
486
+ user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
487
+ ].filter(Boolean).join("\n");
488
+ var formatOrganization = (org) => [
489
+ `## ${org.name} (${org.id})`,
490
+ org.details ? `- **Details**: ${org.details}` : "",
491
+ org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
492
+ org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
493
+ ].filter(Boolean).join("\n");
494
+ var formatArticleSummary = (article) => [
495
+ `## ${article.title} (${article.id})`,
496
+ `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
497
+ `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
498
+ article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
499
+ `- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
500
+ ].filter(Boolean).join("\n");
501
+ var formatArticle = (article) => [formatArticleSummary(article), "", article.body].join("\n");
502
+ var formatTranslationSummary = (translation) => [
503
+ `## Translation: ${translation.locale} (${translation.id})`,
504
+ `- **Title**: ${translation.title}`,
505
+ `- **Draft**: ${translation.draft}`,
506
+ `- **Updated**: ${translation.updated_at}`
507
+ ].join("\n");
508
+ var formatTranslation = (translation) => [formatTranslationSummary(translation), "", translation.body].join("\n");
509
+ var formatCategory = (category) => `- **${category.name}** (${category.id}) \u2014 ${category.description || "No description"}`;
510
+ var formatSection = (section) => `- **${section.name}** (${section.id}) \u2014 Category: ${section.category_id} \u2014 ${section.description || "No description"}`;
511
+ var formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " \u2014 Built-in" : ""}`;
512
+ var formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
513
+ var formatLabel = (label) => `- **${label.name}** (${label.id})`;
514
+ var formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) \u2014 ${segment.user_type}${segment.built_in ? " \u2014 Built-in" : ""}`;
515
+ var formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) \u2014 ${attachment.content_type} \u2014 ${attachment.size} bytes`;
516
+ var formatList = (items, formatter, meta) => {
517
+ const header = meta ? formatPagination(meta) : "";
518
+ const body = items.map(formatter).join("\n\n");
519
+ const text = [header, body].filter(Boolean).join("\n\n");
520
+ return truncateIfNeeded(text);
521
+ };
522
+
523
+ // src/utils/pagination.ts
524
+ var buildCursorParams = (pageSize, cursor) => {
525
+ const params = {
526
+ "page[size]": String(pageSize)
527
+ };
528
+ if (cursor) {
529
+ params["page[after]"] = cursor;
530
+ }
531
+ return params;
532
+ };
533
+ var buildOffsetParams = (perPage, page) => {
534
+ const params = {
535
+ per_page: String(perPage)
536
+ };
537
+ if (page && page > 1) {
538
+ params["page"] = String(page);
539
+ }
540
+ return params;
541
+ };
542
+ var extractPaginationMeta = (response) => ({
543
+ has_more: response.meta?.has_more ?? response.next_page != null,
544
+ after_cursor: response.meta?.after_cursor ?? null,
545
+ count: response.count ?? 0
546
+ });
547
+ var extractSearchPaginationMeta = (response, perPage, page) => {
548
+ const count = response.count ?? 0;
549
+ const has_more = count > page * perPage;
550
+ return {
551
+ has_more,
552
+ after_cursor: has_more ? String(page + 1) : null,
553
+ count
554
+ };
555
+ };
556
+
557
+ // src/tools/help-center.ts
558
+ var largeArticleHint = (body, sectionCount) => {
559
+ if (body.length < LARGE_ARTICLE_BODY_CHARS && sectionCount < LARGE_ARTICLE_SECTION_COUNT) {
560
+ return null;
561
+ }
562
+ return [
563
+ `> \u26A0 Large article (${body.length} chars, ${sectionCount} sections).`,
564
+ "> For targeted edits, prefer get_article_outline + get_article_section +",
565
+ "> update_article_section to avoid re-sending the full body on each write.",
566
+ ""
567
+ ].join("\n");
568
+ };
569
+ var createHelpCenterTools = (ctx) => {
570
+ const { subdomain, getToken } = ctx;
571
+ return [
572
+ {
573
+ name: "search_articles",
574
+ namespace: "help_center",
575
+ readOnly: true,
576
+ title: "Search Help Center Articles",
577
+ description: "Full-text search across Help Center articles (metadata only, no body). Use get_article for full content. Supports locale filtering. Returns total count.",
578
+ inputSchema: z2.object({
579
+ query: z2.string().min(1).describe("Search query"),
580
+ locale: z2.string().optional().describe('Filter by locale (e.g., "en-us", "fr")'),
581
+ per_page: z2.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Results per page"),
582
+ page: z2.number().int().min(1).default(1).describe("Page number")
583
+ }),
584
+ annotations: {
585
+ readOnlyHint: true,
586
+ destructiveHint: false,
587
+ idempotentHint: true,
588
+ openWorldHint: true
589
+ },
590
+ handler: async (params) => {
591
+ const { query, locale, per_page, page } = params;
592
+ const token = await getToken();
593
+ const p = { query, ...buildOffsetParams(per_page, page) };
594
+ if (locale) p["locale"] = locale;
595
+ const response = await helpCenterGet(
596
+ subdomain,
597
+ token,
598
+ "/articles/search",
599
+ p
600
+ );
601
+ return {
602
+ content: [
603
+ {
604
+ type: "text",
605
+ text: formatList(
606
+ response.results ?? [],
607
+ formatArticleSummary,
608
+ extractSearchPaginationMeta(response, per_page, page)
609
+ )
610
+ }
611
+ ]
612
+ };
613
+ }
614
+ },
615
+ {
616
+ name: "get_article",
617
+ namespace: "help_center",
618
+ readOnly: true,
619
+ title: "Get Help Center Article",
620
+ description: "Retrieve an article by ID with full body content. For large articles, prefer get_article_outline + get_article_section to save tokens. Optionally specify locale for a translated version. Returns body (HTML), metadata, source_locale, and list of available translations.",
621
+ inputSchema: z2.object({
622
+ article_id: z2.number().int().describe("Article ID"),
623
+ locale: z2.string().optional().describe("Locale for translated version")
624
+ }),
625
+ annotations: {
626
+ readOnlyHint: true,
627
+ destructiveHint: false,
628
+ idempotentHint: true,
629
+ openWorldHint: true
630
+ },
631
+ handler: async (params) => {
632
+ const { article_id, locale } = params;
633
+ const token = await getToken();
634
+ const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
635
+ const { article } = await helpCenterGet(
636
+ subdomain,
637
+ token,
638
+ path
639
+ );
640
+ const { translations } = await helpCenterGet(
641
+ subdomain,
642
+ token,
643
+ `/articles/${article_id}/translations`
644
+ );
645
+ const hint = largeArticleHint(article.body, parseSections(article.body).length);
646
+ const text = (hint ?? "") + formatArticle(article) + `
647
+
648
+ **Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
649
+ return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
650
+ }
651
+ },
652
+ {
653
+ name: "list_categories",
654
+ namespace: "help_center",
655
+ readOnly: true,
656
+ title: "List Help Center Categories",
657
+ description: "List all Help Center categories. Optionally filter by locale.",
658
+ inputSchema: z2.object({
659
+ locale: z2.string().optional(),
660
+ page_size: z2.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
661
+ cursor: z2.string().optional()
662
+ }),
663
+ annotations: {
664
+ readOnlyHint: true,
665
+ destructiveHint: false,
666
+ idempotentHint: true,
667
+ openWorldHint: true
668
+ },
669
+ handler: async (params) => {
670
+ const { locale, page_size, cursor } = params;
671
+ const token = await getToken();
672
+ const path = locale ? `/${locale}/categories` : "/categories";
673
+ const response = await helpCenterGet(
674
+ subdomain,
675
+ token,
676
+ path,
677
+ buildCursorParams(page_size, cursor)
678
+ );
679
+ return {
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: formatList(
684
+ response.categories ?? [],
685
+ formatCategory,
686
+ extractPaginationMeta(response)
687
+ )
688
+ }
689
+ ]
690
+ };
691
+ }
692
+ },
693
+ {
694
+ name: "list_sections",
695
+ namespace: "help_center",
696
+ readOnly: true,
697
+ title: "List Help Center Sections",
698
+ description: "List sections, optionally filtered by category ID and locale.",
699
+ inputSchema: z2.object({
700
+ category_id: z2.number().int().optional(),
701
+ locale: z2.string().optional(),
702
+ page_size: z2.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
703
+ cursor: z2.string().optional()
704
+ }),
705
+ annotations: {
706
+ readOnlyHint: true,
707
+ destructiveHint: false,
708
+ idempotentHint: true,
709
+ openWorldHint: true
710
+ },
711
+ handler: async (params) => {
712
+ const { category_id, locale, page_size, cursor } = params;
713
+ const token = await getToken();
714
+ const path = category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections";
715
+ const response = await helpCenterGet(
716
+ subdomain,
717
+ token,
718
+ path,
719
+ buildCursorParams(page_size, cursor)
720
+ );
721
+ return {
722
+ content: [
723
+ {
724
+ type: "text",
725
+ text: formatList(
726
+ response.sections ?? [],
727
+ formatSection,
728
+ extractPaginationMeta(response)
729
+ )
730
+ }
731
+ ]
732
+ };
733
+ }
734
+ },
735
+ {
736
+ name: "list_articles",
737
+ namespace: "help_center",
738
+ readOnly: true,
739
+ title: "List Help Center Articles",
740
+ description: 'List articles (metadata only, no body). Use get_article for full content. Optionally filter by section ID and locale. Supports sort_by ("title", "created_at", "updated_at") and include_translations: true to show available translation locales per article. Note: include_translations must be re-sent on each paginated request.',
741
+ inputSchema: z2.object({
742
+ section_id: z2.number().int().optional(),
743
+ locale: z2.string().optional(),
744
+ page_size: z2.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
745
+ cursor: z2.string().optional(),
746
+ sort_by: z2.enum(["created_at", "updated_at", "position", "title"]).default("position").describe("Sort field"),
747
+ sort_order: z2.enum(["asc", "desc"]).default("asc").describe("Sort direction"),
748
+ include_translations: z2.boolean().default(false).describe(
749
+ "Include available translation locales per article (causes 1 extra API call per article)"
750
+ )
751
+ }),
752
+ annotations: {
753
+ readOnlyHint: true,
754
+ destructiveHint: false,
755
+ idempotentHint: true,
756
+ openWorldHint: true
757
+ },
758
+ handler: async (params) => {
759
+ const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
760
+ const token = await getToken();
761
+ const path = section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles";
762
+ const response = await helpCenterGet(
763
+ subdomain,
764
+ token,
765
+ path,
766
+ { ...buildCursorParams(page_size, cursor), sort_by, sort_order }
767
+ );
768
+ const articles = response.articles ?? [];
769
+ if (!include_translations) {
770
+ return {
771
+ content: [
772
+ {
773
+ type: "text",
774
+ text: formatList(articles, formatArticleSummary, extractPaginationMeta(response))
775
+ }
776
+ ]
777
+ };
778
+ }
779
+ const formatted = await Promise.all(
780
+ articles.map(async (article) => {
781
+ const { translations } = await helpCenterGet(
782
+ subdomain,
783
+ token,
784
+ `/articles/${article.id}/translations`
785
+ );
786
+ const locales = translations.map((t) => t.locale).join(", ");
787
+ return `${formatArticleSummary(article)}
788
+ - **Translations**: ${locales}`;
789
+ })
790
+ );
791
+ const meta = extractPaginationMeta(response);
792
+ const header = meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "";
793
+ const text = [header, ...formatted].filter(Boolean).join("\n\n");
794
+ return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
795
+ }
796
+ },
797
+ {
798
+ name: "list_article_translations",
799
+ namespace: "help_center",
800
+ readOnly: true,
801
+ title: "List Article Translations",
802
+ description: "List all available translations for an article (metadata only, no body: locale, title, draft, updated_at). Use get_article with locale for full translated content.",
803
+ inputSchema: z2.object({ article_id: z2.number().int().describe("Article ID") }),
804
+ annotations: {
805
+ readOnlyHint: true,
806
+ destructiveHint: false,
807
+ idempotentHint: true,
808
+ openWorldHint: true
809
+ },
810
+ handler: async (params) => {
811
+ const { article_id } = params;
812
+ const token = await getToken();
813
+ const { translations } = await helpCenterGet(
814
+ subdomain,
815
+ token,
816
+ `/articles/${article_id}/translations`
817
+ );
818
+ return {
819
+ content: [{ type: "text", text: formatList(translations, formatTranslationSummary) }]
820
+ };
821
+ }
822
+ },
823
+ {
824
+ name: "create_article_translation",
825
+ namespace: "help_center",
826
+ readOnly: false,
827
+ title: "Create Article Translation",
828
+ description: "Create a translation for an existing article in a specific locale.",
829
+ inputSchema: z2.object({
830
+ article_id: z2.number().int(),
831
+ locale: z2.string().describe('Target locale (e.g., "fr", "de")'),
832
+ title: z2.string().min(1),
833
+ body: z2.string().min(1).describe("Translated body (HTML)"),
834
+ draft: z2.boolean().default(false)
835
+ }),
836
+ annotations: {
837
+ readOnlyHint: false,
838
+ destructiveHint: false,
839
+ idempotentHint: false,
840
+ openWorldHint: true
841
+ },
842
+ handler: async (params) => {
843
+ const { article_id, locale, title, body, draft } = params;
844
+ const token = await getToken();
845
+ const { translation } = await helpCenterPost(
846
+ subdomain,
847
+ token,
848
+ `/articles/${article_id}/translations`,
849
+ { translation: { locale, title, body, draft } }
850
+ );
851
+ return {
852
+ content: [
853
+ {
854
+ type: "text",
855
+ text: `Translation created for article #${article_id} in "${locale}".
856
+
857
+ ${formatTranslation(translation)}`
858
+ }
859
+ ]
860
+ };
861
+ }
862
+ },
863
+ {
864
+ name: "update_article_translation",
865
+ namespace: "help_center",
866
+ readOnly: false,
867
+ title: "Update Article Translation",
868
+ description: "Update article content (title, body) in a specific locale. For targeted edits on one or a few sections, prefer update_article_section \u2014 this tool replaces the FULL body and re-sends the entire article on each write. Use the article's source_locale (from get_article) for the default language, or another locale for translations.",
869
+ inputSchema: z2.object({
870
+ article_id: z2.number().int(),
871
+ locale: z2.string(),
872
+ title: z2.string().optional(),
873
+ body: z2.string().optional(),
874
+ draft: z2.boolean().optional()
875
+ }),
876
+ annotations: {
877
+ readOnlyHint: false,
878
+ destructiveHint: false,
879
+ idempotentHint: true,
880
+ openWorldHint: true
881
+ },
882
+ handler: async (params) => {
883
+ const { article_id, locale, ...updates } = params;
884
+ const token = await getToken();
885
+ const { translation } = await helpCenterPut(
886
+ subdomain,
887
+ token,
888
+ `/articles/${article_id}/translations/${locale}`,
889
+ { translation: updates }
890
+ );
891
+ return {
892
+ content: [
893
+ {
894
+ type: "text",
895
+ text: `Translation updated for article #${article_id} in "${locale}".
896
+
897
+ ${formatTranslation(translation)}`
898
+ }
899
+ ]
900
+ };
901
+ }
902
+ },
903
+ {
904
+ name: "list_permission_groups",
905
+ namespace: "help_center",
906
+ readOnly: true,
907
+ title: "List Permission Groups",
908
+ description: "List all Guide permission groups. Use this to find the permission_group_id required when creating articles.",
909
+ inputSchema: z2.object({}),
910
+ annotations: {
911
+ readOnlyHint: true,
912
+ destructiveHint: false,
913
+ idempotentHint: true,
914
+ openWorldHint: true
915
+ },
916
+ handler: async () => {
917
+ const token = await getToken();
918
+ const response = await zendeskGet(subdomain, token, "/guide/permission_groups");
919
+ return {
920
+ content: [
921
+ {
922
+ type: "text",
923
+ text: formatList(response.permission_groups ?? [], formatPermissionGroup)
924
+ }
925
+ ]
926
+ };
927
+ }
928
+ },
929
+ {
930
+ name: "create_article",
931
+ namespace: "help_center",
932
+ readOnly: false,
933
+ title: "Create Help Center Article",
934
+ description: "Create a new article in a section. The locale becomes the article's source_locale. Requires a permission_group_id (use list_permission_groups to find available IDs). To add content in other locales afterwards, use create_article_translation.",
935
+ inputSchema: z2.object({
936
+ section_id: z2.number().int(),
937
+ title: z2.string().min(1),
938
+ body: z2.string().min(1).describe("Article body (HTML)"),
939
+ permission_group_id: z2.number().int().describe("Permission group ID (use list_permission_groups to find it)"),
940
+ user_segment_id: z2.number().int().optional().describe(
941
+ "User segment ID for visibility (use list_user_segments to find it). Defaults to everyone."
942
+ ),
943
+ author_id: z2.number().int().optional().describe("Author user ID. Defaults to the authenticated user."),
944
+ content_tag_ids: z2.array(z2.string()).optional().describe("Content tag IDs (use list_content_tags to find them)"),
945
+ locale: z2.string().optional(),
946
+ draft: z2.boolean().default(true),
947
+ promoted: z2.boolean().default(false),
948
+ label_names: z2.array(z2.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)")
949
+ }),
950
+ annotations: {
951
+ readOnlyHint: false,
952
+ destructiveHint: false,
953
+ idempotentHint: false,
954
+ openWorldHint: true
955
+ },
956
+ handler: async (params) => {
957
+ const { section_id, ...articleData } = params;
958
+ const token = await getToken();
959
+ const { article } = await helpCenterPost(
960
+ subdomain,
961
+ token,
962
+ `/sections/${section_id}/articles`,
963
+ { article: articleData }
964
+ );
965
+ return {
966
+ content: [
967
+ { type: "text", text: `Article #${article.id} created.
968
+
969
+ ${formatArticle(article)}` }
970
+ ]
971
+ };
972
+ }
973
+ },
974
+ {
975
+ name: "update_article",
976
+ namespace: "help_center",
977
+ readOnly: false,
978
+ title: "Update Help Center Article",
979
+ description: "Update article metadata only (draft, promoted, labels, tags, visibility, section, etc.). Does NOT update content (title, body) \u2014 use update_article_translation for that.",
980
+ inputSchema: z2.object({
981
+ article_id: z2.number().int(),
982
+ draft: z2.boolean().optional(),
983
+ promoted: z2.boolean().optional(),
984
+ label_names: z2.array(z2.string()).optional().describe("Label names for search ranking"),
985
+ content_tag_ids: z2.array(z2.string()).optional().describe("Content tag IDs"),
986
+ user_segment_id: z2.number().int().optional().describe("User segment ID for visibility"),
987
+ author_id: z2.number().int().optional().describe("Author user ID"),
988
+ permission_group_id: z2.number().int().optional().describe("Permission group ID"),
989
+ section_id: z2.number().int().optional()
990
+ }),
991
+ annotations: {
992
+ readOnlyHint: false,
993
+ destructiveHint: false,
994
+ idempotentHint: true,
995
+ openWorldHint: true
996
+ },
997
+ handler: async (params) => {
998
+ const { article_id, ...updates } = params;
999
+ const token = await getToken();
1000
+ const { article } = await helpCenterPut(
1001
+ subdomain,
1002
+ token,
1003
+ `/articles/${article_id}`,
1004
+ { article: updates }
1005
+ );
1006
+ return {
1007
+ content: [
1008
+ { type: "text", text: `Article #${article.id} updated.
1009
+
1010
+ ${formatArticle(article)}` }
1011
+ ]
1012
+ };
1013
+ }
1014
+ },
1015
+ {
1016
+ name: "list_content_tags",
1017
+ namespace: "help_center",
1018
+ readOnly: true,
1019
+ title: "List Content Tags",
1020
+ description: "List all Guide content tags. Content tags are visible to end users and help them find related articles.",
1021
+ inputSchema: z2.object({}),
1022
+ annotations: {
1023
+ readOnlyHint: true,
1024
+ destructiveHint: false,
1025
+ idempotentHint: true,
1026
+ openWorldHint: true
1027
+ },
1028
+ handler: async () => {
1029
+ const token = await getToken();
1030
+ const response = await zendeskGet(
1031
+ subdomain,
1032
+ token,
1033
+ "/guide/content_tags"
1034
+ );
1035
+ return {
1036
+ content: [{ type: "text", text: formatList(response.records ?? [], formatContentTag) }]
1037
+ };
1038
+ }
1039
+ },
1040
+ {
1041
+ name: "create_content_tag",
1042
+ namespace: "help_center",
1043
+ readOnly: false,
1044
+ title: "Create Content Tag",
1045
+ description: "Create a new content tag for Guide articles.",
1046
+ inputSchema: z2.object({
1047
+ name: z2.string().min(1).describe("Content tag name")
1048
+ }),
1049
+ annotations: {
1050
+ readOnlyHint: false,
1051
+ destructiveHint: false,
1052
+ idempotentHint: false,
1053
+ openWorldHint: true
1054
+ },
1055
+ handler: async (params) => {
1056
+ const { name } = params;
1057
+ const token = await getToken();
1058
+ const { record: record2 } = await zendeskPost(
1059
+ subdomain,
1060
+ token,
1061
+ "/guide/content_tags",
1062
+ { record: { name } }
1063
+ );
1064
+ return {
1065
+ content: [{ type: "text", text: `Content tag created.
1066
+
1067
+ ${formatContentTag(record2)}` }]
1068
+ };
1069
+ }
1070
+ },
1071
+ {
1072
+ name: "list_labels",
1073
+ namespace: "help_center",
1074
+ readOnly: true,
1075
+ title: "List Article Labels",
1076
+ description: "List all article labels. Labels improve Help Center search ranking and are not visible to end users.",
1077
+ inputSchema: z2.object({}),
1078
+ annotations: {
1079
+ readOnlyHint: true,
1080
+ destructiveHint: false,
1081
+ idempotentHint: true,
1082
+ openWorldHint: true
1083
+ },
1084
+ handler: async () => {
1085
+ const token = await getToken();
1086
+ const response = await helpCenterGet(
1087
+ subdomain,
1088
+ token,
1089
+ "/articles/labels"
1090
+ );
1091
+ return {
1092
+ content: [{ type: "text", text: formatList(response.labels ?? [], formatLabel) }]
1093
+ };
1094
+ }
1095
+ },
1096
+ {
1097
+ name: "list_user_segments",
1098
+ namespace: "help_center",
1099
+ readOnly: true,
1100
+ title: "List User Segments",
1101
+ description: "List all user segments. User segments control article visibility (who can view). Use the ID when creating or updating articles.",
1102
+ inputSchema: z2.object({}),
1103
+ annotations: {
1104
+ readOnlyHint: true,
1105
+ destructiveHint: false,
1106
+ idempotentHint: true,
1107
+ openWorldHint: true
1108
+ },
1109
+ handler: async () => {
1110
+ const token = await getToken();
1111
+ const response = await helpCenterGet(subdomain, token, "/user_segments");
1112
+ return {
1113
+ content: [
1114
+ { type: "text", text: formatList(response.user_segments ?? [], formatUserSegment) }
1115
+ ]
1116
+ };
1117
+ }
1118
+ },
1119
+ {
1120
+ name: "list_article_attachments",
1121
+ namespace: "help_center",
1122
+ readOnly: true,
1123
+ title: "List Article Attachments",
1124
+ description: "List all attachments for an article.",
1125
+ inputSchema: z2.object({
1126
+ article_id: z2.number().int().describe("Article ID")
1127
+ }),
1128
+ annotations: {
1129
+ readOnlyHint: true,
1130
+ destructiveHint: false,
1131
+ idempotentHint: true,
1132
+ openWorldHint: true
1133
+ },
1134
+ handler: async (params) => {
1135
+ const { article_id } = params;
1136
+ const token = await getToken();
1137
+ const response = await helpCenterGet(subdomain, token, `/articles/${article_id}/attachments`);
1138
+ return {
1139
+ content: [
1140
+ {
1141
+ type: "text",
1142
+ text: formatList(response.article_attachments ?? [], formatAttachment)
1143
+ }
1144
+ ]
1145
+ };
1146
+ }
1147
+ },
1148
+ {
1149
+ name: "get_article_outline",
1150
+ namespace: "help_center",
1151
+ readOnly: true,
1152
+ title: "Get Article Outline",
1153
+ description: "Return a compact outline of an article (list of sections delimited by h1/h2/h3, with word counts) for the given locale (defaults to source_locale). Includes available translations with their outdated status. Use get_article_section to fetch a specific section.",
1154
+ inputSchema: z2.object({
1155
+ article_id: z2.number().int().describe("Article ID"),
1156
+ locale: z2.string().optional().describe("Locale of the body to outline (defaults to article source_locale)")
1157
+ }),
1158
+ annotations: {
1159
+ readOnlyHint: true,
1160
+ destructiveHint: false,
1161
+ idempotentHint: true,
1162
+ openWorldHint: true
1163
+ },
1164
+ handler: async (params) => {
1165
+ const { article_id, locale } = params;
1166
+ const token = await getToken();
1167
+ const { article } = await helpCenterGet(
1168
+ subdomain,
1169
+ token,
1170
+ `/articles/${article_id}`
1171
+ );
1172
+ const effectiveLocale = locale ?? article.source_locale;
1173
+ const { translation } = await helpCenterGet(
1174
+ subdomain,
1175
+ token,
1176
+ `/articles/${article_id}/translations/${effectiveLocale}`
1177
+ );
1178
+ const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
1179
+ const sections = parseSections(translation.body);
1180
+ const outlineLines = sections.length ? sections.map(
1181
+ (s) => `- [${s.index}] ${s.headingTag ? `${s.headingTag}: ` : ""}${s.heading} (${s.wordCount} words)`
1182
+ ).join("\n") : "_(no sections detected)_";
1183
+ const translationsList = translations.map((t) => `- ${t.locale}${t.outdated ? " (outdated)" : ""}`).join("\n");
1184
+ const text = [
1185
+ `# Outline \u2014 Article #${article_id} (${effectiveLocale})`,
1186
+ `**Title**: ${translation.title}`,
1187
+ "",
1188
+ "## Sections",
1189
+ outlineLines,
1190
+ "",
1191
+ "## Available translations",
1192
+ translationsList
1193
+ ].join("\n");
1194
+ return { content: [{ type: "text", text }] };
1195
+ }
1196
+ },
1197
+ {
1198
+ name: "get_article_section",
1199
+ namespace: "help_center",
1200
+ readOnly: true,
1201
+ title: "Get Article Section",
1202
+ description: 'Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format="html" for round-trip safety. Pass format="markdown" only for human review \u2014 the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section).',
1203
+ inputSchema: z2.object({
1204
+ article_id: z2.number().int().describe("Article ID"),
1205
+ locale: z2.string().describe('Locale of the body (e.g., "en-us", "fr")'),
1206
+ section_index: z2.number().int().min(0).describe("0-based index of the section (see get_article_outline)"),
1207
+ format: z2.enum(["html", "markdown"]).default("html").describe(
1208
+ 'Output format. "html" (default) is round-trip safe. "markdown" is lossy on some HTML structures \u2014 use only for human review, not before update_article_section.'
1209
+ )
1210
+ }),
1211
+ annotations: {
1212
+ readOnlyHint: true,
1213
+ destructiveHint: false,
1214
+ idempotentHint: true,
1215
+ openWorldHint: true
1216
+ },
1217
+ handler: async (params) => {
1218
+ const { article_id, locale, section_index, format } = params;
1219
+ const token = await getToken();
1220
+ const { translation } = await helpCenterGet(
1221
+ subdomain,
1222
+ token,
1223
+ `/articles/${article_id}/translations/${locale}`
1224
+ );
1225
+ const sections = parseSections(translation.body);
1226
+ const section = sections[section_index];
1227
+ if (!section) {
1228
+ throw new Error(
1229
+ `Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`
1230
+ );
1231
+ }
1232
+ const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
1233
+ const headerLine = section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`;
1234
+ const text = [
1235
+ headerLine,
1236
+ `_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
1237
+ "",
1238
+ content
1239
+ ].join("\n");
1240
+ return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
1241
+ }
1242
+ },
1243
+ {
1244
+ name: "update_article_section",
1245
+ namespace: "help_center",
1246
+ readOnly: false,
1247
+ title: "Update Article Section",
1248
+ description: 'Replace the content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. Default format="html" for fidelity. Use format="markdown" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells). The section heading is preserved and is NOT part of the replaced content.',
1249
+ inputSchema: z2.object({
1250
+ article_id: z2.number().int().describe("Article ID"),
1251
+ locale: z2.string().describe("Locale of the translation to update"),
1252
+ section_index: z2.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
1253
+ content: z2.string().describe(
1254
+ 'New content for the section (heading excluded). HTML by default, Markdown if format="markdown".'
1255
+ ),
1256
+ format: z2.enum(["html", "markdown"]).default("html").describe(
1257
+ 'Input format. "html" (default) is the safe path. "markdown" is converted to HTML server-side but may introduce artifacts on complex content.'
1258
+ )
1259
+ }),
1260
+ annotations: {
1261
+ readOnlyHint: false,
1262
+ destructiveHint: false,
1263
+ idempotentHint: true,
1264
+ openWorldHint: true
1265
+ },
1266
+ handler: async (params) => {
1267
+ const { article_id, locale, section_index, content, format } = params;
1268
+ const token = await getToken();
1269
+ const { translation } = await helpCenterGet(
1270
+ subdomain,
1271
+ token,
1272
+ `/articles/${article_id}/translations/${locale}`
1273
+ );
1274
+ const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
1275
+ const newBody = replaceSectionContent(translation.body, section_index, newSectionHtml);
1276
+ const { translation: updated } = await helpCenterPut(
1277
+ subdomain,
1278
+ token,
1279
+ `/articles/${article_id}/translations/${locale}`,
1280
+ { translation: { body: newBody } }
1281
+ );
1282
+ const updatedSections = parseSections(updated.body);
1283
+ const updatedSection = updatedSections[section_index];
1284
+ const newWordCount = updatedSection?.wordCount ?? 0;
1285
+ const headingLabel = updatedSection?.heading ?? "(intro)";
1286
+ const text = [
1287
+ `Section [${section_index}] "${headingLabel}" updated for article #${article_id} (${locale}).`,
1288
+ `New word count: ${newWordCount}.`
1289
+ ].join("\n");
1290
+ return { content: [{ type: "text", text }] };
1291
+ }
1292
+ },
1293
+ {
1294
+ name: "compare_translations",
1295
+ namespace: "help_center",
1296
+ readOnly: true,
1297
+ title: "Compare Article Translations",
1298
+ description: 'Compare section structure between two locales of the same article, matched by index. Returns a compact table (one row per section) with status: "ok" (both present, source/target word count ratio within 25%), "different" (word count ratio diverges by more than 25% \u2014 size signal only, NOT a semantic divergence: two locales may legitimately differ in verbosity) or "missing" (section absent in target). Useful to spot structurally stale or missing sections; do not interpret "different" as an edit regression on its own.',
1299
+ inputSchema: z2.object({
1300
+ article_id: z2.number().int().describe("Article ID"),
1301
+ source_locale: z2.string().describe("Source (reference) locale"),
1302
+ target_locale: z2.string().describe("Target locale to compare against source")
1303
+ }),
1304
+ annotations: {
1305
+ readOnlyHint: true,
1306
+ destructiveHint: false,
1307
+ idempotentHint: true,
1308
+ openWorldHint: true
1309
+ },
1310
+ handler: async (params) => {
1311
+ const { article_id, source_locale, target_locale } = params;
1312
+ const token = await getToken();
1313
+ const [sourceRes, targetRes] = await Promise.all([
1314
+ helpCenterGet(
1315
+ subdomain,
1316
+ token,
1317
+ `/articles/${article_id}/translations/${source_locale}`
1318
+ ),
1319
+ helpCenterGet(
1320
+ subdomain,
1321
+ token,
1322
+ `/articles/${article_id}/translations/${target_locale}`
1323
+ )
1324
+ ]);
1325
+ const sourceSections = parseSections(sourceRes.translation.body);
1326
+ const targetSections = parseSections(targetRes.translation.body);
1327
+ const maxLen = Math.max(sourceSections.length, targetSections.length);
1328
+ const rows = [];
1329
+ rows.push(`| Idx | Heading | Status | Source words | Target words |`);
1330
+ rows.push(`| --- | --- | --- | --- | --- |`);
1331
+ for (let i = 0; i < maxLen; i += 1) {
1332
+ const src = sourceSections[i];
1333
+ const tgt = targetSections[i];
1334
+ const heading = src?.heading ?? tgt?.heading ?? "";
1335
+ const sourceWords = src?.wordCount ?? 0;
1336
+ const targetWords = tgt?.wordCount ?? 0;
1337
+ let status;
1338
+ if (!tgt) status = "missing";
1339
+ else if (!src) status = "different";
1340
+ else {
1341
+ const denom = Math.max(sourceWords, 1);
1342
+ const diffRatio = Math.abs(sourceWords - targetWords) / denom;
1343
+ status = diffRatio > 0.25 ? "different" : "ok";
1344
+ }
1345
+ rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
1346
+ }
1347
+ const text = [
1348
+ `# Translation diff \u2014 Article #${article_id} (${source_locale} \u2192 ${target_locale})`,
1349
+ "",
1350
+ ...rows
1351
+ ].join("\n");
1352
+ return { content: [{ type: "text", text }] };
1353
+ }
1354
+ },
1355
+ {
1356
+ name: "create_article_attachment",
1357
+ namespace: "help_center",
1358
+ readOnly: false,
1359
+ title: "Create Article Attachment",
1360
+ description: "Upload an attachment to an article. Provide file content as base64-encoded string.",
1361
+ inputSchema: z2.object({
1362
+ article_id: z2.number().int().describe("Article ID"),
1363
+ file_name: z2.string().min(1).describe('File name (e.g., "screenshot.png")'),
1364
+ file_base64: z2.string().min(1).describe("File content encoded as base64"),
1365
+ content_type: z2.string().default("application/octet-stream").describe('MIME type (e.g., "image/png", "application/pdf")')
1366
+ }),
1367
+ annotations: {
1368
+ readOnlyHint: false,
1369
+ destructiveHint: false,
1370
+ idempotentHint: false,
1371
+ openWorldHint: true
1372
+ },
1373
+ handler: async (params) => {
1374
+ const { article_id, file_name, file_base64, content_type } = params;
1375
+ const token = await getToken();
1376
+ const buffer = Buffer.from(file_base64, "base64");
1377
+ const blob = new Blob([buffer], { type: content_type });
1378
+ const formData = new FormData();
1379
+ formData.append("file", blob, file_name);
1380
+ const { article_attachment } = await helpCenterUpload(subdomain, token, `/articles/${article_id}/attachments`, formData);
1381
+ return {
1382
+ content: [
1383
+ {
1384
+ type: "text",
1385
+ text: `Attachment created for article #${article_id}.
1386
+
1387
+ ${formatAttachment(article_attachment)}`
1388
+ }
1389
+ ]
1390
+ };
1391
+ }
1392
+ }
1393
+ ];
1394
+ };
1395
+
1396
+ // src/tools/search.ts
1397
+ import * as z3 from "zod/v4";
1398
+ var formatSearchResult = (result) => {
1399
+ const lines = [`## [${result["result_type"]}] #${result["id"]}`];
1400
+ if (result["subject"]) lines.push(`**Subject**: ${result["subject"]}`);
1401
+ if (result["name"]) lines.push(`**Name**: ${result["name"]}`);
1402
+ if (result["title"]) lines.push(`**Title**: ${result["title"]}`);
1403
+ if (result["email"]) lines.push(`**Email**: ${result["email"]}`);
1404
+ if (result["status"]) lines.push(`**Status**: ${result["status"]}`);
1405
+ if (result["description"]) {
1406
+ const desc = String(result["description"]);
1407
+ lines.push(desc.length > 200 ? `${desc.slice(0, 200)}...` : desc);
1408
+ }
1409
+ return lines.join("\n");
1410
+ };
1411
+ var createSearchTools = (ctx) => {
1412
+ const { subdomain, getToken } = ctx;
1413
+ return [
1414
+ {
1415
+ name: "search",
1416
+ namespace: "tickets",
1417
+ readOnly: true,
1418
+ title: "Zendesk Unified Search",
1419
+ description: 'Search across tickets, users, and organizations. Supports filters like "type:ticket status:open", "type:user role:agent". Returns total count and paginated results (100 per page). Organization results include name and ID only \u2014 use get_organization for full details (tags, domains, details).',
1420
+ inputSchema: z3.object({
1421
+ query: z3.string().min(1).describe("Zendesk search query"),
1422
+ per_page: z3.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Results per page (max 100)"),
1423
+ page: z3.number().int().min(1).default(1).describe("Page number (1-based)")
1424
+ }),
1425
+ annotations: {
1426
+ readOnlyHint: true,
1427
+ destructiveHint: false,
1428
+ idempotentHint: true,
1429
+ openWorldHint: true
1430
+ },
1431
+ handler: async (params) => {
1432
+ const { query, per_page, page } = params;
1433
+ const token = await getToken();
1434
+ const response = await zendeskGet(
1435
+ subdomain,
1436
+ token,
1437
+ "/search",
1438
+ {
1439
+ query,
1440
+ ...buildOffsetParams(per_page, page)
1441
+ }
1442
+ );
1443
+ const results = response.results ?? [];
1444
+ const meta = extractSearchPaginationMeta(response, per_page, page);
1445
+ const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
1446
+ const body = results.map(formatSearchResult).join("\n\n");
1447
+ return {
1448
+ content: [
1449
+ { type: "text", text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n")) }
1450
+ ]
1451
+ };
1452
+ }
1453
+ }
1454
+ ];
1455
+ };
1456
+
1457
+ // src/tools/tickets.ts
1458
+ import * as z4 from "zod/v4";
1459
+ var createTicketTools = (ctx) => {
1460
+ const { subdomain, getToken } = ctx;
1461
+ return [
1462
+ {
1463
+ name: "get_ticket",
1464
+ namespace: "tickets",
1465
+ readOnly: true,
1466
+ title: "Get Zendesk Ticket",
1467
+ description: "Retrieve a Zendesk ticket by ID, including its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes.",
1468
+ inputSchema: z4.object({
1469
+ ticket_id: z4.number().int().describe("Ticket ID"),
1470
+ include_comments: z4.boolean().default(false).describe("Include ticket comments")
1471
+ }),
1472
+ annotations: {
1473
+ readOnlyHint: true,
1474
+ destructiveHint: false,
1475
+ idempotentHint: true,
1476
+ openWorldHint: true
1477
+ },
1478
+ handler: async (params) => {
1479
+ const { ticket_id, include_comments } = params;
1480
+ const token = await getToken();
1481
+ const { ticket } = await zendeskGet(
1482
+ subdomain,
1483
+ token,
1484
+ `/tickets/${ticket_id}`
1485
+ );
1486
+ let text = formatTicket(ticket);
1487
+ if (include_comments) {
1488
+ const { comments } = await zendeskGet(
1489
+ subdomain,
1490
+ token,
1491
+ `/tickets/${ticket_id}/comments`
1492
+ );
1493
+ text += `
1494
+
1495
+ ---
1496
+ # Comments
1497
+
1498
+ ${comments.map(formatComment).join("\n\n")}`;
1499
+ }
1500
+ return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
1501
+ }
1502
+ },
1503
+ {
1504
+ name: "search_tickets",
1505
+ namespace: "tickets",
1506
+ readOnly: true,
1507
+ title: "Search Zendesk Tickets",
1508
+ description: 'Search tickets using Zendesk query syntax (e.g., "status:open assignee:me", "priority:urgent type:incident"). Returns total count.',
1509
+ inputSchema: z4.object({
1510
+ query: z4.string().min(1).describe("Zendesk search query string"),
1511
+ per_page: z4.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Results per page"),
1512
+ page: z4.number().int().min(1).default(1).describe("Page number")
1513
+ }),
1514
+ annotations: {
1515
+ readOnlyHint: true,
1516
+ destructiveHint: false,
1517
+ idempotentHint: true,
1518
+ openWorldHint: true
1519
+ },
1520
+ handler: async (params) => {
1521
+ const { query, per_page, page } = params;
1522
+ const token = await getToken();
1523
+ const response = await zendeskGet(
1524
+ subdomain,
1525
+ token,
1526
+ "/search",
1527
+ {
1528
+ query: `type:ticket ${query}`,
1529
+ ...buildOffsetParams(per_page, page)
1530
+ }
1531
+ );
1532
+ return {
1533
+ content: [
1534
+ {
1535
+ type: "text",
1536
+ text: formatList(
1537
+ response.results ?? [],
1538
+ formatTicket,
1539
+ extractSearchPaginationMeta(response, per_page, page)
1540
+ )
1541
+ }
1542
+ ]
1543
+ };
1544
+ }
1545
+ },
1546
+ {
1547
+ name: "create_ticket",
1548
+ namespace: "tickets",
1549
+ readOnly: false,
1550
+ title: "Create Zendesk Ticket",
1551
+ description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags.",
1552
+ inputSchema: z4.object({
1553
+ subject: z4.string().min(1).describe("Ticket subject"),
1554
+ description: z4.string().min(1).describe("Ticket description"),
1555
+ priority: z4.enum(["urgent", "high", "normal", "low"]).optional(),
1556
+ type: z4.enum(["problem", "incident", "question", "task"]).optional(),
1557
+ assignee_id: z4.number().int().optional(),
1558
+ group_id: z4.number().int().optional(),
1559
+ tags: z4.array(z4.string()).optional(),
1560
+ custom_fields: z4.array(z4.object({ id: z4.number().int(), value: z4.unknown() })).optional()
1561
+ }),
1562
+ annotations: {
1563
+ readOnlyHint: false,
1564
+ destructiveHint: false,
1565
+ idempotentHint: false,
1566
+ openWorldHint: true
1567
+ },
1568
+ handler: async (params) => {
1569
+ const { subject, description, ...rest } = params;
1570
+ const token = await getToken();
1571
+ const { ticket } = await zendeskPost(
1572
+ subdomain,
1573
+ token,
1574
+ "/tickets",
1575
+ {
1576
+ ticket: { subject, comment: { body: description }, ...rest }
1577
+ }
1578
+ );
1579
+ return {
1580
+ content: [
1581
+ { type: "text", text: `Ticket #${ticket.id} created.
1582
+
1583
+ ${formatTicket(ticket)}` }
1584
+ ]
1585
+ };
1586
+ }
1587
+ },
1588
+ {
1589
+ name: "update_ticket",
1590
+ namespace: "tickets",
1591
+ readOnly: false,
1592
+ title: "Update Zendesk Ticket",
1593
+ description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields).",
1594
+ inputSchema: z4.object({
1595
+ ticket_id: z4.number().int().describe("Ticket ID"),
1596
+ status: z4.enum(["new", "open", "pending", "hold", "solved", "closed"]).optional(),
1597
+ priority: z4.enum(["urgent", "high", "normal", "low"]).optional(),
1598
+ type: z4.enum(["problem", "incident", "question", "task"]).optional(),
1599
+ assignee_id: z4.number().int().optional(),
1600
+ group_id: z4.number().int().optional(),
1601
+ subject: z4.string().optional(),
1602
+ tags: z4.array(z4.string()).optional(),
1603
+ custom_fields: z4.array(z4.object({ id: z4.number().int(), value: z4.unknown() })).optional()
1604
+ }),
1605
+ annotations: {
1606
+ readOnlyHint: false,
1607
+ destructiveHint: false,
1608
+ idempotentHint: true,
1609
+ openWorldHint: true
1610
+ },
1611
+ handler: async (params) => {
1612
+ const { ticket_id, ...updates } = params;
1613
+ const token = await getToken();
1614
+ const { ticket } = await zendeskPut(
1615
+ subdomain,
1616
+ token,
1617
+ `/tickets/${ticket_id}`,
1618
+ { ticket: updates }
1619
+ );
1620
+ return {
1621
+ content: [
1622
+ { type: "text", text: `Ticket #${ticket.id} updated.
1623
+
1624
+ ${formatTicket(ticket)}` }
1625
+ ]
1626
+ };
1627
+ }
1628
+ },
1629
+ {
1630
+ name: "add_private_note",
1631
+ namespace: "tickets",
1632
+ readOnly: false,
1633
+ title: "Add Private Note",
1634
+ description: "Add an internal note (not visible to requester) to a ticket.",
1635
+ inputSchema: z4.object({
1636
+ ticket_id: z4.number().int().describe("Ticket ID"),
1637
+ body: z4.string().min(1).describe("Note content")
1638
+ }),
1639
+ annotations: {
1640
+ readOnlyHint: false,
1641
+ destructiveHint: false,
1642
+ idempotentHint: false,
1643
+ openWorldHint: true
1644
+ },
1645
+ handler: async (params) => {
1646
+ const { ticket_id, body } = params;
1647
+ const token = await getToken();
1648
+ await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, {
1649
+ ticket: { comment: { body, public: false } }
1650
+ });
1651
+ return { content: [{ type: "text", text: `Private note added to ticket #${ticket_id}.` }] };
1652
+ }
1653
+ },
1654
+ {
1655
+ name: "add_public_comment",
1656
+ namespace: "tickets",
1657
+ readOnly: false,
1658
+ title: "Add Public Comment",
1659
+ description: "Add a public comment (visible to requester) to a ticket.",
1660
+ inputSchema: z4.object({
1661
+ ticket_id: z4.number().int().describe("Ticket ID"),
1662
+ body: z4.string().min(1).describe("Comment content")
1663
+ }),
1664
+ annotations: {
1665
+ readOnlyHint: false,
1666
+ destructiveHint: false,
1667
+ idempotentHint: false,
1668
+ openWorldHint: true
1669
+ },
1670
+ handler: async (params) => {
1671
+ const { ticket_id, body } = params;
1672
+ const token = await getToken();
1673
+ await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, {
1674
+ ticket: { comment: { body, public: true } }
1675
+ });
1676
+ return {
1677
+ content: [{ type: "text", text: `Public comment added to ticket #${ticket_id}.` }]
1678
+ };
1679
+ }
1680
+ },
1681
+ {
1682
+ name: "list_tickets",
1683
+ namespace: "tickets",
1684
+ readOnly: true,
1685
+ title: "List Zendesk Tickets",
1686
+ description: "List tickets with cursor-based pagination, sorted by most recently updated.",
1687
+ inputSchema: z4.object({
1688
+ page_size: z4.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
1689
+ cursor: z4.string().optional().describe("Pagination cursor")
1690
+ }),
1691
+ annotations: {
1692
+ readOnlyHint: true,
1693
+ destructiveHint: false,
1694
+ idempotentHint: true,
1695
+ openWorldHint: true
1696
+ },
1697
+ handler: async (params) => {
1698
+ const { page_size, cursor } = params;
1699
+ const token = await getToken();
1700
+ const response = await zendeskGet(
1701
+ subdomain,
1702
+ token,
1703
+ "/tickets",
1704
+ buildCursorParams(page_size, cursor)
1705
+ );
1706
+ return {
1707
+ content: [
1708
+ {
1709
+ type: "text",
1710
+ text: formatList(
1711
+ response.tickets ?? [],
1712
+ formatTicket,
1713
+ extractPaginationMeta(response)
1714
+ )
1715
+ }
1716
+ ]
1717
+ };
1718
+ }
1719
+ },
1720
+ {
1721
+ name: "get_linked_incidents",
1722
+ namespace: "tickets",
1723
+ readOnly: true,
1724
+ title: "Get Linked Incidents",
1725
+ description: "Get all incident tickets linked to a problem ticket.",
1726
+ inputSchema: z4.object({
1727
+ problem_id: z4.number().int().describe("Problem ticket ID")
1728
+ }),
1729
+ annotations: {
1730
+ readOnlyHint: true,
1731
+ destructiveHint: false,
1732
+ idempotentHint: true,
1733
+ openWorldHint: true
1734
+ },
1735
+ handler: async (params) => {
1736
+ const { problem_id } = params;
1737
+ const token = await getToken();
1738
+ const response = await zendeskGet(
1739
+ subdomain,
1740
+ token,
1741
+ `/tickets/${problem_id}/incidents`
1742
+ );
1743
+ const incidents = response.tickets ?? [];
1744
+ const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}
1745
+
1746
+ ${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
1747
+ return { content: [{ type: "text", text: truncateIfNeeded(text) }] };
1748
+ }
1749
+ },
1750
+ {
1751
+ name: "manage_tags",
1752
+ namespace: "tickets",
1753
+ readOnly: false,
1754
+ title: "Manage Ticket Tags",
1755
+ description: "Add or remove tags on a ticket.",
1756
+ inputSchema: z4.object({
1757
+ ticket_id: z4.number().int().describe("Ticket ID"),
1758
+ add: z4.array(z4.string()).optional().describe("Tags to add"),
1759
+ remove: z4.array(z4.string()).optional().describe("Tags to remove")
1760
+ }),
1761
+ annotations: {
1762
+ readOnlyHint: false,
1763
+ destructiveHint: false,
1764
+ idempotentHint: true,
1765
+ openWorldHint: true
1766
+ },
1767
+ handler: async (params) => {
1768
+ const { ticket_id, add, remove } = params;
1769
+ const token = await getToken();
1770
+ const { ticket } = await zendeskGet(
1771
+ subdomain,
1772
+ token,
1773
+ `/tickets/${ticket_id}`
1774
+ );
1775
+ const tags = new Set(ticket.tags);
1776
+ add?.forEach((t) => {
1777
+ tags.add(t);
1778
+ });
1779
+ remove?.forEach((t) => {
1780
+ tags.delete(t);
1781
+ });
1782
+ const { ticket: updated } = await zendeskPut(
1783
+ subdomain,
1784
+ token,
1785
+ `/tickets/${ticket_id}`,
1786
+ { ticket: { tags: [...tags] } }
1787
+ );
1788
+ return {
1789
+ content: [
1790
+ {
1791
+ type: "text",
1792
+ text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
1793
+ }
1794
+ ]
1795
+ };
1796
+ }
1797
+ }
1798
+ ];
1799
+ };
1800
+
1801
+ // src/tools/users.ts
1802
+ import * as z5 from "zod/v4";
1803
+ var createUserTools = (ctx) => {
1804
+ const { subdomain, getToken } = ctx;
1805
+ return [
1806
+ {
1807
+ name: "get_current_user",
1808
+ namespace: "users",
1809
+ readOnly: true,
1810
+ title: "Get Current Zendesk User",
1811
+ description: "Get the currently authenticated Zendesk user. Useful to verify identity and permissions.",
1812
+ inputSchema: z5.object({}),
1813
+ annotations: {
1814
+ readOnlyHint: true,
1815
+ destructiveHint: false,
1816
+ idempotentHint: true,
1817
+ openWorldHint: true
1818
+ },
1819
+ handler: async () => {
1820
+ const token = await getToken();
1821
+ const { user } = await zendeskGet(subdomain, token, "/users/me");
1822
+ return { content: [{ type: "text", text: formatUser(user) }] };
1823
+ }
1824
+ },
1825
+ {
1826
+ name: "search_users",
1827
+ namespace: "users",
1828
+ readOnly: true,
1829
+ title: "Search Zendesk Users",
1830
+ description: "Search for users by name, email, or other criteria using Zendesk search query syntax. Returns total count.",
1831
+ inputSchema: z5.object({
1832
+ query: z5.string().min(1).describe("Search query"),
1833
+ per_page: z5.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe("Results per page"),
1834
+ page: z5.number().int().min(1).default(1).describe("Page number")
1835
+ }),
1836
+ annotations: {
1837
+ readOnlyHint: true,
1838
+ destructiveHint: false,
1839
+ idempotentHint: true,
1840
+ openWorldHint: true
1841
+ },
1842
+ handler: async (params) => {
1843
+ const { query, per_page, page } = params;
1844
+ const token = await getToken();
1845
+ const response = await zendeskGet(
1846
+ subdomain,
1847
+ token,
1848
+ "/search",
1849
+ {
1850
+ query: `type:user ${query}`,
1851
+ ...buildOffsetParams(per_page, page)
1852
+ }
1853
+ );
1854
+ return {
1855
+ content: [
1856
+ {
1857
+ type: "text",
1858
+ text: formatList(
1859
+ response.results ?? [],
1860
+ formatUser,
1861
+ extractSearchPaginationMeta(response, per_page, page)
1862
+ )
1863
+ }
1864
+ ]
1865
+ };
1866
+ }
1867
+ },
1868
+ {
1869
+ name: "get_user",
1870
+ namespace: "users",
1871
+ readOnly: true,
1872
+ title: "Get Zendesk User",
1873
+ description: "Retrieve a user by ID.",
1874
+ inputSchema: z5.object({ user_id: z5.number().int().describe("User ID") }),
1875
+ annotations: {
1876
+ readOnlyHint: true,
1877
+ destructiveHint: false,
1878
+ idempotentHint: true,
1879
+ openWorldHint: true
1880
+ },
1881
+ handler: async (params) => {
1882
+ const { user_id } = params;
1883
+ const token = await getToken();
1884
+ const { user } = await zendeskGet(
1885
+ subdomain,
1886
+ token,
1887
+ `/users/${user_id}`
1888
+ );
1889
+ return { content: [{ type: "text", text: formatUser(user) }] };
1890
+ }
1891
+ },
1892
+ {
1893
+ name: "get_organization",
1894
+ namespace: "users",
1895
+ readOnly: true,
1896
+ title: "Get Zendesk Organization",
1897
+ description: "Retrieve an organization by ID.",
1898
+ inputSchema: z5.object({ organization_id: z5.number().int().describe("Organization ID") }),
1899
+ annotations: {
1900
+ readOnlyHint: true,
1901
+ destructiveHint: false,
1902
+ idempotentHint: true,
1903
+ openWorldHint: true
1904
+ },
1905
+ handler: async (params) => {
1906
+ const { organization_id } = params;
1907
+ const token = await getToken();
1908
+ const { organization } = await zendeskGet(
1909
+ subdomain,
1910
+ token,
1911
+ `/organizations/${organization_id}`
1912
+ );
1913
+ return { content: [{ type: "text", text: formatOrganization(organization) }] };
1914
+ }
1915
+ },
1916
+ {
1917
+ name: "list_organizations",
1918
+ namespace: "users",
1919
+ readOnly: true,
1920
+ title: "List Zendesk Organizations",
1921
+ description: "List all organizations with pagination.",
1922
+ inputSchema: z5.object({
1923
+ page_size: z5.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
1924
+ cursor: z5.string().optional()
1925
+ }),
1926
+ annotations: {
1927
+ readOnlyHint: true,
1928
+ destructiveHint: false,
1929
+ idempotentHint: true,
1930
+ openWorldHint: true
1931
+ },
1932
+ handler: async (params) => {
1933
+ const { page_size, cursor } = params;
1934
+ const token = await getToken();
1935
+ const response = await zendeskGet(
1936
+ subdomain,
1937
+ token,
1938
+ "/organizations",
1939
+ buildCursorParams(page_size, cursor)
1940
+ );
1941
+ return {
1942
+ content: [
1943
+ {
1944
+ type: "text",
1945
+ text: formatList(
1946
+ response.organizations ?? [],
1947
+ formatOrganization,
1948
+ extractPaginationMeta(response)
1949
+ )
1950
+ }
1951
+ ]
1952
+ };
1953
+ }
1954
+ }
1955
+ ];
1956
+ };
1957
+
1958
+ // src/tools/index.ts
1959
+ var createAllTools = (ctx) => [
1960
+ ...createTicketTools(ctx),
1961
+ ...createSearchTools(ctx),
1962
+ ...createHelpCenterTools(ctx),
1963
+ ...createUserTools(ctx)
1964
+ ];
1965
+
1966
+ // src/server.ts
1967
+ var NAMESPACE_LABELS = {
1968
+ tickets: { toolName: "zendesk_tickets", title: "Zendesk Tickets" },
1969
+ help_center: { toolName: "zendesk_help_center", title: "Zendesk Help Center" },
1970
+ users: { toolName: "zendesk_users", title: "Zendesk Users" }
1971
+ };
1972
+ var summarizeDescription = (description) => {
1973
+ const idx = description.indexOf(". ");
1974
+ if (idx === -1) return description;
1975
+ return description.slice(0, idx + 1);
1976
+ };
1977
+ var buildOperationList = (tools) => tools.map(
1978
+ (t) => `- **${t.name}**: ${summarizeDescription(t.description)}${t.readOnly ? "" : " (write)"}`
1979
+ ).join("\n");
1980
+ var registerProxyTool = (server, toolName, title, tools, handlerMap) => {
1981
+ const operationNames = tools.map((t) => t.name);
1982
+ const operationList = buildOperationList(tools);
1983
+ server.registerTool(
1984
+ toolName,
1985
+ {
1986
+ title,
1987
+ description: `${title}. Specify the operation and its parameters.
1988
+
1989
+ Available operations:
1990
+ ${operationList}`,
1991
+ inputSchema: z6.object({
1992
+ operation: z6.string().describe(`One of: ${operationNames.join(", ")}`),
1993
+ params: z6.record(z6.string(), z6.unknown()).default({}).describe("Operation parameters")
1994
+ })
1995
+ },
1996
+ async ({ operation, params }) => {
1997
+ const def = handlerMap.get(operation);
1998
+ if (!def) {
1999
+ return {
2000
+ content: [
2001
+ {
2002
+ type: "text",
2003
+ text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2004
+ }
2005
+ ]
2006
+ };
2007
+ }
2008
+ const validated = def.inputSchema.parse(params);
2009
+ return def.handler(validated);
2010
+ }
2011
+ );
2012
+ };
2013
+ var createMcpServer = (config, getToken) => {
2014
+ const server = new McpServer({
2015
+ name: "@digital4better/zendesk-mcp-server",
2016
+ version: "0.1.0"
2017
+ });
2018
+ const allTools = createAllTools({ subdomain: config.subdomain, getToken });
2019
+ const filteredTools = filterTools(allTools, {
2020
+ readOnly: config.readOnly,
2021
+ namespaces: config.namespaces,
2022
+ tools: config.tools
2023
+ });
2024
+ const handlerMap = /* @__PURE__ */ new Map();
2025
+ for (const tool of filteredTools) {
2026
+ handlerMap.set(tool.name, tool);
2027
+ }
2028
+ switch (config.mode) {
2029
+ case "all": {
2030
+ for (const tool of filteredTools) {
2031
+ server.registerTool(
2032
+ tool.name,
2033
+ {
2034
+ title: tool.title,
2035
+ description: tool.description,
2036
+ inputSchema: tool.inputSchema,
2037
+ annotations: tool.annotations
2038
+ },
2039
+ async (params) => tool.handler(params)
2040
+ );
2041
+ }
2042
+ break;
2043
+ }
2044
+ case "namespace": {
2045
+ const grouped = groupByNamespace(filteredTools);
2046
+ for (const [namespace, tools] of grouped) {
2047
+ const label = NAMESPACE_LABELS[namespace];
2048
+ if (label) {
2049
+ registerProxyTool(server, label.toolName, label.title, tools, handlerMap);
2050
+ }
2051
+ }
2052
+ break;
2053
+ }
2054
+ case "single": {
2055
+ registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap);
2056
+ break;
2057
+ }
2058
+ }
2059
+ console.error(`Registered ${filteredTools.length} tools in ${config.mode} mode`);
2060
+ return server;
2061
+ };
2062
+
2063
+ // src/transports/stdio.ts
2064
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2065
+ var startStdioTransport = async (server) => {
2066
+ const transport = new StdioServerTransport();
2067
+ await server.connect(transport);
2068
+ console.error("Zendesk MCP server running via stdio");
2069
+ };
2070
+
2071
+ // src/index.ts
2072
+ var main = async () => {
2073
+ const config = loadConfig();
2074
+ if (config.zendeskEmail && config.zendeskApiToken) {
2075
+ const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2076
+ const getToken = () => staticToken;
2077
+ const server = createMcpServer(config, getToken);
2078
+ await startStdioTransport(server);
2079
+ } else {
2080
+ const tokenStore = createTokenStore({
2081
+ subdomain: config.subdomain,
2082
+ oauthClientId: config.oauthClientId
2083
+ });
2084
+ const server = createMcpServer(config, tokenStore.getToken);
2085
+ await startStdioTransport(server);
2086
+ }
2087
+ };
2088
+ main().catch((error) => {
2089
+ console.error("Fatal error:", error);
2090
+ process.exit(1);
2091
+ });
2092
+ //# sourceMappingURL=index.js.map